From 4d6e69e324f4d80098774707eb414ad1286daebd Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:58:17 -0500 Subject: [PATCH] release ownership: one Deezer id can be seven artists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/metadata/src/lib.rs | 829 ++++++++++++++++++++++++++++++++++--- src-tauri/src/commands.rs | 20 + src-tauri/src/lib.rs | 1 + src-tauri/src/pipeline.rs | 7 +- src/lib/api.js | 2 + src/lib/state.svelte.js | 97 ++++- 6 files changed, 892 insertions(+), 64 deletions(-) diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index d63a272..cb36958 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -261,7 +261,20 @@ const HERO_MAX_DECODE_DIMENSION: u32 = 12_000; const HERO_MAX_DECODE_ALLOC_BYTES: u64 = 128 * 1024 * 1024; const COVER_MAX_DECODE_DIMENSION: u32 = 8_000; const COVER_MAX_DECODE_ALLOC_BYTES: u64 = 192 * 1024 * 1024; +const FUSED_PORTRAIT_FILE: &str = "fused-portraits-v1.json"; const APPLE_EXACT_ARTIST_CANDIDATE_LIMIT: usize = 8; +/// The identity pass may look at far more same-named Apple performers than the +/// page-load gate dares to, because it does not have to GUESS which one is +/// ours: MusicBrainz has already named the artist behind this Deezer id, so the +/// winner is chosen by overlap with that identity instead of by overlap with a +/// Deezer list that may itself be fused. Wand has TEN exact-name Apple artists +/// and the page-load cap therefore returned an empty, unusable catalogue — +/// which is precisely why 46 co-tenant releases reached the page. +const APPLE_IDENTITY_ARTIST_CANDIDATE_LIMIT: usize = 20; +/// Minimum MusicBrainz release-group titles an Apple candidate must reproduce +/// before it is accepted as the same act. One shared title is a coincidence +/// between namesakes ("Greatest Hits"); two is a catalogue. +const APPLE_IDENTITY_MIN_TITLE_HITS: usize = 2; // Cover verification is background polish, not a reason for four malicious or // unusually large remote rasters to reserve up to 768 MiB at once. Network // fetches still overlap four-wide in the UI; only CPU decode/hash work is @@ -626,6 +639,17 @@ pub struct CatalogArtistIdentity { pub albums: Vec, pub socials: Vec, pub type_hints: Vec, + /// Deezer album ids this artist's own identity corroborates. A row flagged + /// `appears_on` that appears here is a real collaboration, not a guest + /// spot — see [`ReleaseOwnership`]. + pub owned_album_ids: Vec, + /// Deezer album ids belonging to a different act that shares this display + /// name. Non-empty only where co-tenancy was proven. + pub foreign_album_ids: Vec, + /// True when this Deezer artist record was proven to hold more than one + /// act. The Deezer artist PORTRAIT is then untrustworthy by the same + /// argument as the releases — it is whichever co-tenant uploaded last. + pub fused_record: bool, } fn empty_catalog_artist_identity(artist_id: u64) -> CatalogArtistIdentity { @@ -665,6 +689,10 @@ struct AppleRelease { /// iTunes `collectionId` — the key for a follow-up song lookup that yields /// the album's tracklist. `None` when the lookup entry omitted it. collection_id: Option, + /// iTunes `copyright`, e.g. "℗ 2014 GOD?". The label is the only field that + /// separates a co-tenant from the artist on a fused Deezer record when + /// neither provider carries the title (see [`ReleaseOwnership`]). + copyright: Option, } /// One identity-checked Apple artist catalogue. Apple can return several @@ -703,6 +731,17 @@ struct AppleArtistCatalog { struct AppleArtistCandidate { artist_id: u64, releases: Vec, + /// Titles this artist's lookup returns under a DIFFERENT Apple artist id + /// whose display credit still names them as a principal — "¥$, Kanye West + /// & Ty Dolla $ign", "JAŸ-Z & Kanye West". `releases` cannot hold these: + /// it is filtered to the looked-up id so the page supplement never injects + /// somebody else's record, which is also why VULTURES 1 and KIDS SEE + /// GHOSTS had no corroboration anywhere. + /// + /// Deliberately NOT merged into `releases`: a guest feature arrives in the + /// same bucket ("Estelle" — "American Boy (feat. Kanye West)") and is + /// excluded here only because the credit does not name the artist. + credited_titles: HashSet, lookup_complete: bool, cacheable: bool, } @@ -845,6 +884,12 @@ pub struct MetadataClient { /// Concurrent page/cover requests share the bounded Apple search + lookup /// wave instead of stampeding the provider. apple_candidates_inflight: Mutex>, + /// The same discovery under [`APPLE_IDENTITY_ARTIST_CANDIDATE_LIMIT`], kept + /// apart because the two differ in the one way that matters: above the + /// page-load cap the map above holds a deliberate "unresolved" sentinel, + /// and the identity pass must not inherit it. + apple_identity_candidates: Mutex>>>, + apple_identity_candidates_inflight: Mutex>, /// Synthetic catalog id → Apple album for supplemented releases missing from /// Deezer's regional response, so an opened card can fetch its iTunes /// tracklist. Mirrors [`Self::mb_catalog`]. @@ -860,6 +905,15 @@ pub struct MetadataClient { /// `None` (the default, and every test/example) keeps behavior purely /// in-memory. disk_cache_dir: Option, + /// Deezer artist id → the portrait to show instead of the one Deezer + /// serves, for records proven to hold more than one act. + /// + /// Deliberately NOT inside [`Self::disk_cache_dir`]: `prune_disk_cache` + /// deletes every `.json` there older than twice the provider TTL, and a + /// verdict is not a cached response — losing it silently would put another + /// artist's face back in the search bar. + identity_store_dir: Option, + fused_portraits: std::sync::Mutex>>, } impl Default for MetadataClient { @@ -897,11 +951,15 @@ impl MetadataClient { apple_albums: Mutex::new(HashMap::new()), apple_candidates: Mutex::new(HashMap::new()), apple_candidates_inflight: Mutex::new(HashMap::new()), + apple_identity_candidates: Mutex::new(HashMap::new()), + apple_identity_candidates_inflight: Mutex::new(HashMap::new()), apple_catalog: Mutex::new(HashMap::new()), wiki_gate: Mutex::new(VecDeque::new()), wiki_cooldown: Mutex::new(None), discogs_gate: Mutex::new(VecDeque::new()), disk_cache_dir: None, + identity_store_dir: None, + fused_portraits: std::sync::Mutex::new(HashMap::new()), } } @@ -919,6 +977,72 @@ impl MetadataClient { self } + /// Enable the durable identity store — verdicts, not cached responses. + /// + /// A fused record is discovered by the deferred ownership pass, which costs + /// rate-limited MusicBrainz and Apple lookups. The search bar cannot pay + /// that on every keystroke, so what it consults is the REMEMBERED answer. + /// Once an artist has been opened, every later surface agrees for free. + pub fn with_identity_store(mut self, dir: std::path::PathBuf) -> Self { + if std::fs::create_dir_all(&dir).is_ok() { + if let Ok(raw) = std::fs::read(dir.join(FUSED_PORTRAIT_FILE)) { + if let Ok(map) = serde_json::from_slice::>>(&raw) { + let mut portraits = self.fused_portraits.lock().unwrap_or_else(|e| e.into_inner()); + for (id, portrait) in map { + if let Ok(id) = id.parse::() { + portraits.insert(id, portrait); + } + } + } + } + self.identity_store_dir = Some(dir); + } + self + } + + /// Record that this Deezer artist record holds more than one act, together + /// with the portrait to show in its place (`None` when nothing better is + /// known — a blank slot is honest, a confident wrong face is not). + pub fn remember_fused_portrait(&self, artist_id: u64, portrait: Option) { + let snapshot = { + let mut portraits = self + .fused_portraits + .lock() + .unwrap_or_else(|e| e.into_inner()); + portraits.insert(artist_id, portrait); + portraits + .iter() + .map(|(id, url)| (id.to_string(), url.clone())) + .collect::>>() + }; + let Some(dir) = self.identity_store_dir.as_ref() else { + return; + }; + if let Ok(raw) = serde_json::to_vec(&snapshot) { + let _ = std::fs::write(dir.join(FUSED_PORTRAIT_FILE), raw); + } + } + + /// `Some(portrait)` when this artist is a known fused record. The inner + /// option is the replacement, which may itself be `None`. + fn known_fused_portrait(&self, artist_id: u64) -> Option> { + self.fused_portraits + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&artist_id) + .cloned() + } + + /// Swap in the remembered portrait wherever a Deezer artist row surfaces. + /// Deezer's `picture` on a fused record is whichever co-tenant uploaded + /// last, and it is a real photograph at real dimensions — nothing about the + /// image itself gives it away, so only the remembered verdict can. + fn apply_known_portrait(&self, artist: &mut CatalogArtist) { + if let Some(portrait) = self.known_fused_portrait(artist.id) { + artist.picture = portrait; + } + } + /// Disk-cache file for a URL, for qualifying (slow-moving) providers only. fn disk_cache_path(&self, url: &str) -> Option { let dir = self.disk_cache_dir.as_ref()?; @@ -2800,6 +2924,12 @@ impl MetadataClient { .cmp(&artist_name_relevance(&a.name, &query_key)) .then(b.fans.unwrap_or(0).cmp(&a.fans.unwrap_or(0))) }); + // The suggestion dropdown is the first place a fused record's wrong + // face appears, and it is typed against per keystroke — so it reads the + // remembered verdict rather than re-deriving one. + for artist in &mut artist_list { + self.apply_known_portrait(artist); + } CatalogSearch { artists: artist_list, albums: albums @@ -2834,7 +2964,16 @@ impl MetadataClient { }; body.get("data") .and_then(|d| d.as_array()) - .map(|items| items.iter().filter_map(parse_artist).collect()) + .map(|items| { + items + .iter() + .filter_map(parse_artist) + .map(|mut artist| { + self.apply_known_portrait(&mut artist); + artist + }) + .collect() + }) .unwrap_or_default() } @@ -2846,7 +2985,11 @@ impl MetadataClient { let profile = self .deezer(&format!("https://api.deezer.com/artist/{artist_id}")) .await?; - let artist = parse_artist(&profile)?; + let mut artist = parse_artist(&profile)?; + // This stage's artist object is spread over the page's in + // `mergeCatalogArtistArt`, so without this it would reinstate the + // portrait the ownership pass just disproved. + self.apply_known_portrait(&mut artist); let art = self.artist_art(&artist.name, artist_id).await; Some(CatalogArtistArt { artist, @@ -2896,7 +3039,8 @@ impl MetadataClient { self.deezer(&popular_url), ); stage_trace("overview.deezer_join", overview_started, ""); - let artist = parse_artist(&profile?)?; + let mut artist = parse_artist(&profile?)?; + self.apply_known_portrait(&mut artist); let album_page = album_page?; let album_items = album_page.get("data")?.as_array()?; let evidence: Vec = album_items @@ -2967,6 +3111,7 @@ impl MetadataClient { .await?; stage_trace("page.profile", page_started, ""); let mut artist = parse_artist(&profile)?; + self.apply_known_portrait(&mut artist); // Provider artwork is an independent frontend stage. Do not make the // exhaustive release list wait for its Bing hero fallback: that used to @@ -3723,6 +3868,20 @@ impl MetadataClient { return empty_catalog_artist_identity(artist_id); }; stage_trace("identity.linked_mbid", identity_started, ""); + let ownership_started = Instant::now(); + let ownership = self + .artist_release_ownership(artist_id, artist, &mbid) + .await; + stage_trace( + "identity.ownership", + ownership_started, + &format!( + "owned={} foreign={} fused={}", + ownership.owned.len(), + ownership.foreign.len(), + ownership.cotenancy_proven + ), + ); let join_started = Instant::now(); // Socials are footer content, not sorting evidence, yet every request // in this join serializes behind MusicBrainz's 1 req/s gate — so the @@ -3744,6 +3903,277 @@ impl MetadataClient { albums, socials: Vec::new(), type_hints, + owned_album_ids: ownership.owned.into_iter().collect(), + foreign_album_ids: ownership.foreign.into_iter().collect(), + fused_record: ownership.cotenancy_proven, + } + } + + /// Every release group MusicBrainz files under this artist, all primary + /// types. [`Self::musicbrainz_studio_albums`] deliberately asks for studio + /// albums only because it SUPPLEMENTS the page; ownership has to recognise + /// singles, EPs and live records too, or the artist's own promo singles read + /// as somebody else's. + async fn musicbrainz_release_titles(&self, mbid: &str) -> HashSet { + let mut titles = HashSet::new(); + for offset in [0usize, 100] { + let url = format!( + "https://musicbrainz.org/ws/2/release-group?artist={mbid}&limit=100&offset={offset}&fmt=json" + ); + let Some(body) = self.musicbrainz_json(&url).await else { + break; + }; + let Some(groups) = body.get("release-groups").and_then(|g| g.as_array()) else { + break; + }; + let returned = groups.len(); + for group in groups { + if let Some(title) = group.get("title").and_then(|t| t.as_str()) { + let key = release_title_identity(title); + if !key.is_empty() { + titles.insert(key); + } + } + } + let total = body + .get("release-group-count") + .and_then(|c| c.as_u64()) + .unwrap_or(returned as u64) as usize; + if offset + returned >= total || returned == 0 { + break; + } + } + titles + } + + /// The labels MusicBrainz attaches to this artist's releases. Curated + /// per-release, so one sighting is enough to trust — unlike Apple's ℗ line, + /// which inherits whatever else got filed under the performer. + async fn musicbrainz_release_labels(&self, mbid: &str) -> HashSet { + let url = format!( + "https://musicbrainz.org/ws/2/release?artist={mbid}&limit=100&inc=labels&fmt=json" + ); + let Some(body) = self.musicbrainz_json(&url).await else { + return HashSet::new(); + }; + let Some(releases) = body.get("releases").and_then(|r| r.as_array()) else { + return HashSet::new(); + }; + let mut labels = HashSet::new(); + for release in releases { + let Some(infos) = release.get("label-info").and_then(|l| l.as_array()) else { + continue; + }; + for info in infos { + let Some(name) = info.pointer("/label/name").and_then(|n| n.as_str()) else { + continue; + }; + // MusicBrainz's placeholder for a self-released record. It is + // not a label and would match every unlabelled row. + if name == "[no label]" { + continue; + } + let key = label_identity(name); + if key.len() >= 3 { + labels.insert(key); + } + } + } + labels + } + + /// Decide, release by release, which of this Deezer artist's rows are + /// actually this artist's. See [`ReleaseOwnership`] for what the two output + /// sets mean and why `foreign` is gated behind proof. + async fn artist_release_ownership( + &self, + artist_id: u64, + artist: &str, + mbid: &str, + ) -> ReleaseOwnership { + let started = Instant::now(); + let (mb_titles, mb_labels, candidates) = tokio::join!( + self.musicbrainz_release_titles(mbid), + self.musicbrainz_release_labels(mbid), + self.apple_identity_candidates(artist), + ); + if mb_titles.is_empty() { + // No identity to reason from. Saying nothing is the correct answer: + // the caller keeps every row. + stage_trace("ownership.total", started, "no-mb-titles"); + return ReleaseOwnership::default(); + } + + // The Apple performer that reproduces this MBID's catalogue is ours; + // the rest are namesakes, and their exclusive titles are the proof that + // a Deezer row was filed under the wrong act. + let mut winner: Option<(usize, usize)> = None; + for (index, candidate) in candidates.iter().enumerate() { + let hits = candidate + .releases + .iter() + .filter(|release| mb_titles.contains(&release.title_norm)) + .map(|release| release.title_norm.clone()) + .collect::>() + .len(); + if winner.is_none_or(|(_, best)| hits > best) { + winner = Some((index, hits)); + } + } + let winner = winner.filter(|(_, hits)| *hits >= APPLE_IDENTITY_MIN_TITLE_HITS); + let mut owned_titles = mb_titles.clone(); + let mut labels = mb_labels; + // Collaborations Apple files under their own artist id while still + // crediting ours. Held apart from `owned_titles` because on a FUSED + // record the credit is ambiguous by construction — "rafftheraff, O mc + // vini, Alec & Wand" names a Wand, just not this one — so these are + // only conclusive where the record holds a single act, and otherwise + // fall through to the label test below. + let credited_titles: HashSet = winner + .map(|(index, _)| candidates[index].credited_titles.clone()) + .unwrap_or_default(); + if let Some((index, _)) = winner { + let mut apple_label_hits: HashMap = HashMap::new(); + for release in &candidates[index].releases { + if !release.title_norm.is_empty() { + owned_titles.insert(release.title_norm.clone()); + } + if let Some(copyright) = release.copyright.as_deref() { + let key = label_identity(&label_from_copyright(copyright)); + if key.len() >= 3 { + *apple_label_hits.entry(key).or_insert(0) += 1; + } + } + } + // Apple leaks: two rafftheraff singles sit under the LA band's + // artist id. One sighting must not admit a co-tenant's whole label. + labels.extend( + apple_label_hits + .into_iter() + .filter(|(_, hits)| *hits >= 2) + .map(|(key, _)| key), + ); + } + let foreign_titles: HashSet<&String> = candidates + .iter() + .enumerate() + .filter(|(index, _)| winner.is_none_or(|(win, _)| *index != win)) + .flat_map(|(_, candidate)| candidate.releases.iter().map(|r| &r.title_norm)) + .filter(|title| !title.is_empty() && !owned_titles.contains(*title)) + .collect(); + + // Deezer's own list for this id. Cached from the page load, so on the + // normal path this costs nothing. + let mut rows: Vec<(u64, String)> = Vec::new(); + for index in (0..200).step_by(50) { + let url = format!( + "https://api.deezer.com/artist/{artist_id}/albums?limit=50&index={index}" + ); + let Some(body) = self.deezer(&url).await else { + break; + }; + let Some(items) = body.get("data").and_then(|d| d.as_array()) else { + break; + }; + for item in items { + let Some(id) = item.get("id").and_then(|i| i.as_u64()) else { + continue; + }; + let Some(title) = item.get("title").and_then(|t| t.as_str()) else { + continue; + }; + rows.push((id, release_title_identity(title))); + } + if body.get("next").and_then(|n| n.as_str()).is_none() { + break; + } + } + + let owned: HashSet = rows + .iter() + .filter(|(_, title)| owned_titles.contains(title)) + .map(|(id, _)| *id) + .collect(); + + // The gate. A row carried by a DIFFERENT same-named Apple performer, + // and by neither MusicBrainz nor ours, is a co-tenant we can name. + let proven: Vec = rows + .iter() + .filter(|(_, title)| foreign_titles.contains(title)) + .map(|(id, _)| *id) + .collect(); + let cotenancy_proven = candidates.len() >= 2 && !proven.is_empty(); + if !cotenancy_proven { + // One act on this record, so a credit naming them is decisive. + // This is what returns VULTURES 1, VULTURES 2 and KIDS SEE GHOSTS + // to Ye's page: MusicBrainz files all three under ¥$ and Kids See + // Ghosts rather than under his own MBID, so nothing else corroborates + // them and the guest guard was deleting all three. + let owned = rows + .iter() + .filter(|(id, title)| owned.contains(id) || credited_titles.contains(title)) + .map(|(id, _)| *id) + .collect::>(); + stage_trace( + "ownership.total", + started, + &format!("gate=closed owned={} rows={}", owned.len(), rows.len()), + ); + return ReleaseOwnership { + owned, + foreign: HashSet::new(), + cotenancy_proven: false, + }; + } + + // Only now is the per-row label worth buying: this is the one artist + // shape where it decides anything, and details are one request each. + // Wand's genuine rows are all Drag City / In The Red and its co-tenants + // span twenty-one other labels — without this leg nine real releases + // (JJ, Smile, The Leap, Goldfish, the live singles…) have no evidence + // at all, because neither provider carries them by title. + let unresolved: Vec = rows + .iter() + .filter(|(id, _)| !owned.contains(id)) + .map(|(id, _)| *id) + .collect(); + let details = stream::iter(unresolved) + .map(|id| { + let url = format!("https://api.deezer.com/album/{id}"); + async move { (id, self.deezer(&url).await) } + }) + .buffer_unordered(12) + .collect::>() + .await; + + let mut foreign = HashSet::new(); + let mut owned = owned; + for (id, detail) in details { + let label = detail + .as_ref() + .and_then(|d| d.get("label")) + .and_then(|l| l.as_str()) + .unwrap_or_default(); + if !label.is_empty() && label_matches(label, &labels) { + owned.insert(id); + } else { + foreign.insert(id); + } + } + stage_trace( + "ownership.total", + started, + &format!( + "gate=open owned={} foreign={} rows={}", + owned.len(), + foreign.len(), + rows.len() + ), + ); + ReleaseOwnership { + owned, + foreign, + cotenancy_proven, } } @@ -5330,7 +5760,12 @@ impl MetadataClient { .clone() }; let candidates = fetch - .get_or_init(|| async { Arc::new(self.fetch_apple_artist_candidates(artist).await) }) + .get_or_init(|| async { + Arc::new( + self.fetch_apple_artist_candidates(artist, APPLE_EXACT_ARTIST_CANDIDATE_LIMIT) + .await, + ) + }) .await .clone(); // A complete zero-match search is a real, cacheable result. Transport @@ -5353,7 +5788,63 @@ impl MetadataClient { candidates } - async fn fetch_apple_artist_candidates(&self, artist: &str) -> Vec { + /// The same resolution with a far higher candidate ceiling, for the + /// MusicBrainz-anchored identity pass only. + /// + /// [`Self::apple_artist_candidates`] fails closed above + /// [`APPLE_EXACT_ARTIST_CANDIDATE_LIMIT`] because at page-load time the only + /// way to pick between same-named performers is to score them against the + /// requested Deezer artist's own release list — and on a fused record that + /// list is partly the co-tenants'. Circular evidence has to fail closed. + /// Here the MBID has already named the artist, so more candidates are more + /// evidence rather than more risk, and the losing catalogues are exactly + /// what proves a Deezer row belongs to someone else. + async fn apple_identity_candidates(&self, artist: &str) -> Arc> { + let want = normalize(artist); + if let Some(candidates) = self.apple_identity_candidates.lock().await.get(&want) { + return candidates.clone(); + } + let fetch = { + let mut inflight = self.apple_identity_candidates_inflight.lock().await; + inflight + .entry(want.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let candidates = fetch + .get_or_init(|| async { + Arc::new( + self.fetch_apple_artist_candidates( + artist, + APPLE_IDENTITY_ARTIST_CANDIDATE_LIMIT, + ) + .await, + ) + }) + .await + .clone(); + if candidates.iter().all(|candidate| candidate.cacheable) { + let mut cache = self.apple_identity_candidates.lock().await; + if cache.len() >= 256 { + cache.clear(); + } + cache.insert(want.clone(), candidates.clone()); + } + let mut inflight = self.apple_identity_candidates_inflight.lock().await; + if inflight + .get(&want) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(&want); + } + candidates + } + + async fn fetch_apple_artist_candidates( + &self, + artist: &str, + candidate_limit: usize, + ) -> Vec { let want = normalize(artist); let search = format!( "https://itunes.apple.com/search?term={}&entity=musicArtist&limit=25", @@ -5386,9 +5877,9 @@ impl MetadataClient { .filter_map(|r| r.get("artistId").and_then(|v| v.as_u64())) .filter(|artist_id| seen.insert(*artist_id)) .collect(); - if artist_ids.len() > APPLE_EXACT_ARTIST_CANDIDATE_LIMIT { + if artist_ids.len() > candidate_limit { log::debug!( - "Apple identity for {artist} has {} exact-name candidates; failing closed before lookups", + "Apple identity for {artist} has {} exact-name candidates (cap {candidate_limit}); failing closed before lookups", artist_ids.len() ); // Preserve an explicit ambiguous shape. Returning [] would make the @@ -5400,6 +5891,7 @@ impl MetadataClient { .map(|artist_id| AppleArtistCandidate { artist_id, releases: Vec::new(), + credited_titles: HashSet::new(), // Intentional ambiguity, not transport failure: selection // and Bing still fail closed, but native Deezer rows render. lookup_complete: true, @@ -5408,7 +5900,9 @@ impl MetadataClient { .collect(); } let mut candidates = stream::iter(artist_ids) - .map(|artist_id| async move { self.fetch_apple_artist_candidate(artist_id).await }) + .map(|artist_id| async move { + self.fetch_apple_artist_candidate(artist_id, artist).await + }) .buffer_unordered(6) .collect::>() .await; @@ -5416,64 +5910,89 @@ impl MetadataClient { candidates } - async fn fetch_apple_artist_candidate(&self, artist_id: u64) -> AppleArtistCandidate { + async fn fetch_apple_artist_candidate( + &self, + artist_id: u64, + artist: &str, + ) -> AppleArtistCandidate { let lookup = format!("https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=200"); - let releases = match self.http.get(&lookup).send().await { + let body = match self.http.get(&lookup).send().await { Ok(resp) => match resp.error_for_status() { - Ok(resp) => match resp.json::().await { - Ok(body) => body - .get("results") - .and_then(|r| r.as_array()) - .map(|results| { - results - .iter() - .filter(|r| { - r.get("wrapperType").and_then(|w| w.as_str()) - == Some("collection") - && r.get("artistId") - .and_then(|id| id.as_u64()) - .is_none_or(|id| id == artist_id) - }) - .filter_map(|r| { - let name = r.get("collectionName").and_then(|c| c.as_str())?; - Some(AppleRelease { - title: name.to_string(), - title_norm: release_title_identity(name), - artwork: r - .get("artworkUrl100") - .and_then(|c| c.as_str()) - .unwrap_or_default() - .to_string(), - release_date: r - .get("releaseDate") - .and_then(|c| c.as_str()) - .map(String::from), - track_count: r - .get("trackCount") - .and_then(|c| c.as_u64()) - .map(|n| n as u32), - collection_type: r - .get("collectionType") - .and_then(|c| c.as_str()) - .map(String::from), - collection_id: r - .get("collectionId") - .and_then(|c| c.as_u64()), - }) - }) - .collect() - }), - Err(_) => None, - }, + Ok(resp) => resp.json::().await.ok(), Err(_) => None, }, Err(_) => None, }; - let lookup_complete = releases.is_some(); + let parsed = body + .as_ref() + .and_then(|body| body.get("results")) + .and_then(|r| r.as_array()) + .map(|results| { + let mut releases = Vec::new(); + let mut credited_titles = HashSet::new(); + for entry in results { + if entry.get("wrapperType").and_then(|w| w.as_str()) != Some("collection") { + continue; + } + let Some(name) = entry.get("collectionName").and_then(|c| c.as_str()) else { + continue; + }; + let owned_by_this_id = entry + .get("artistId") + .and_then(|id| id.as_u64()) + .is_none_or(|id| id == artist_id); + if !owned_by_this_id { + // Filed under a collaboration's own Apple artist. Only + // a display credit naming this artist makes it theirs; + // "(feat. …)" in the title does not. + let credited = entry + .get("artistName") + .and_then(|c| c.as_str()) + .is_some_and(|credit| apple_credit_names_artist(credit, artist)); + if credited { + let key = release_title_identity(name); + if !key.is_empty() { + credited_titles.insert(key); + } + } + continue; + } + releases.push(AppleRelease { + title: name.to_string(), + title_norm: release_title_identity(name), + artwork: entry + .get("artworkUrl100") + .and_then(|c| c.as_str()) + .unwrap_or_default() + .to_string(), + release_date: entry + .get("releaseDate") + .and_then(|c| c.as_str()) + .map(String::from), + track_count: entry + .get("trackCount") + .and_then(|c| c.as_u64()) + .map(|n| n as u32), + collection_type: entry + .get("collectionType") + .and_then(|c| c.as_str()) + .map(String::from), + collection_id: entry.get("collectionId").and_then(|c| c.as_u64()), + copyright: entry + .get("copyright") + .and_then(|c| c.as_str()) + .map(String::from), + }); + } + (releases, credited_titles) + }); + let lookup_complete = parsed.is_some(); + let (releases, credited_titles) = parsed.unwrap_or_default(); AppleArtistCandidate { artist_id, - releases: releases.unwrap_or_default(), + releases, + credited_titles, lookup_complete, cacheable: lookup_complete, } @@ -5870,6 +6389,7 @@ fn unavailable_apple_candidates() -> Vec { .map(|artist_id| AppleArtistCandidate { artist_id, releases: Vec::new(), + credited_titles: HashSet::new(), lookup_complete: false, cacheable: false, }) @@ -6017,6 +6537,102 @@ fn select_apple_artist_catalog( } } +/// A record label reduced to a comparison key. MusicBrainz writes full legal +/// names ("In the Red Records"), Deezer writes trade names ("In The Red") and +/// Apple writes whatever sits after the ℗ year, so the corporate furniture has +/// to go before two spellings of one label can meet. Punctuation is dropped +/// entirely rather than collapsed to spaces — "20/20 Vision" and "2020Vision" +/// are the same label and were not matching while a space survived between +/// them. +fn label_identity(label: &str) -> String { + // Whole words only. A substring pass turns "southern" into "sou|rn" and + // "rafftheraff" into "raff|raff" by eating an inner "the", which is how a + // co-tenant's imprint could come to collide with an unrelated one. + const NOISE: [&str; 15] = [ + "records", + "record", + "recordings", + "recording", + "music", + "musik", + "entertainment", + "group", + "limited", + "ltd", + "llc", + "inc", + "gmbh", + "the", + "division", + ]; + label + .to_lowercase() + .replace('&', " and ") + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty() && !NOISE.contains(word)) + .collect() +} + +/// "℗ 2014 GOD?" → "GOD?". Apple states the label only inside the copyright +/// line, and the leading symbol/year is the only reliably-shaped part of it. +fn label_from_copyright(copyright: &str) -> String { + let trimmed = copyright + .trim_start_matches(['℗', '©', ' ']) + .trim_start(); + let without_year = trimmed + .split_once(' ') + .filter(|(head, _)| head.len() == 4 && head.chars().all(|c| c.is_ascii_digit())) + .map(|(_, tail)| tail) + .unwrap_or(trimmed); + without_year.trim().to_string() +} + +fn label_matches(label: &str, known: &HashSet) -> bool { + let key = label_identity(label); + if key.len() < 3 { + return false; + } + known.iter().any(|candidate| { + candidate.len() >= 3 + && (candidate == &key + || (key.len() >= 5 && candidate.contains(&key)) + || (candidate.len() >= 5 && key.contains(candidate.as_str()))) + }) +} + +/// Per-release ownership verdicts for one artist, decided by that artist's own +/// MusicBrainz identity rather than by Deezer's release list. +/// +/// Two different failures are answered by the same evidence: +/// +/// 1. **Deezer files several unrelated acts under one artist id.** There is +/// only ONE Deezer artist called "Wand" (1213178) and it carries the LA +/// psych band, a Brazilian funk MC, an Indonesian act and four more — 46 of +/// its 72 releases belong to somebody else. Nothing about ranking search +/// results or reading credit roles can help: there is one candidate to open, +/// and every row is primary-credited to it. +/// 2. **The guest-appearance guard deletes real collaborations.** That guard +/// keeps an `appears_on` row only when MusicBrainz credits it to this +/// artist's own MBID, which assumes MB files collaborations under both +/// parties. It does for "Watch the Throne" (one artist credit naming both) +/// and it does NOT when the collaboration is its own MB entity: neither +/// VULTURES 1, VULTURES 2 nor KIDS SEE GHOSTS appears anywhere in Ye's 315 +/// release groups, so all three were being erased from his page. +/// +/// `owned` answers (2) — it is every Deezer row this artist's identity +/// corroborates, so a flagged collaboration survives on evidence rather than on +/// MB happening to duplicate the credit. `foreign` answers (1), and is empty +/// unless co-tenancy is PROVEN: some row on this page must be carried by a +/// *different* same-named Apple performer and by neither of ours. Without that +/// proof the pass observes and removes nothing, which is what keeps Weezer's 67 +/// rows, Kanye's 73 and Pond's 30 untouched — measured, not assumed. +#[derive(Debug, Clone, Default)] +struct ReleaseOwnership { + owned: HashSet, + foreign: HashSet, + cotenancy_proven: bool, +} + fn apple_release_is_foreign( catalog: &AppleArtistCatalog, title: &str, @@ -6029,6 +6645,25 @@ fn apple_release_is_foreign( || genre_id.is_some_and(|genre_id| catalog.foreign_genres.contains(&genre_id))) } +/// True when an Apple display credit names this artist as one of the principals +/// rather than merely mentioning them. +/// +/// The credit is a list, so it is read as one: "¥$, Kanye West & Ty Dolla $ign" +/// names Kanye, "Estelle" does not — even though her record's TITLE says +/// "American Boy (feat. Kanye West)". Matching the title instead would turn +/// every guest spot into an owned release, which is the exact bug the +/// `appears_on` guard exists to prevent. +fn apple_credit_names_artist(credit: &str, artist: &str) -> bool { + let want = normalize(artist); + if want.is_empty() { + return false; + } + credit + .split([',', '&', ';']) + .flat_map(|part| part.split(" and ")) + .any(|part| normalize(part) == want) +} + /// Top-track payloads retain featured appearances through the contributors /// list. Missing identity fields are accepted (older provider shapes), but an /// explicit different primary/contributor set is not. @@ -9698,6 +10333,84 @@ mod tests { assert!(select_audiodb_artist(&body, "Billy Cob").is_none()); } + /// Three spellings of one label have to meet: MusicBrainz writes the full + /// legal name, Deezer the trade name, Apple whatever follows the ℗ year. + #[test] + fn label_identity_reconciles_provider_spellings() { + assert_eq!(label_identity("In the Red Records"), label_identity("In The Red")); + // Crazy P's "Beatbox" was being dropped purely because a space survived + // between these two. + assert_eq!(label_identity("20/20 Vision"), label_identity("2020Vision")); + assert_eq!(label_identity("Drag City"), "dragcity"); + assert_ne!(label_identity("Drag City"), label_identity("Explosão Music")); + // Whole-word noise removal only. + assert_eq!(label_identity("rafftheraff"), "rafftheraff"); + assert_eq!(label_identity("Southern"), "southern"); + } + + #[test] + fn label_from_copyright_drops_the_symbol_and_year() { + assert_eq!(label_from_copyright("℗ 2014 GOD?"), "GOD?"); + assert_eq!(label_from_copyright("℗ 2019 Drag City Records"), "Drag City Records"); + // No year to strip, and a four-letter first word is not one. + assert_eq!(label_from_copyright("Drag City"), "Drag City"); + } + + /// The label leg is what keeps nine genuine Wand releases — JJ, Smile, The + /// Leap, Goldfish, the live singles — which neither MusicBrainz nor Apple + /// carries by title, while refusing every co-tenant imprint on that record. + #[test] + fn label_matches_anchored_set_but_not_co_tenants() { + let anchored: HashSet = ["In the Red Records", "Drag City", "God?"] + .into_iter() + .map(label_identity) + .collect(); + assert!(label_matches("Drag City", &anchored)); + assert!(label_matches("In The Red", &anchored)); + for co_tenant in [ + "WMD Distribuidora", + "Explosão Music", + "Netrilis Music", + "MWR Records Retro", + "https://www.instagram.com/wandbarbosas", + "Ecstatic Peace/Universal Records", + "Klap Label", + "Tropicália Records", + ] { + assert!( + !label_matches(co_tenant, &anchored), + "{co_tenant} must not match the anchored set", + ); + } + // A bare imprint equal to the artist's own name proves nothing on a + // CONTESTED name — the co-tenant self-releases as "Wand" too. + assert!(!label_matches("Wand", &anchored)); + } + + /// A display credit is a LIST of principals. Reading the title instead + /// would make every guest spot an owned release — the exact thing the + /// `appears_on` guard exists to stop. + #[test] + fn apple_credit_names_only_actual_principals() { + for credit in [ + "¥$, Kanye West & Ty Dolla $ign", + "KIDS SEE GHOSTS, Kanye West & Kid Cudi", + "JAŸ-Z & Kanye West", + "Rihanna and Kanye West and Paul McCartney", + ] { + assert!( + apple_credit_names_artist(credit, "Kanye West"), + "{credit} names Kanye West", + ); + } + // "American Boy (feat. Kanye West)" is Estelle's record; the credit is + // hers alone and that is the only thing consulted. + assert!(!apple_credit_names_artist("Estelle", "Kanye West")); + assert!(!apple_credit_names_artist("Clipse", "Kanye West")); + // Substring of a longer name is not a credit. + assert!(!apple_credit_names_artist("Kanye Western", "Kanye West")); + } + fn test_apple_release(title: &str, collection_id: u64) -> AppleRelease { AppleRelease { title: title.into(), @@ -9707,6 +10420,7 @@ mod tests { track_count: None, collection_type: Some("Album".into()), collection_id: Some(collection_id), + copyright: None, } } @@ -9714,6 +10428,7 @@ mod tests { AppleArtistCandidate { artist_id, releases, + credited_titles: HashSet::new(), lookup_complete: true, cacheable: true, } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8578530..0350149 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1536,6 +1536,26 @@ pub async fn catalog_artist_bio( 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, +) -> Result<(), String> { + state + .metadata + .remember_fused_portrait(artist_id, portrait); + Ok(()) +} + /// Footer links, fetched only after the sorting-critical identity merge has /// landed — one MusicBrainz request that used to cost every cold artist ~1 s /// of time-to-correctly-sorted inside the identity join. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 901f26d..0ca8aa5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -85,6 +85,7 @@ pub fn run() { commands::catalog_artist_logos, commands::catalog_artist_identity, commands::catalog_artist_socials, + commands::remember_fused_artist_portrait, commands::catalog_artist_bio, commands::catalog_cached_cover_logo, commands::catalog_cover_logo, diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index b6a78b6..52d81e1 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -151,7 +151,12 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> { // Wikipedia) so a restarted app keeps its supplement/type-hint and // description warmth instead of re-serializing MusicBrainz at 1 req/s. 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 // resolution can be compared against `hero_source_probe` — the two // do not always see the same candidate set, and only the app's own diff --git a/src/lib/api.js b/src/lib/api.js index 45fa250..30dc133 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -88,6 +88,8 @@ export const addToPlaylist = (playlistId, tracks) => export const removeFromPlaylist = (playlistId, position) => invoke("remove_from_playlist", { playlistId, position }); 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 catalogArtistArt = (artistId) => checkedArtistPayload("catalog_artist_art", artistId); diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 950fb9b..1d62cd1 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -1019,7 +1019,9 @@ export function invalidateCatalogArtist(artistId) { // `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"; +// 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; // 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 @@ -1511,15 +1513,45 @@ async function applyArtistIdentity(artistId, active) { // 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. + // + // 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 ownAlbums = identityAlbums.length - ? (current.albums ?? []).filter( - (a) => !a?.appears_on || mbTitles.has(normText(a.title)), - ) - : (current.albums ?? []); + 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 merged = { ...current, 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 ? [...ownAlbums, ...extra].sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? "")) @@ -1528,6 +1560,7 @@ async function applyArtistIdentity(artistId, active) { typeHints: typeHints.length ? typeHints : current.typeHints, }; catalog.artistPage = merged; + if (merged.fused_record) void disownFusedPortrait(artistId, merged); // Write the supplemented page back into the session cache: a warm // revisit must not render less than the cold visit did. Overwriting // a pending entry is safe — cachedArtistRequest only installs its @@ -2094,6 +2127,58 @@ async function deezerPortraitMissing(url) { 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 * MusicBrainz→Wikidata identity (see `artist_bio`). This used to be a raw * browser fetch that displayed Wikipedia's FIRST hit for