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