diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index aac1532..8f400d0 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -340,6 +340,22 @@ pub struct CatalogAlbum { /// Only set once album details (hence the tracklist) are known. #[serde(default)] pub is_live: bool, + /// True when Deezer's album details credit a DIFFERENT artist as the + /// release's primary artist — i.e. this artist is a contributor on someone + /// else's record. `/artist/{id}/albums` returns those alongside the + /// artist's own releases, which is how four Rooby Jeantal lo-fi records + /// arrived in Mid-Air Thief's discography. + /// + /// A flag rather than a filter, deliberately: Deezer marks EVERY + /// contributor `role: "Main"`, so a guest spot and a genuine joint album + /// (Kanye West on "Watch the Throne", primary artist JAY-Z) are + /// indistinguishable here. Only corroboration can separate them, and that + /// happens where the MusicBrainz identity is merged. + /// + /// Only set once album details are known; false means "own release, or not + /// yet checked". + #[serde(default)] + pub appears_on: bool, } #[derive(Debug, Clone, serde::Serialize)] @@ -3129,6 +3145,14 @@ impl MetadataClient { .filter(|a| { a.release_date.is_none() || (a.track_count.is_none() && feat_collision_ids.contains(&a.id)) + // EPs are pulled in for the ownership check: the list + // endpoint carries no artist object, so details are the only + // place a release's PRIMARY artist is visible, and a guest + // appearance is typed "ep" as often as "album" — the four + // Rooby Jeantal records in Mid-Air Thief's discography are + // all EPs, which is why an album-only pass never saw them. + // They already sort ahead of singles for the cap below. + || a.record_type.as_deref() == Some("ep") || (a.record_type.as_deref() == Some("album") && (a.track_count.is_none_or(|n| n <= 14) || is_edition_title(&a.title) @@ -3185,6 +3209,13 @@ impl MetadataClient { a.track_count = nb; } a.is_soundtrack = detail_is_soundtrack(d); + // The list endpoint carries no artist object at all, so this pass is + // the only place the release's PRIMARY artist is visible. A mismatch + // means the artist is a contributor on someone else's record. + a.appears_on = d + .pointer("/artist/id") + .and_then(|id| id.as_u64()) + .is_some_and(|primary| primary != artist_id); // These albums arrived from search (no tracklist); the details pass // is the first look at their tracks, so read live-ness here. Titles // like "Ballet Slippers" give no hint — the tracks do. @@ -4148,6 +4179,7 @@ impl MetadataClient { edition_group: None, is_soundtrack: false, is_live: false, + appears_on: false, }); } albums @@ -4242,6 +4274,7 @@ impl MetadataClient { edition_group: None, is_soundtrack: false, is_live: false, + appears_on: false, }; Some(CatalogAlbumPage { album, tracks }) } @@ -4333,6 +4366,7 @@ impl MetadataClient { edition_group: None, is_soundtrack: false, is_live: false, + appears_on: false, }; Some(CatalogAlbumPage { album, tracks }) } @@ -5204,6 +5238,7 @@ impl MetadataClient { edition_group: None, is_soundtrack: false, is_live: false, + appears_on: false, }); } if !registered.is_empty() { @@ -5604,6 +5639,9 @@ fn parse_album(v: &serde_json::Value) -> Option { // later from the album-details pass; a full album fetch (catalog_album) // does include /tracks/data and resolves it here directly. is_live: detail_is_live(v), + // Needs the requested artist's id to mean anything, which this parser + // does not have. Resolved in the album-details pass. + appears_on: false, }) } @@ -9222,6 +9260,7 @@ mod tests { edition_group: None, is_soundtrack: false, is_live: false, + appears_on: false, }; let releases = [release(1, "single"), release(2, "album")]; let matches = releases @@ -10092,6 +10131,7 @@ mod tests { edition_group: None, is_soundtrack: false, is_live: false, + appears_on: false, } } diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 7823b5e..6621b74 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -1015,7 +1015,11 @@ export function invalidateCatalogArtist(artistId) { // `art_preview: true` (the full page still lands) and set `replayed: true` // (the overview stage must not overwrite final classified albums with its // raw first-50). -const PAGE_REPLAY_RECIPE = "replay-v1"; +// v2: pages saved before guest appearances were filtered are stamped +// `identity_merged`, so they would skip the pass that drops them and keep +// rendering another artist's records. Cheap to discard — the replay only buys +// an instant first paint, and a miss is one ordinary load. +const PAGE_REPLAY_RECIPE = "replay-v2"; const PAGE_REPLAY_LIMIT = 16; // Matches the backend's provider disk-cache TTL on purpose: a replay must // never outlive the MusicBrainz warmth it was saved with, or the identity @@ -1483,13 +1487,38 @@ async function applyArtistIdentity(artistId, active) { // to displayed releases by title and re-files any the provider // mislabeled "album" (compilations, soundtracks…). const typeHints = identity?.type_hints ?? []; + // Guest appearances, dropped only where MusicBrainz can arbitrate. + // + // Deezer's /artist/{id}/albums returns records the artist merely + // contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air + // Thief's discography that way. The backend flags them (`appears_on`: + // the release's primary artist is someone else), but that flag ALONE + // must never delete a row: Deezer marks every contributor "Main", so a + // guest spot looks exactly like a genuine joint album — Kanye West's + // "Watch the Throne" carries primary artist JAY-Z and would be wrongly + // erased from his page. + // + // MusicBrainz separates them, because it credits a real collaboration + // under BOTH artists: querying Kanye's own MBID returns "Watch the + // Throne", while Mid-Air Thief's returns only his two real albums. So a + // flagged row survives if MB credits it and is dropped if MB does not + // — and ONLY when MB actually answered for this artist. No MB albums + // means no arbiter, and every row is kept rather than guessed at, which + // keeps thin-coverage artists (the obscure end, where this matters + // most) from losing real releases. + const mbTitles = new Set(identityAlbums.map((a) => normText(a.title))); + const ownAlbums = identityAlbums.length + ? (current.albums ?? []).filter( + (a) => !a?.appears_on || mbTitles.has(normText(a.title)), + ) + : (current.albums ?? []); const merged = { ...current, identity_merged: true, albums: extra.length - ? [...current.albums, ...extra].sort((a, b) => + ? [...ownAlbums, ...extra].sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? "")) - : current.albums, + : ownAlbums, socials: socials.length ? socials : current.socials, typeHints: typeHints.length ? typeHints : current.typeHints, };