diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index abb189a..b4850d9 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -413,6 +413,18 @@ pub struct CatalogAlbum { /// Only set once album details (hence the tracklist) are known. #[serde(default)] pub is_live: bool, + /// True when MusicBrainz types the release group `Mixtape/Street`. + /// + /// Deezer has no mixtape record type at all, and for several artists the + /// mixtape IS the canonical work — Death Grips' "Exmilitary" is their + /// debut, and Chance the Rapper's "Acid Rap" and "Coloring Book" carry + /// more scrobbles than anything he has released as an album. They cannot + /// simply join the Albums grid, though: MusicBrainz files 118 of them for + /// Gucci Mane and 51 for Lil Wayne, which would bury a 15-record + /// discography. The flag exists so the frontend can give them their own + /// tab, where volume costs nothing and nothing has to be hidden. + #[serde(default)] + pub is_mixtape: 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 @@ -4209,14 +4221,26 @@ impl MetadataClient { // socials lookup was ~1 s of extra time-to-correctly-sorted on every // cold artist. They now ride [`Self::catalog_artist_socials`], which // the frontend calls only after this merge-critical response lands. - let (albums, type_hints) = tokio::join!( + let (albums, mixtapes, type_hints) = tokio::join!( self.musicbrainz_studio_albums(artist, &mbid), + self.musicbrainz_mixtapes(artist, &mbid), self.musicbrainz_type_hints(&mbid) ); + // One list from here on: a mixtape is a supplemented release like any + // other and carries its own `is_mixtape` flag, so everything + // downstream — the name-credit scope check, the seen/variant filters, + // the grid — treats it exactly as it treats an injected album. + let mixtape_count = mixtapes.len(); + let albums: Vec = albums.into_iter().chain(mixtapes).collect(); stage_trace( "identity.mb_join", join_started, - &format!("albums={} hints={}", albums.len(), type_hints.len()), + &format!( + "albums={} mixtapes={} hints={}", + albums.len() - mixtape_count, + mixtape_count, + type_hints.len() + ), ); stage_trace("identity.total", identity_started, ""); CatalogArtistIdentity { @@ -4923,9 +4947,41 @@ impl MetadataClient { // Queen's "Zoom 1979", "In the Lap of the Queen", Taylor Swift's // "Lover The Secret Studio Sessions". Verified live: with the filter // Queen's supplement is exactly its 14 studio albums. - let query = urlencode(&format!( - "arid:{mbid} AND primarytype:album AND -secondarytype:* AND status:official" - )); + let query = format!("arid:{mbid} AND primarytype:album AND -secondarytype:* AND status:official"); + self.musicbrainz_release_groups(artist, &query, false).await + } + + /// Release groups MusicBrainz types `Mixtape/Street`, for the same + /// identity-anchored id. See [`CatalogAlbum::is_mixtape`] for why these are + /// worth fetching and why they cannot join the Albums grid. + /// + /// A SEPARATE request rather than a relaxed filter on the query above, and + /// the reason is the 100-item page cap. Asking one query for both + /// (`-secondarytype:live AND -secondarytype:compilation AND …`, the only + /// form MusicBrainz's Lucene actually honours — a bare + /// `(-secondarytype:* OR secondarytype:"Mixtape/Street")` returns either + /// nothing or mixtapes only) returned Gucci Mane's 87 mixtapes alongside + /// just 13 of his 15 studio albums: the mixtapes ate the page and pushed + /// two real records off it. Splitting them leaves the album supplement + /// byte-for-byte what it was, and costs one request on the DEFERRED + /// identity pass, disk cached for a day like every other MusicBrainz call. + async fn musicbrainz_mixtapes(&self, artist: &str, mbid: &str) -> Vec { + let query = format!( + "arid:{mbid} AND primarytype:album AND secondarytype:\"Mixtape/Street\" AND status:official" + ); + self.musicbrainz_release_groups(artist, &query, true).await + } + + /// Shared body of the two supplements above: run a release-group search, + /// register each group so its tracklist can be resolved later, and return + /// it as a catalog row. + async fn musicbrainz_release_groups( + &self, + artist: &str, + query: &str, + is_mixtape: bool, + ) -> Vec { + let query = urlencode(query); let url = format!("https://musicbrainz.org/ws/2/release-group/?query={query}&limit=100&fmt=json"); let Some(body) = self.musicbrainz_json(&url).await else { @@ -4991,6 +5047,7 @@ impl MetadataClient { edition_group: None, is_soundtrack: false, is_live: false, + is_mixtape, appears_on: false, }); } @@ -5091,6 +5148,9 @@ impl MetadataClient { edition_group: None, is_soundtrack: false, is_live: false, + // The opened page's own row; tab filing is decided by the grid row + // this was opened FROM, which carries the flag from the supplement. + is_mixtape: false, appears_on: false, }; Some(CatalogAlbumPage { album, tracks, album_plays: None, album_listeners: None, totals_summed: false }) @@ -5188,6 +5248,9 @@ impl MetadataClient { edition_group: None, is_soundtrack: false, is_live: false, + // The opened page's own row; tab filing is decided by the grid row + // this was opened FROM, which carries the flag from the supplement. + is_mixtape: false, appears_on: false, }; Some(CatalogAlbumPage { album, tracks, album_plays: None, album_listeners: None, totals_summed: false }) @@ -6380,6 +6443,9 @@ impl MetadataClient { edition_group: None, is_soundtrack: false, is_live: false, + // Apple has no mixtape record type either; only the + // MusicBrainz supplement sets this. + is_mixtape: false, appears_on: false, }); } @@ -6870,6 +6936,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), + // Deezer has no mixtape record type, so a Deezer-sourced row is never + // one. Only the MusicBrainz supplement sets this. + is_mixtape: false, // Needs the requested artist's id to mean anything, which this parser // does not have. Resolved in the album-details pass. appears_on: false, @@ -11411,6 +11480,7 @@ mod tests { edition_group: None, is_soundtrack: false, is_live: false, + is_mixtape: false, appears_on: false, }; let releases = [release(1, "single"), release(2, "album")]; @@ -12362,6 +12432,7 @@ mod tests { edition_group: None, is_soundtrack: false, is_live: false, + is_mixtape: false, appears_on: false, } } @@ -13818,6 +13889,7 @@ mod tests { edition_group: None, is_soundtrack: false, is_live: false, + is_mixtape: false, appears_on: false, } } @@ -13835,6 +13907,7 @@ mod tests { edition_group: None, is_soundtrack: false, is_live: false, + is_mixtape: false, appears_on: false, } } diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index c9e1a90..dba8889 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -1504,9 +1504,78 @@ async function applyArtistIdentity(artistId, active) { && !sequelTail(t.slice(base.length)), ); }; - const extra = identityAlbums.filter( - (a) => a?.title && !seen.has(normText(a.title)) && !coveredByVariant(a.title), - ); + // `normText` DELETES bracket contents, which is right for peer file + // matching and wrong here: MusicBrainz writes a qualifier after a colon + // where Deezer puts it in parentheses, so "World War 3 (Lean)" reduced + // to "worldwar3" while MB's "World War 3: Lean" reduced to + // "worldwar3lean" and the two never collided. The result was a second + // card for a record already on the page — and once mixtapes had a tab + // of their own, the same work appeared in Albums AND Mixtapes. + // + // This key keeps the qualifier and instead folds the three things the + // providers genuinely disagree about: the separator, the words + // introducing a number, and how that number is written. Measured on + // Gucci Mane it collapses ten such pairs, among them + // "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part Five", + // "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" / + // "Trap House Four". + // + // Single-letter roman numerals are deliberately NOT folded: "X", "V" + // and "I" are real album titles, and reading them as 10, 5 and 1 would + // merge unrelated records. + const ORDINALS = { + one: "1", two: "2", three: "3", four: "4", five: "5", + six: "6", seven: "7", eight: "8", nine: "9", ten: "10", + ii: "2", iii: "3", iv: "4", vi: "6", vii: "7", viii: "8", ix: "9", + }; + const releaseKey = (title) => + asciiFold(title ?? "") + .toLowerCase() + .replace(/[‘’']/g, "") + .replace(/\b(?:part|pt|vol|volume)\b\.?/g, " ") + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(" ") + .map((word) => ORDINALS[word] ?? word) + .join(""); + // Every album-like row already on the page, by that key, so a matched + // MB row can be recognised as the one on screen rather than injected + // beside it. + // Every row sharing a key, not just the first: a provider sometimes + // carries the same record twice under its own spellings ("Brick Factory + // 2" beside "Brick Factory, Vol. 2"). Typing only one of them would put + // one copy in Mixtapes and leave the other in Albums, which is a worse + // page than leaving both alone. + const existingByKey = new Map(); + for (const album of current.albums ?? []) { + if (album.record_type === "single") continue; + const key = releaseKey(album.title); + if (!key) continue; + if (!existingByKey.has(key)) existingByKey.set(key, []); + existingByKey.get(key).push(album); + } + // A MusicBrainz mixtape that IS a row already on the page contributes + // its TYPE, not a card. The Deezer row wins the slot because it is the + // playable one — it carries a real album id and tracklist — and MB's + // Mixtape/Street verdict re-files it, exactly as `type_hints` re-files + // a mislabelled compilation. That is what moves "World War 3 (Lean)" + // out of Albums instead of leaving one copy in each tab. + const adoptedMixtapeIds = new Set(); + const extra = identityAlbums.filter((a) => { + if (!a?.title) return false; + const twins = a.is_mixtape ? existingByKey.get(releaseKey(a.title)) : undefined; + if (twins?.length) { + for (const twin of twins) { + if (!twin.is_mixtape) adoptedMixtapeIds.add(twin.id); + } + return false; + } + return ( + !seen.has(normText(a.title)) + && !existingByKey.has(releaseKey(a.title)) + && !coveredByVariant(a.title) + ); + }); const socials = identity?.socials ?? []; // MB's authoritative primary/secondary types. The view matches these // to displayed releases by title and re-files any the provider @@ -1554,14 +1623,22 @@ async function applyArtistIdentity(artistId, active) { const mbTitles = new Set(identityAlbums.map((a) => normText(a.title))); const owned = new Set(identity?.owned_album_ids ?? []); const foreign = new Set(identity?.foreign_album_ids ?? []); - const ownAlbums = (current.albums ?? []).filter((a) => { - if (foreign.has(a?.id)) return false; - if (!a?.appears_on) return true; - if (owned.has(a?.id)) return true; - // No MB albums = no arbiter = keep everything, so thin-coverage - // artists never lose real releases. - return identityAlbums.length ? mbTitles.has(normText(a.title)) : true; - }); + const ownAlbums = (current.albums ?? []) + .filter((a) => { + if (foreign.has(a?.id)) return false; + if (!a?.appears_on) return true; + if (owned.has(a?.id)) return true; + // No MB albums = no arbiter = keep everything, so thin-coverage + // artists never lose real releases. + return identityAlbums.length ? mbTitles.has(normText(a.title)) : true; + }) + // Rows MusicBrainz typed Mixtape/Street under a title Deezer spells + // differently. A new object rather than a mutation: `current.albums` + // is shared with the page replay cache and with whatever is already + // rendered, and this must not edit either in place. + .map((a) => + adoptedMixtapeIds.has(a?.id) ? { ...a, is_mixtape: true } : a, + ); const merged = { ...current, identity_merged: true, diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 1a7060d..fbe5629 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -2155,6 +2155,13 @@ // Announced full-lengths stay visible without being presented as part // of the released discography. They move automatically on release day. if (isUpcomingAlbum(album)) return "upcoming"; + // MusicBrainz's own Mixtape/Street typing, and the only thing that ever + // sets this flag. It outranks every heuristic below because the row + // exists for no other reason — the supplement fetched it BY that type, + // so there is nothing left for a title regex to decide. Above the + // description hint too: a mixtape's lead sentence routinely calls it a + // "compilation album", which would file Acid Rap under Compilations. + if (album.is_mixtape) return "mixtape"; // Editorial description evidence outranks every heuristic below: a // lead sentence that says "…is a compilation album by the Killers…" // is the record declaring its own kind, seen even when the title @@ -2281,6 +2288,11 @@ const TAB_DEFS = [ { key: "upcoming", label: "Upcoming", cats: ["upcoming"] }, { key: "album", label: "Albums", cats: ["album"] }, + // Sits beside Albums rather than down with the archival tabs: for the + // artists who have any, the mixtape is usually canon (Exmilitary is + // Death Grips' debut) rather than a footnote. Empty tabs are dropped, + // so it appears for nobody else. + { key: "mixtape", label: "Mixtapes", cats: ["mixtape"] }, { key: "ep", label: "EPs", cats: ["ep"] }, { key: "single", label: "Singles", cats: ["single"] }, { key: "live", label: "Live", cats: ["live"] }, @@ -2293,7 +2305,7 @@ const tabs = $derived.by(() => { if (!page) return []; const by = { - upcoming: [], album: [], live: [], soundtrack: [], compilation: [], + upcoming: [], album: [], mixtape: [], live: [], soundtrack: [], compilation: [], ep: [], single: [], remix: [], other: [], }; for (const album of page.albums) by[categoryFor(album)].push(album);