release ownership: one Deezer id can be seven artists
There is exactly one Deezer artist called "Wand" (1213178) and it holds the LA psych band, a Brazilian funk MC, an Indonesian act, an Indian one, a 2020 house project and whoever released Hard Knox in 2009 — 46 of its 72 rows are somebody else's. Ranking search results cannot help (there is one candidate) and neither can credit role (every row is primary-credited to it). The Apple namesake filter that should have caught this switched itself off: APPLE_EXACT_ARTIST_CANDIDATE_LIMIT is 8 and Apple has TEN artists named Wand, so the candidate fetch failed closed and every catalogue came back empty. It fails closed for a good reason at page-load time — it picks the winner by overlap with the requested artist's own Deezer releases, which on a fused record are partly the co-tenants'. Circular evidence has to fail closed. So the answer is not to raise that cap but to stop asking the fused list. artist_release_ownership runs in the DEFERRED identity pass, where the linked MBID has already named the artist, and picks the Apple performer that reproduces THAT catalogue. Non-circular, so 20 candidates is safe. Evidence is MB release groups of every type, the verified Apple catalogue, and the label sets of both (MB at one sighting, Apple at two — Apple files two rafftheraff singles under the LA band). Pruning only runs where a row is provably carried by a different same-named Apple artist, so the gate opens on 4 of the 55 artists measured and stays shut on Wednesday's twenty namesakes, Jungle's sixteen and Roosevelt's fourteen. The same evidence repairs the opposite failure. The guest guard keeps an appears_on row only if MB credits it to the artist's own MBID, which assumes MB mirrors collaborations. It does for Watch the Throne, whose artist CREDIT names both. It does not when the collaboration is its own entity: VULTURES 1, VULTURES 2 and KIDS SEE GHOSTS appear nowhere in Ye's 315 release groups, and all three were being erased from his page. Apple's artistName credit, read as a list of principals, restores them without restoring guest spots — Estelle's "American Boy (feat. Kanye West)" is still hers. A fused record's PORTRAIT is wrong for the same reason its releases are: it is whichever co-tenant uploaded last, a real photograph of the wrong person, so deezerPortraitMissing cannot see it. The verdict is persisted, because the search dropdown is typed against per keystroke and can never afford the lookups that produced it. Its store is deliberately not in net-cache-v1 — prune_disk_cache deletes every json there by mtime, and a verdict is not a cached response. Measured live: Wand 72 -> 26, Hot Toddy 36 -> 16, Crazy P 68 -> 51, Turnover 30 -> 25, Kanye 50 -> 64; Weezer, Pond, Billy Cobb, JPEGMAFIA and Daft Punk untouched. The page's "wand." wordmark went with the co-tenants and should have: it was traced from the Indonesian single's cover.
This commit is contained in:
1 parent
8e623e3d8d
commit
4d6e69e324
6 files changed
+892
-64
No files matched your search
+772
-57
File diff suppressed because it is too large.
Load diff
@@ -1536,6 +1536,26 @@ pub async fn catalog_artist_bio(
|
|||||||
Ok(state.metadata.artist_bio(artist_id, &artist).await)
|
Ok(state.metadata.artist_bio(artist_id, &artist).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist "this Deezer artist record holds more than one act, show this
|
||||||
|
/// portrait instead".
|
||||||
|
///
|
||||||
|
/// The verdict costs rate-limited MusicBrainz and Apple lookups, so the search
|
||||||
|
/// bar — typed against per keystroke — can only ever consult a remembered one.
|
||||||
|
/// The frontend calls this once the identity merge has proven the record fused
|
||||||
|
/// AND the identity-gated hero has resolved, so every later surface (suggestion
|
||||||
|
/// dropdown, search results, related artists, Now Playing) agrees for free.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remember_fused_artist_portrait(
|
||||||
|
state: State<'_, LibraryState>,
|
||||||
|
artist_id: u64,
|
||||||
|
portrait: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.metadata
|
||||||
|
.remember_fused_portrait(artist_id, portrait);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Footer links, fetched only after the sorting-critical identity merge has
|
/// Footer links, fetched only after the sorting-critical identity merge has
|
||||||
/// landed — one MusicBrainz request that used to cost every cold artist ~1 s
|
/// landed — one MusicBrainz request that used to cost every cold artist ~1 s
|
||||||
/// of time-to-correctly-sorted inside the identity join.
|
/// of time-to-correctly-sorted inside the identity join.
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub fn run() {
|
|||||||
commands::catalog_artist_logos,
|
commands::catalog_artist_logos,
|
||||||
commands::catalog_artist_identity,
|
commands::catalog_artist_identity,
|
||||||
commands::catalog_artist_socials,
|
commands::catalog_artist_socials,
|
||||||
|
commands::remember_fused_artist_portrait,
|
||||||
commands::catalog_artist_bio,
|
commands::catalog_artist_bio,
|
||||||
commands::catalog_cached_cover_logo,
|
commands::catalog_cached_cover_logo,
|
||||||
commands::catalog_cover_logo,
|
commands::catalog_cover_logo,
|
||||||
|
|||||||
@@ -151,7 +151,12 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
|
|||||||
// Wikipedia) so a restarted app keeps its supplement/type-hint and
|
// Wikipedia) so a restarted app keeps its supplement/type-hint and
|
||||||
// description warmth instead of re-serializing MusicBrainz at 1 req/s.
|
// description warmth instead of re-serializing MusicBrainz at 1 req/s.
|
||||||
metadata: Arc::new({
|
metadata: Arc::new({
|
||||||
let client = MetadataClient::new().with_disk_cache(data_dir.join("net-cache-v1"));
|
let client = MetadataClient::new()
|
||||||
|
.with_disk_cache(data_dir.join("net-cache-v1"))
|
||||||
|
// Its own directory, NOT inside the cache above: that one is
|
||||||
|
// pruned by mtime, and these are verdicts rather than cached
|
||||||
|
// responses.
|
||||||
|
.with_identity_store(data_dir.join("artist-identity-v1"));
|
||||||
// Hero candidate/scoring diagnostics on demand, so an in-APP hero
|
// Hero candidate/scoring diagnostics on demand, so an in-APP hero
|
||||||
// resolution can be compared against `hero_source_probe` — the two
|
// resolution can be compared against `hero_source_probe` — the two
|
||||||
// do not always see the same candidate set, and only the app's own
|
// do not always see the same candidate set, and only the app's own
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export const addToPlaylist = (playlistId, tracks) =>
|
|||||||
export const removeFromPlaylist = (playlistId, position) =>
|
export const removeFromPlaylist = (playlistId, position) =>
|
||||||
invoke("remove_from_playlist", { playlistId, position });
|
invoke("remove_from_playlist", { playlistId, position });
|
||||||
export const catalogSearch = (query) => invoke("catalog_search", { query });
|
export const catalogSearch = (query) => invoke("catalog_search", { query });
|
||||||
|
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
||||||
|
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
||||||
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
|
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
|
||||||
export const catalogArtistArt = (artistId) =>
|
export const catalogArtistArt = (artistId) =>
|
||||||
checkedArtistPayload("catalog_artist_art", artistId);
|
checkedArtistPayload("catalog_artist_art", artistId);
|
||||||
|
|||||||
+91
-6
@@ -1019,7 +1019,9 @@ export function invalidateCatalogArtist(artistId) {
|
|||||||
// `identity_merged`, so they would skip the pass that drops them and keep
|
// `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
|
// rendering another artist's records. Cheap to discard — the replay only buys
|
||||||
// an instant first paint, and a miss is one ordinary load.
|
// an instant first paint, and a miss is one ordinary load.
|
||||||
const PAGE_REPLAY_RECIPE = "replay-v2";
|
// v3: same argument for release ownership. A v2 replay carries a fused
|
||||||
|
// record's co-tenants and its wrong portrait, both stamped as merged.
|
||||||
|
const PAGE_REPLAY_RECIPE = "replay-v3";
|
||||||
const PAGE_REPLAY_LIMIT = 16;
|
const PAGE_REPLAY_LIMIT = 16;
|
||||||
// Matches the backend's provider disk-cache TTL on purpose: a replay must
|
// 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
|
// never outlive the MusicBrainz warmth it was saved with, or the identity
|
||||||
@@ -1511,15 +1513,45 @@ async function applyArtistIdentity(artistId, active) {
|
|||||||
// means no arbiter, and every row is kept rather than guessed at, which
|
// means no arbiter, and every row is kept rather than guessed at, which
|
||||||
// keeps thin-coverage artists (the obscure end, where this matters
|
// keeps thin-coverage artists (the obscure end, where this matters
|
||||||
// most) from losing real releases.
|
// most) from losing real releases.
|
||||||
|
//
|
||||||
|
// The title check alone is not enough, and Kanye is the proof. MB files
|
||||||
|
// a collaboration under both parties only when the credit NAMES both
|
||||||
|
// ("Watch the Throne"); when the collaboration is its own MB entity it
|
||||||
|
// does not, and neither VULTURES 1, VULTURES 2 nor KIDS SEE GHOSTS
|
||||||
|
// appears anywhere in Ye's 315 release groups. All three were being
|
||||||
|
// erased from his page by the very rule written to protect them.
|
||||||
|
// `owned_album_ids` is the backend's wider answer to the same question
|
||||||
|
// — MB release groups of every type plus the MB-verified Apple
|
||||||
|
// catalogue — so a real collaboration survives on evidence instead of
|
||||||
|
// on MusicBrainz happening to duplicate the credit.
|
||||||
|
//
|
||||||
|
// `foreign_album_ids` is the other half: releases that belong to a
|
||||||
|
// DIFFERENT act sharing this Deezer artist record. Deezer files several
|
||||||
|
// unrelated performers under one id (there is exactly one Deezer
|
||||||
|
// "Wand", and it holds an LA psych band, a Brazilian funk MC, an
|
||||||
|
// Indonesian act and four more), which no amount of search ranking or
|
||||||
|
// credit-role reading can undo. The backend only fills this in when it
|
||||||
|
// can NAME the co-tenant, so on an ordinary artist it is empty and the
|
||||||
|
// page is untouched.
|
||||||
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
|
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
|
||||||
const ownAlbums = identityAlbums.length
|
const owned = new Set(identity?.owned_album_ids ?? []);
|
||||||
? (current.albums ?? []).filter(
|
const foreign = new Set(identity?.foreign_album_ids ?? []);
|
||||||
(a) => !a?.appears_on || mbTitles.has(normText(a.title)),
|
const ownAlbums = (current.albums ?? []).filter((a) => {
|
||||||
)
|
if (foreign.has(a?.id)) return false;
|
||||||
: (current.albums ?? []);
|
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 merged = {
|
const merged = {
|
||||||
...current,
|
...current,
|
||||||
identity_merged: true,
|
identity_merged: true,
|
||||||
|
// A fused record's Deezer PORTRAIT is untrustworthy for exactly the
|
||||||
|
// reason its releases are: it is whichever co-tenant uploaded last.
|
||||||
|
// Wand's is a different artist entirely, and `deezerPortraitMissing`
|
||||||
|
// cannot catch it — it is a real photograph, just not of this band.
|
||||||
|
fused_record: !!identity?.fused_record,
|
||||||
albums: extra.length
|
albums: extra.length
|
||||||
? [...ownAlbums, ...extra].sort((a, b) =>
|
? [...ownAlbums, ...extra].sort((a, b) =>
|
||||||
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
|
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
|
||||||
@@ -1528,6 +1560,7 @@ async function applyArtistIdentity(artistId, active) {
|
|||||||
typeHints: typeHints.length ? typeHints : current.typeHints,
|
typeHints: typeHints.length ? typeHints : current.typeHints,
|
||||||
};
|
};
|
||||||
catalog.artistPage = merged;
|
catalog.artistPage = merged;
|
||||||
|
if (merged.fused_record) void disownFusedPortrait(artistId, merged);
|
||||||
// Write the supplemented page back into the session cache: a warm
|
// Write the supplemented page back into the session cache: a warm
|
||||||
// revisit must not render less than the cold visit did. Overwriting
|
// revisit must not render less than the cold visit did. Overwriting
|
||||||
// a pending entry is safe — cachedArtistRequest only installs its
|
// a pending entry is safe — cachedArtistRequest only installs its
|
||||||
@@ -2094,6 +2127,58 @@ async function deezerPortraitMissing(url) {
|
|||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace the portrait of an artist whose Deezer record was proven to hold
|
||||||
|
* more than one act.
|
||||||
|
*
|
||||||
|
* `deezerPortraitMissing` above catches the grey silhouette. It cannot catch
|
||||||
|
* this: a fused record's `picture` is a genuine photograph at genuine
|
||||||
|
* dimensions of a genuine artist — just not the one the page is about. Wand's
|
||||||
|
* is the co-tenant who uploaded last, sitting above a biography of the LA
|
||||||
|
* psych band. The hero the artist page already resolved is identity-gated, so
|
||||||
|
* it is the honest replacement; if there is none, no picture beats a wrong one.
|
||||||
|
*
|
||||||
|
* Both surfaces are corrected — the page's blurred instant-shell seed and the
|
||||||
|
* Now Playing "About the artist" card, which is where the wrong face is
|
||||||
|
* actually legible. */
|
||||||
|
async function disownFusedPortrait(artistId, merged) {
|
||||||
|
const suspect = merged.artist?.picture ?? null;
|
||||||
|
const seed = await getCatalogArtistHeroSeed(artistId).catch(() => null);
|
||||||
|
const replacement = seed?.banner ?? merged.banner ?? null;
|
||||||
|
const current = catalog.artistPage;
|
||||||
|
if (current && sameArtistId(current.artist?.id, artistId)) {
|
||||||
|
const corrected = {
|
||||||
|
...current,
|
||||||
|
artist: { ...current.artist, picture: replacement },
|
||||||
|
placeholder_picture: replacement,
|
||||||
|
};
|
||||||
|
catalog.artistPage = corrected;
|
||||||
|
// Same reason the identity merge writes itself back: a warm revisit
|
||||||
|
// must not resurrect what this pass just disproved.
|
||||||
|
const key = artistCacheKey(artistId);
|
||||||
|
if (key) {
|
||||||
|
catalogArtistPageCache.delete(key);
|
||||||
|
catalogArtistPageCache.set(key, corrected);
|
||||||
|
trimArtistRequestCache(catalogArtistPageCache);
|
||||||
|
}
|
||||||
|
rememberArtistSeed(corrected.artist);
|
||||||
|
savePageReplay(artistId, corrected);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
discovery.picture
|
||||||
|
&& suspect
|
||||||
|
&& discovery.picture === suspect
|
||||||
|
&& sameArtistId(discovery.artistId, artistId)
|
||||||
|
) discovery.picture = replacement;
|
||||||
|
// Hand the verdict to the backend so the surfaces that CANNOT afford to
|
||||||
|
// re-derive it agree too — above all the suggestion dropdown, which is
|
||||||
|
// typed against per keystroke and is where the wrong face is met first.
|
||||||
|
try {
|
||||||
|
await api.rememberFusedArtistPortrait(artistId, replacement);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: this session is already correct without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Short artist bio, resolved by the backend through the artist's OWN
|
/** Short artist bio, resolved by the backend through the artist's OWN
|
||||||
* MusicBrainz→Wikidata identity (see `artist_bio`). This used to be a raw
|
* MusicBrainz→Wikidata identity (see `artist_bio`). This used to be a raw
|
||||||
* browser fetch that displayed Wikipedia's FIRST hit for
|
* browser fetch that displayed Wikipedia's FIRST hit for
|
||||||
|
|||||||
Reference in new issue
Block a user