Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/rfcs/RFC-011-metadata-enrichment.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
key), #270 (the pin moved onto them), #271 (the cache and the engine that
fills it), #272 (`getArtistInfo`, `getArtistInfo2` and the native artist
page read it), #273 and #274 (`getSimilarSongs` and `getSimilarSongs2` draw
from it), and the pull request closing #268 (portraits from Deezer). The web
client and the compatibility campaign are still to come. The *Status* field
from it), #275 (portraits from Deezer), and the pull request closing #269
(the web client's artist page, and `enrichment.image` on the native API,
which #275 had left out). The compatibility campaign is still to come. The
*Status* field
above never flips in this project; this line is the one that says what is
running.
- **Date**: 2026-10-14
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
services::EnrichmentState,
services::EnrichmentSource,
services::EnrichedBio,
services::EnrichedImage,
services::EnrichedSimilar,
services::SearchResult,
services::PlaylistItem,
Expand Down
13 changes: 12 additions & 1 deletion src/services/artist_enrichment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,22 @@ impl DomainServices {
.neighbours(user_id, &rows, &canonical, similar_limit)
.await?;
let bio = merged_bio(&rows);
let image = merged_image(&rows);
// Only a fresh row is an answer. An expired one has just had its
// refresh scheduled, and an expired miss is not "nobody knows" but
// "being asked again" — `pending`, as decision 11 defines it. Stale
// content still shows, and `ready` wins over both.
let now = crate::authentication::now_ms();
let answered = rows.iter().filter(|row| row.expires_at > now).count();
let state = state_of(
bio.is_some() || similar.is_some(),
bio.is_some() || image.is_some() || similar.is_some(),
answered,
engine.provider_count(),
);
Ok(ArtistEnrichment {
state,
bio,
image,
similar,
})
}
Expand Down Expand Up @@ -321,6 +323,15 @@ fn merged_bio(rows: &[CachedArtist]) -> Option<EnrichedBio> {
})
}

fn merged_image(rows: &[CachedArtist]) -> Option<EnrichedImage> {
rows.iter().find_map(|row| {
row.image_hash.as_ref().map(|hash| EnrichedImage {
artwork_hash: hash.clone(),
source: source_of(row),
})
})
}

/// Content wins over everything; without it, a provider that has not
/// answered makes the lookup `pending`, and only when every one of them has
/// answered is it `none`.
Expand Down
11 changes: 11 additions & 0 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,8 @@ pub struct ArtistEnrichment {
pub state: EnrichmentState,
/// The first configured provider with a biography supplies it.
pub bio: Option<EnrichedBio>,
/// The first configured provider with a portrait supplies it.
pub image: Option<EnrichedImage>,
/// The first configured provider whose neighbours include artists the
/// caller can see supplies the list — those artists only, best first.
pub similar: Option<EnrichedSimilar>,
Expand All @@ -966,6 +968,7 @@ impl ArtistEnrichment {
Self {
state: EnrichmentState::Disabled,
bio: None,
image: None,
similar: None,
}
}
Expand Down Expand Up @@ -1003,6 +1006,14 @@ pub struct EnrichedBio {
pub source: EnrichmentSource,
}

/// A portrait, as a key into the artwork store.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EnrichedImage {
/// Served by the artwork route, as every `artwork_hash` is.
pub artwork_hash: String,
pub source: EnrichmentSource,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EnrichedSimilar {
pub artists: Vec<ArtistSummary>,
Expand Down
18 changes: 18 additions & 0 deletions tests/enrichment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,24 @@ async fn a_portrait_is_fetched_once_and_shown_on_every_artist_with_the_name() {
assert_eq!(source, "deezer");
assert_eq!(artist_events(&state).await, 2);
assert_eq!(listener.fake.images.load(Ordering::SeqCst), 1);

// Decision 11: the native page names where the portrait came from.
state.services.use_enrichment(enrichment);
let bjork = artist_id(&state, owner, "Björk").await;
let enriched = state
.services
.artist_page(owner, bjork)
.await
.unwrap()
.enrichment
.expect("the native page always carries it");
let image = enriched.image.expect("the portrait is published");
assert!(image.artwork_hash == hash, "another portrait was published");
assert_eq!(image.source.provider, "deezer");
assert_eq!(
enriched.state,
waveflow_server::services::EnrichmentState::Ready
);
}

/// A picture URL naming a host off the allowlist is dropped without being
Expand Down
124 changes: 123 additions & 1 deletion webapp/e2e/studio-nocturne.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,15 @@ const artistDetail = {
let artistPlayed: Array<ReturnType<typeof song>> = [];
/** The limits the page asked for, so a screenful can be told from a catalogue. */
let topSongAsks: Array<string | null> = [];
/** How many times the artist page was asked for. */
let artistAsks = 0;
/**
* The `enrichment` object of the artist's nth answer, counting from one.
* Undefined leaves it out, as a server from before RFC-011 does.
*/
let enrichmentOf: (ask: number) => unknown = () => undefined;
/** Every artist answer after the first is a 500. */
let artistFailsAfterFirst = false;

/**
* A dry run's answer: three of five entries placed, two it could not.
Expand Down Expand Up @@ -1032,7 +1041,14 @@ async function mockAuthenticatedApi(page: Page) {
return;
}
if (url.pathname === "/api/v2/artists/artist-1") {
await route.fulfill({ json: artistDetail });
artistAsks += 1;
if (artistFailsAfterFirst && artistAsks > 1) {
await route.fulfill({ status: 500, json: { error: "internal" } });
return;
}
await route.fulfill({
json: { ...artistDetail, enrichment: enrichmentOf(artistAsks) },
});
return;
}
if (url.pathname === "/api/v2/albums") {
Expand Down Expand Up @@ -1061,6 +1077,9 @@ test.beforeEach(async ({ page }) => {
radioGate = Promise.resolve();
artistPlayed = [song(2, "Cocoon", 0, false), song(1, "Hidden Place", 5, true)];
topSongAsks = [];
artistAsks = 0;
enrichmentOf = () => undefined;
artistFailsAfterFirst = false;
// **Reset here, and they were not.** These four reached the mock without
// reaching this block, because a `replace` whose needle had already been
// reformatted did nothing and said nothing. So `subsonicWrites`
Expand Down Expand Up @@ -1535,6 +1554,109 @@ test("an artist page shows this account's most played, and no heading without a
).toHaveCount(0);
});

/**
* RFC-011 decision 11 on the screen: what a provider said, and which one said
* it. The first answer is `pending`, as an artist's first visit is; the page
* asks again on its own, since the web client reads no event stream, and the
* second answer fills it.
*/
test("an artist page shows what the server learned, and where each part came from", async ({
page,
}) => {
const lastfm = { provider: "lastfm", fetched_at: Date.UTC(2026, 9, 14) };
enrichmentOf = (ask) =>
ask === 1
? { state: "pending", bio: null, image: null, similar: null }
: {
state: "ready",
bio: {
summary: "An Icelandic singer and songwriter.",
full: "An Icelandic singer and songwriter.\nHer albums are many.",
source: lastfm,
},
image: {
artwork_hash: "a".repeat(64),
source: { provider: "deezer", fetched_at: Date.UTC(2026, 9, 15) },
},
similar: {
artists: [
{
id: "artist-2",
name: "Sigur Rós",
album_count: 1,
artwork_hash: null,
},
],
source: lastfm,
},
};
// Only the portrait's hash answers with an image: the artist row names
// none, so the hero can show it only through `enrichment.image`.
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
await page.route(`**/api/v2/artwork/${"a".repeat(64)}`, async (route) => {
await route.fulfill({ contentType: "image/png", body: png });
});

await page.goto("/artists/artist-1");
await expect(page.getByRole("status")).toContainText(
"Looking this artist up",
);
await expect(page.getByRole("heading", { name: "About" })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("An Icelandic singer and songwriter.").first()).toBeVisible();
await expect(page.getByText(/^Biography from Last\.fm, /)).toBeVisible();
await expect(page.getByText(/^Portrait from Deezer, /)).toBeVisible();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await expect(page.locator(".artist-hero img.artist-portrait")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Similar artists in your library" }),
).toBeVisible();
await expect(page.getByRole("link", { name: /Sigur Rós/ })).toBeVisible();
await expect(page.getByText(/^Suggested by Last\.fm, /)).toBeVisible();
await expect(page.getByText("Looking this artist up")).toHaveCount(0);
expect(artistAsks).toBe(2);

const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations).toEqual([]);
});

/** `none` is an answer: nothing is shown, and nothing is asked again. */
test("an artist nobody knows shows no enrichment and is not asked again", async ({
page,
}) => {
enrichmentOf = () => ({ state: "none", bio: null, image: null, similar: null });
await page.goto("/artists/artist-1");
await expect(page.getByRole("link", { name: "Open the album: Vespertine" })).toBeVisible();
await page.waitForTimeout(4_000);
await expect(page.getByRole("heading", { name: "About" })).toHaveCount(0);
await expect(page.getByText("Looking this artist up")).toHaveCount(0);
expect(artistAsks).toBe(1);
});

/**
* A server that fails after a `pending` answer leaves that answer on screen,
* and a failed ask does not move the count of answers. The failures are
* counted with them, so the page still stops at the bound.
*/
test("a pending artist whose server keeps failing is asked a bounded number of times", async ({
page,
}) => {
test.setTimeout(45_000);
artistFailsAfterFirst = true;
enrichmentOf = () => ({ state: "pending", bio: null, image: null, similar: null });
await page.goto("/artists/artist-1");
await expect(page.getByRole("heading", { name: "Björk", level: 2 })).toBeVisible();
await expect.poll(() => artistAsks, { timeout: 30_000 }).toBe(6);
await page.waitForTimeout(5_000);
expect(artistAsks).toBe(6);
await expect(page.getByText("Looking this artist up")).toHaveCount(0);
});

/**
* `GET /history` answers plays and not songs, so every distinct track on this
* screen is a request of its own, and they all leave together. Without a cap a
Expand Down
32 changes: 31 additions & 1 deletion webapp/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,37 @@ export type Artist = {
artwork_hash: string | null;
};

export type ArtistDetail = Artist & { albums: Album[] };
/** Where one enriched field came from — RFC-011 decision 11. */
export type EnrichmentSource = {
/** As `WAVEFLOW_METADATA_PROVIDERS` spells it: `lastfm`, `deezer`. */
provider: string;
/** When the provider gave this answer, in epoch milliseconds. */
fetched_at: number;
};

/**
* What the server learned about an artist from outside the library.
*
* `pending` means a provider has not answered yet, `none` that every one
* answered with nothing, `disabled` that none is configured. The page asks
* again while `pending`, a bounded number of times.
*/
export type ArtistEnrichment = {
state: "disabled" | "pending" | "ready" | "none";
bio: {
summary: string | null;
full: string | null;
source: EnrichmentSource;
} | null;
image: { artwork_hash: string; source: EnrichmentSource } | null;
similar: { artists: Artist[]; source: EnrichmentSource } | null;
};

export type ArtistDetail = Artist & {
albums: Album[];
/** Always present on the native page; optional so an older server parses. */
enrichment?: ArtistEnrichment;
};

export type SearchResult = {
artists: Artist[];
Expand Down
14 changes: 14 additions & 0 deletions webapp/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ const en = {
other: "{count} albums in your library",
},
"artists.mostPlayed": "Most played here",
"artists.about": "About",
"artists.readMore": "Read more",
"artists.similar": "Similar artists in your library",
"artists.enrichmentPending": "Looking this artist up…",
"artists.bioSource": "Biography from {provider}, {date}.",
"artists.similarSource": "Suggested by {provider}, {date}.",
"artists.portraitSource": "Portrait from {provider}, {date}.",
"favourites.detail": {
one: "{count} saved track",
other: "{count} saved tracks",
Expand Down Expand Up @@ -718,6 +725,13 @@ const fr: Record<TranslationKey, TranslationValue> = {
other: "{count} albums dans votre bibliothèque",
},
"artists.mostPlayed": "Les plus écoutés ici",
"artists.about": "À propos",
"artists.readMore": "Lire la suite",
"artists.similar": "Artistes proches dans votre bibliothèque",
"artists.enrichmentPending": "Recherche d’informations sur cet artiste…",
"artists.bioSource": "Biographie fournie par {provider}, le {date}.",
"artists.similarSource": "Suggérés par {provider}, le {date}.",
"artists.portraitSource": "Portrait fourni par {provider}, le {date}.",
"favourites.detail": {
one: "{count} piste enregistrée",
other: "{count} pistes enregistrées",
Expand Down
Loading
Loading