discography: give mixtapes a tab, and stop the provider spelling it twice

Death Grips' "Exmilitary" was missing from the app entirely. Deezer never
carried it, and MusicBrainz has it but types the release group Mixtape/Street,
which the studio-album supplement excluded along with every other secondary
type. The same exclusion hid Chance the Rapper's "Acid Rap" and "Coloring
Book" — records that outscrobble everything he has released as an album.

They cannot simply join Albums. MusicBrainz files 118 Mixtape/Street groups for
Gucci Mane and 51 for Lil Wayne, which buries a 15-record discography, and no
significance gate separates the canon from the ephemera: measured on Last.fm,
Exmilitary sits at 19% of Death Grips' biggest record while Gucci Mane's
"Chicken Talk" sits at 20% of his. The two are indistinguishable by weight
because both are real. So the answer is filing, not filtering — a tab of their
own, where volume costs nothing and nothing has to be hidden. It sits beside
Albums rather than down with the archival tabs because for the artists who have
any, the mixtape is usually the canon. Empty tabs are already dropped, so
Queen and Radiohead never see it.

A SEPARATE query rather than a relaxed filter, and the page cap is the reason.
MusicBrainz returns 100 items, and asking one query for both — via
`-secondarytype:live AND -secondarytype:compilation AND …`, the only form its
Lucene actually honours, since a bare
`(-secondarytype:* OR secondarytype:"Mixtape/Street")` returns either nothing
or mixtapes only — came back with Gucci Mane's 87 mixtapes and just 13 of his
15 studio albums. The mixtapes ate the page and pushed two real records off it.
Split, the album supplement is byte-for-byte what it was, and the cost is one
request on the DEFERRED identity pass, disk cached for a day like every other
MusicBrainz call.

The tab then exposed a dedup hole that had been there all along.

`normText` DELETES bracket contents, which is correct for the peer file
matching it was written for and wrong for release identity. 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". They could never collide, so the supplement
injected a second card for a record already on the page — and with mixtapes now
filed apart, the same work appeared in Albums AND Mixtapes. Ten such pairs on
Gucci Mane, among them "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part
Five", "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" / "Trap House
Four".

The identity merge gets its own key, which keeps the qualifier and folds the
three things the providers genuinely disagree about: the separator, the words
introducing a number, and whether that number is a digit, a word or a roman
numeral. Single-letter romans are deliberately NOT folded — "X", "V" and "I"
are real album titles and reading them as 10, 5 and 1 would merge unrelated
records.

And a matched mixtape contributes its TYPE, not a card. The Deezer row keeps
the slot because it is the playable one — real album id, real tracklist — and
MusicBrainz's verdict re-files it, exactly as `type_hints` already re-files a
mislabelled compilation. That is what moves "World War 3 (Lean)" out of Albums
instead of leaving one copy in each tab. It is also why Gucci Mane's Albums
count falls 146 -> 73: those rows were ALREADY being swallowed by the old
title check and left sitting in Albums untyped, so this does not hide them, it
finally classifies them.

Every row sharing a key adopts together, not just the first. A provider
sometimes carries one record twice under its own spellings ("Brick Factory 2"
beside "Brick Factory, Vol. 2"); typing one of them and not the other put one
copy in Mixtapes and left the other in Albums, which is a worse page than
leaving both alone.

Measured live: Gucci Mane 252 -> 241 rows with cross-tab duplicates 10 -> 0,
Death Grips one mixtape and it is Exmilitary, Queen and Radiohead unchanged
with no Mixtapes tab rendered. 75 Rust tests and 85 JS tests pass.

Still open, and older than this change: Deezer carries "Brick Factory 2" and
"Brick Factory, Vol. 2" as two rows, and "Trap House 3" beside "Trap House
III". This key would collapse them, but that means deleting a row the page
currently shows, which wants measuring across a range of artists first.
This commit is contained in:
KeelrLeek committed 2026-07-28 10:28:34 -05:00
1 parent 388ad7d910
commit 644a0192a5
3 files changed
+179 -17

No files matched your search

+78 -5
View File
@@ -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<CatalogAlbum> = 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<CatalogAlbum> {
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<CatalogAlbum> {
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<CatalogAlbum> {
// 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,
}
}
+88 -11
View File
@@ -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,
+13 -1
View File
@@ -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);