fix(musicbrainz): identity-based artist matching, never an unrelated first result (#768)

Swim Deep's Artist page showed Deep Purple's founding year and members. Two
defects combined: `search_artist` emitted `artist:Swim Deep` unquoted (the
second word leaks into the global query; Deep Purple ranks first at 100,
Swim Deep second at 88), and `resolve_artist` fell back unconditionally to
the first result when no exact name scored >= 90. Name-keyed caches then
made such guesses durable and shareable across homonyms.

New `qbz-integrations::musicbrainz::identity` — pure decisions plus
orchestration over injected fetcher/store/catalog boundaries:
- query `artist:"<lucene-escaped name>"`, URL-encoded once;
- containment rule: exactly one exact-name candidate with score >= 90, else
  None — the first-result fallback no longer exists;
- source direction (Qobuz artist id -> MBID): the page's own Popular-Track
  ISRCs (max 3, guests excluded) vote among the exact-name candidates using
  every credit of every recording that carries the ISRC; ties, conflicts and
  failures fail closed;
- scene direction (MBID + catalog scope -> Qobuz id): a unique exact-name
  Qobuz artist is provisional; several homonyms are split by their own
  top-track ISRCs (max 2 each); no evidence omits the row instead of taking
  max(albums_count);
- four `*_v2` cache tables keyed by the stable identifier + matcher version,
  carrying evidence kind, expiring at read time (verified 30 d, provisional
  7 d, search windows 7 d, ISRC credits 30 d); legacy name-keyed rows are
  neither read nor written; scene cache key bumped to v2 with the real
  account territory (no scope -> no cache), rows re-derived on replay.

Core: `catalog_scope()`, `musicbrainz_resolve_source_artist`, store/catalog
adapters; `musicbrainz_resolve_artist` and the musician resolver go through
the containment rule. Artist page passes {qobuz id, own ISRCs} to core and
keeps its generation guard; QML, playback, auth and reco crates untouched.

Tests: qbz-integrations 59 (query encoding, #768 ordering fixture, ISRC
voting incl. collaborations/dupes/conflicts, cache keys/TTL/version/legacy,
bounded fan-out with request counting), qbz-core 40, qbz-qt 782.

cc
This commit is contained in:
vicrodh committed 2026-09-11 09:46:02 -06:00
1 parent baed7bb785
commit 7e30ad769d
7 files changed
+2555 -207

No files matched your search

+309 -136
View File
@@ -6,7 +6,11 @@
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use qbz_integrations::musicbrainz::cache::{MusicBrainzCache, QobuzArtistMatch}; use qbz_integrations::musicbrainz::cache::MusicBrainzCache;
use qbz_integrations::musicbrainz::identity::{
self, ArtistCandidate, BoxFuture, FetchError, IsrcCredits, SceneIdentityRow,
SceneQobuzCandidate, SourceIdentityRow,
};
use qbz_integrations::musicbrainz::genre::{extract_affinity_seeds, genre_summary, is_broad_genre}; use qbz_integrations::musicbrainz::genre::{extract_affinity_seeds, genre_summary, is_broad_genre};
use qbz_integrations::musicbrainz::location::compute_affinity_score; use qbz_integrations::musicbrainz::location::compute_affinity_score;
use qbz_integrations::musicbrainz::{ use qbz_integrations::musicbrainz::{
@@ -2378,41 +2382,67 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
Arc::clone(&self.musicbrainz) Arc::clone(&self.musicbrainz)
} }
/// Resolve an artist name to a MusicBrainz id. Returns `None` if no /// The authenticated Qobuz catalog territory (ISO 3166-1 alpha-2), the
/// confident match is found. /// scope of every MBID → Qobuz-id identity row. `None` before login —
/// callers then display but persist nothing (an invented constant would
/// let one territory's duplicate poison another's).
pub async fn catalog_scope(&self) -> Option<String> {
let client = self.client.read().await;
let client = client.as_ref()?;
client
.session()
.await
.and_then(|s| s.country_code)
.map(|c| c.trim().to_uppercase())
.filter(|c| !c.is_empty())
}
/// Resolve an artist NAME to a MusicBrainz id under the containment rule
/// (`identity::select_exact_name`): exactly one result in the quoted-name
/// window with the same name and score >= 90, else `None`. Used where
/// only a name exists (playlist suggestions, local artist images). The
/// search WINDOW is cached as evidence and re-gated on every hit; the
/// legacy name-keyed `resolved_artists` table is neither read nor written
/// — a name alone is not an identity (#768).
pub async fn musicbrainz_resolve_artist( pub async fn musicbrainz_resolve_artist(
&self, &self,
name: &str, name: &str,
) -> Result<Option<ResolvedArtist>, CoreError> { ) -> Result<Option<ResolvedArtist>, CoreError> {
// Cache-first (piece 3): a resolved artist is stable, so every let store = CoreIdentityStore(&self.musicbrainz_cache);
// ArtistPage open re-searched `artist?query=` live otherwise. Same identity::resolve_artist_by_name(Some(&store), self.musicbrainz.as_ref(), name)
// shape as `musicbrainz_get_artist_metadata` (cache-first, write-after).
if let Ok(guard) = self.musicbrainz_cache.lock() {
if let Some(cache) = guard.as_ref() {
if let Ok(Some(cached)) = cache.get_artist(name) {
return Ok(Some(cached));
}
}
}
let resolved = self
.musicbrainz
.resolve_artist(name)
.await .await
.map_err(|e| CoreError::Internal(e.to_string()))?; .map_err(|e| CoreError::Internal(e.to_string()))
}
// Write-after, but NEVER cache a negative: a "not found" pinned for 30 /// Resolve the SOURCE artist of an Artist Page — a Qobuz artist id, its
// days is worse than re-searching (handoff policy). Expiry stays with /// name and the ISRCs of its own Popular Tracks — to a MusicBrainz id
// the existing `cleanup(ttl_days)`; no new TTL here. /// (`identity::resolve_source_artist`). Verified mappings are cached by
if let Some(ref artist) = resolved { /// Qobuz artist id (Qobuz artist ids are catalog-global: territory
if let Ok(guard) = self.musicbrainz_cache.lock() { /// decides availability, not which artist an id names), bounded to
if let Some(cache) = guard.as_ref() { /// `MAX_SOURCE_ISRCS` MusicBrainz lookups through the shared client.
let _ = cache.put_artist(artist); pub async fn musicbrainz_resolve_source_artist(
} &self,
} qobuz_artist_id: u64,
name: &str,
isrcs: &[String],
) -> identity::SourceResolution {
if !self.musicbrainz.is_enabled().await {
return identity::SourceResolution {
identity: identity::SourceIdentity::Unavailable,
resolved: None,
requests: 0,
cache: "disabled",
};
} }
let store = CoreIdentityStore(&self.musicbrainz_cache);
Ok(resolved) identity::resolve_source_artist(
Some(&store),
self.musicbrainz.as_ref(),
qobuz_artist_id,
name,
isrcs,
)
.await
} }
/// Generate playlist "Suggested Songs" via the artist_vectors engine. /// Generate playlist "Suggested Songs" via the artist_vectors engine.
@@ -2960,8 +2990,8 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// account switch. The post-filter on read covers newly-BLOCKED artists; /// account switch. The post-filter on read covers newly-BLOCKED artists;
/// this covers the two cases it cannot: un-blocking (the validation loop /// this covers the two cases it cannot: un-blocking (the validation loop
/// had already substituted a different same-name Qobuz artist for that /// had already substituted a different same-name Qobuz artist for that
/// MBID) and a change of account territory (which decides what validates /// MBID). The account territory is part of the v2 key, so a territory
/// at all and is not yet part of the cache key). /// change no longer needs this.
pub fn invalidate_scene_cache(&self) { pub fn invalidate_scene_cache(&self) {
if let Ok(guard) = self.musicbrainz_cache.lock() { if let Ok(guard) = self.musicbrainz_cache.lock() {
if let Some(cache) = guard.as_ref() { if let Some(cache) = guard.as_ref() {
@@ -3069,29 +3099,54 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
// pre-resolution identity saves up to five MusicBrainz round-trips on // pre-resolution identity saves up to five MusicBrainz round-trips on
// every hit. First page only, matching Tauri — paginated pages are // every hit. First page only, matching Tauri — paginated pages are
// cheap to recompute and would multiply the key space by the offset. // cheap to recompute and would multiply the key space by the offset.
let cache_key = location::build_scene_cache_key_v1(&location::SceneCacheKey { // The catalog territory is part of the key (v2) — which same-name
source_mbid, // Qobuz artist validates depends on it. Without a scope (not logged
area_id: area_id.unwrap_or(area_name), // in) the scene is computed but neither read from nor written to the
country, // materialised cache, rather than keyed on an invented constant.
seeds: &source_seeds, let scope = self.catalog_scope().await;
page_size: limit, let store = CoreIdentityStore(&self.musicbrainz_cache);
catalog_scope: None, let catalog = SceneQobuz {
core: self,
fatal: std::sync::Mutex::new(None),
};
let cache_key = scope.as_deref().map(|scope| {
location::build_scene_cache_key_v1(&location::SceneCacheKey {
source_mbid,
area_id: area_id.unwrap_or(area_name),
country,
seeds: &source_seeds,
page_size: limit,
catalog_scope: Some(scope),
})
}); });
if cache_key.is_none() {
log::info!("[scene] no catalog scope (not logged in): scene cache bypassed");
}
if offset == 0 { if let (0, Some(cache_key)) = (offset, cache_key.as_deref()) {
let mut cached = None; let mut cached = None;
if let Ok(guard) = self.musicbrainz_cache.lock() { if let Ok(guard) = self.musicbrainz_cache.lock() {
if let Some(cache) = guard.as_ref() { if let Some(cache) = guard.as_ref() {
cached = cache.get_scene_cache(&cache_key).ok().flatten(); cached = cache.get_scene_cache(cache_key).ok().flatten();
} }
} }
if let Some(mut hit) = cached { if let Some(mut hit) = cached {
let removed = apply_scene_blacklist(&mut hit, blacklist); let removed = apply_scene_blacklist(&mut hit, blacklist);
// The materialised scene is NOT an identity authority: every
// row's Qobuz projection is re-derived from the identifier-
// keyed identity cache (verified rows live 30 days, a
// provisional unique-name row 7 days, then one cheap Qobuz
// re-search). A provisional match therefore never silently
// becomes a 30-day association because the page was cached.
let dropped = self
.revalidate_scene_rows(&mut hit, &store, &catalog, scope.as_deref(), blacklist)
.await?;
log::info!( log::info!(
"[scene] cache hit for {} ({} artists, {} blacklisted out)", "[scene] cache hit for {} ({} artists, {} blacklisted out, {} dropped on revalidation)",
area_name, area_name,
hit.artists.len(), hit.artists.len(),
removed removed,
dropped
); );
return Ok(hit); return Ok(hit);
} }
@@ -3303,112 +3358,45 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
); );
} }
let name_normalized = MusicBrainzCache::normalize_name(mb_name); // Identity, not popularity (#768): `identity::resolve_scene_candidate`
// keys its cache on THIS candidate's MBID + the catalog scope,
// Read-through validation cache. Only the Qobuz projection is // takes a unique exact-name Qobuz artist as a provisional match,
// stored (id/name/image/album count) — those four are a pure // and splits several same-name Qobuz artists with their own
// function of the name. The candidate is rebuilt around them from // top-track ISRCs — never with `albums_count` alone. No evidence,
// THIS scene's mbid, score and genres, which is exactly what made // no row.
// the Tauri-era whole-LocationCandidate cache unreplayable. match identity::resolve_scene_candidate(
let mut cached_match = None; Some(&store),
if let Ok(guard) = self.musicbrainz_cache.lock() { self.musicbrainz.as_ref(),
if let Some(cache) = guard.as_ref() { &catalog,
cached_match = cache.get_qobuz_artist_match(&name_normalized).ok().flatten(); mbid,
} mb_name,
} scope.as_deref(),
// A hit on a since-blacklisted artist is treated as a MISS, not as blacklist,
// "no match": re-running the search lets the next-best same-name )
// Qobuz artist take the slot, exactly as a cold run would. .await
let cached_match = {
cached_match.filter(|m| !(m.qobuz_id > 0 && blacklist(m.qobuz_id as u64))); Ok(Some(m)) => validated.push(LocationCandidate {
// RE-ASSERT THE ACCEPTANCE GATE ON REPLAY. The cache KEY is
// `MusicBrainzCache::normalize_name`, which also strips `'`, `"`,
// `.` and `,`; the cold path below accepts a result only when
// `normalize_artist_name` matches, and that one merely
// trims/lowercases/collapses whitespace. The key is therefore
// strictly COARSER than the predicate, so two MusicBrainz
// candidates that the cold path would treat as different names
// share one cache slot — "Guns N' Roses" and "Guns N Roses",
// "Eve." and "Eve". Whichever validated first would then own the
// row for BOTH, for the full 30-day TTL, and a replay that skipped
// the gate is the only way an artist the cold path would have
// REJECTED can end up attributed to a candidate.
//
// Checked here rather than by narrowing the key, so it still holds
// if either normaliser changes independently later.
let cached_match = cached_match
.filter(|m| normalize_artist_name(&m.name) == normalize_artist_name(mb_name));
if let Some(hit) = cached_match {
validated.push(LocationCandidate {
mbid: mbid.clone(), mbid: mbid.clone(),
mb_name: mb_name.clone(), mb_name: mb_name.clone(),
qobuz_id: Some(hit.qobuz_id), qobuz_id: Some(m.qobuz_id as i64),
qobuz_name: Some(hit.name), qobuz_name: Some(m.name),
qobuz_image: hit.image, qobuz_image: m.image,
score: *score, score: *score,
genres: candidate_genres.clone(), genres: candidate_genres.clone(),
qobuz_albums_count: hit.albums_count, qobuz_albums_count: m.albums_count,
}); }),
continue; Ok(None) => {}
}
let results = match self.search_artists(mb_name, 5, 0, None).await {
Ok(results) => results,
Err(e) => { Err(e) => {
// Terminal classes end the page here rather than burning // Terminal classes end the page here rather than burning
// up to 99 more searches that will fail the same way. // up to 99 more searches that will fail the same way.
if let Some(fatal) = scene_fatal_qobuz(&e) { if let Some(fatal) = catalog.take_fatal() {
log::warn!("[scene] Qobuz validation aborted: {e}"); log::warn!("[scene] Qobuz validation aborted: {e}");
return Err(fatal); return Err(fatal);
} }
log::warn!("[scene] Qobuz validation failed for {mb_name:?}: {e}"); log::warn!("[scene] Qobuz validation failed for {mb_name:?}: {e}");
validation_failures += 1; validation_failures += 1;
last_validation_error = e.to_string(); last_validation_error = e.to_string();
continue;
} }
};
let mb_norm = normalize_artist_name(mb_name);
// The blacklist filters INSIDE the match, not after it: when the
// top same-name artist is blocked, the next-best one takes the
// slot instead of the row disappearing. That is Tauri's rule.
let best = results
.items
.iter()
.filter(|a| normalize_artist_name(&a.name) == mb_norm && !blacklist(a.id))
.max_by_key(|a| a.albums_count.unwrap_or(0));
if let Some(qobuz_artist) = best {
let image_url = qobuz_artist
.image
.as_ref()
.and_then(|img| img.small.as_ref().or(img.thumbnail.as_ref()).cloned());
// Positive results only. Tauri's negative cache is disabled on
// purpose: "not on Qobuz" stuck for 30 days is how a newly
// added artist stays invisible.
if let Ok(guard) = self.musicbrainz_cache.lock() {
if let Some(cache) = guard.as_ref() {
let _ = cache.set_qobuz_artist_match(
&name_normalized,
&QobuzArtistMatch {
qobuz_id: qobuz_artist.id as i64,
name: qobuz_artist.name.clone(),
image: image_url.clone(),
albums_count: qobuz_artist.albums_count,
},
);
}
}
validated.push(LocationCandidate {
mbid: mbid.clone(),
mb_name: mb_name.clone(),
qobuz_id: Some(qobuz_artist.id as i64),
qobuz_name: Some(qobuz_artist.name.clone()),
qobuz_image: image_url,
score: *score,
genres: candidate_genres.clone(),
qobuz_albums_count: qobuz_artist.albums_count,
});
} }
} }
@@ -3443,16 +3431,201 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
// First page only, non-empty only — Tauri's rule. The stored rows are // First page only, non-empty only — Tauri's rule. The stored rows are
// already blacklist-aware; the post-filter on read catches anything // already blacklist-aware; the post-filter on read catches anything
// blocked afterwards, and `invalidate_scene_cache` covers un-blocking. // blocked afterwards, and `invalidate_scene_cache` covers un-blocking.
if offset == 0 && !response.artists.is_empty() { if let (0, false, Some(cache_key)) =
(offset, response.artists.is_empty(), cache_key.as_deref())
{
if let Ok(guard) = self.musicbrainz_cache.lock() { if let Ok(guard) = self.musicbrainz_cache.lock() {
if let Some(cache) = guard.as_ref() { if let Some(cache) = guard.as_ref() {
let _ = cache.set_scene_cache(&cache_key, &response); let _ = cache.set_scene_cache(cache_key, &response);
} }
} }
} }
Ok(response) Ok(response)
} }
/// Re-derive every cached scene row's Qobuz projection from the
/// identifier-keyed identity cache (cheap on a live row; a cheap Qobuz
/// re-search on an expired provisional one). Rows that no longer resolve
/// are dropped. Returns the number dropped.
async fn revalidate_scene_rows(
&self,
hit: &mut LocationDiscoveryResponse,
store: &CoreIdentityStore<'_>,
catalog: &SceneQobuz<'_, A>,
scope: Option<&str>,
blacklist: &(dyn Fn(u64) -> bool + Send + Sync),
) -> Result<usize, SceneDiscoveryError> {
let before = hit.artists.len();
let mut kept: Vec<LocationCandidate> = Vec::with_capacity(before);
for mut row in std::mem::take(&mut hit.artists) {
match identity::resolve_scene_candidate(
Some(store),
self.musicbrainz.as_ref(),
catalog,
&row.mbid,
&row.mb_name,
scope,
blacklist,
)
.await
{
Ok(Some(m)) => {
row.qobuz_id = Some(m.qobuz_id as i64);
row.qobuz_name = Some(m.name);
row.qobuz_image = m.image;
row.qobuz_albums_count = m.albums_count;
kept.push(row);
}
Ok(None) => {}
Err(e) => {
if let Some(fatal) = catalog.take_fatal() {
return Err(fatal);
}
// Transient: keep the cached projection for this row.
log::warn!("[scene] revalidation of {:?} failed, keeping cached row: {e}", row.mb_name);
kept.push(row);
}
}
}
let dropped = before.saturating_sub(kept.len());
hit.artists = kept;
Ok(dropped)
}
}
// ----- #768 identity boundaries ----------------------------------------------
/// `identity::IdentityStore` over the core's optional SQLite cache. Every
/// failure is logged and reads as a miss; without a cache the matcher simply
/// runs uncached.
struct CoreIdentityStore<'a>(&'a std::sync::Mutex<Option<MusicBrainzCache>>);
impl CoreIdentityStore<'_> {
fn with<T>(
&self,
what: &str,
f: impl FnOnce(&MusicBrainzCache) -> Result<T, String>,
) -> Option<T> {
let guard = self.0.lock().ok()?;
let cache = guard.as_ref()?;
match f(cache) {
Ok(v) => Some(v),
Err(e) => {
log::warn!("[mb-identity] cache {what}: {e}");
None
}
}
}
}
impl identity::IdentityStore for CoreIdentityStore<'_> {
fn artist_query(&self, key: &str) -> Option<Vec<ArtistCandidate>> {
self.with("artist_query", |c| c.get_artist_query(key)).flatten()
}
fn put_artist_query(&self, key: &str, window: &[ArtistCandidate]) {
self.with("put_artist_query", |c| c.set_artist_query(key, window));
}
fn isrc_credits(&self, isrc: &str) -> Option<IsrcCredits> {
self.with("isrc_credits", |c| c.get_isrc_credits(isrc)).flatten()
}
fn put_isrc_credits(&self, credits: &IsrcCredits) {
self.with("put_isrc_credits", |c| c.set_isrc_credits(credits));
}
fn source_identity(&self, qobuz_artist_id: u64) -> Option<SourceIdentityRow> {
self.with("source_identity", |c| c.get_source_identity(qobuz_artist_id))
.flatten()
}
fn put_source_identity(&self, row: &SourceIdentityRow) {
self.with("put_source_identity", |c| c.set_source_identity(row));
}
fn scene_identity(&self, mbid: &str, scope: &str) -> Option<SceneIdentityRow> {
self.with("scene_identity", |c| c.get_scene_identity(mbid, scope))
.flatten()
}
fn put_scene_identity(&self, row: &SceneIdentityRow) {
self.with("put_scene_identity", |c| c.set_scene_identity(row));
}
}
/// `identity::SceneCatalog` over the core's Qobuz client. A Qobuz failure
/// that will refuse every further call (auth/region, `scene_fatal_qobuz`)
/// is parked in `fatal` so the validation loop can end the page instead of
/// burning the remaining searches.
struct SceneQobuz<'a, A: FrontendAdapter + Send + Sync + 'static> {
core: &'a QbzCore<A>,
fatal: std::sync::Mutex<Option<SceneDiscoveryError>>,
}
impl<A: FrontendAdapter + Send + Sync + 'static> SceneQobuz<'_, A> {
fn park(&self, e: &CoreError) -> FetchError {
if let Some(fatal) = scene_fatal_qobuz(e) {
if let Ok(mut slot) = self.fatal.lock() {
*slot = Some(fatal);
}
}
FetchError::Unavailable(e.to_string())
}
fn take_fatal(&self) -> Option<SceneDiscoveryError> {
self.fatal.lock().ok().and_then(|mut slot| slot.take())
}
}
impl<A: FrontendAdapter + Send + Sync + 'static> identity::SceneCatalog for SceneQobuz<'_, A> {
/// The bounded Qobuz search (five results, unchanged) filtered to exact
/// normalised names. The blacklist is applied by the matcher.
fn exact_name_candidates<'a>(
&'a self,
name: &'a str,
) -> BoxFuture<'a, Result<Vec<SceneQobuzCandidate>, FetchError>> {
Box::pin(async move {
let results = self
.core
.search_artists(name, 5, 0, None)
.await
.map_err(|e| self.park(&e))?;
let norm = normalize_artist_name(name);
Ok(results
.items
.iter()
.filter(|a| normalize_artist_name(&a.name) == norm)
.map(|a| SceneQobuzCandidate {
qobuz_id: a.id,
name: a.name.clone(),
image: a
.image
.as_ref()
.and_then(|img| img.small.as_ref().or(img.thumbnail.as_ref()).cloned()),
albums_count: a.albums_count,
})
.collect())
})
}
/// One `/artist/page` per AMBIGUOUS candidate, its own top-track ISRCs
/// only (guest appearances excluded), capped by the matcher.
fn candidate_isrcs<'a>(
&'a self,
qobuz_id: u64,
cap: usize,
) -> BoxFuture<'a, Result<Vec<String>, FetchError>> {
Box::pin(async move {
let page = self
.core
.get_artist_page(qobuz_id, None)
.await
.map_err(|e| self.park(&e))?;
let tracks = page.top_tracks.unwrap_or_default();
Ok(identity::pick_source_isrcs(
qobuz_id,
tracks
.iter()
.map(|t| (t.isrc.as_deref(), t.artist.as_ref().map(|a| a.id))),
cap,
))
})
}
} }
// ----- Musician resolution + appearances ----------------------------------- // ----- Musician resolution + appearances -----------------------------------
@@ -7,6 +7,10 @@ use rusqlite::{params, Connection, OptionalExtension};
use std::path::Path; use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use super::identity::{
ArtistCandidate, EvidenceKind, IsrcCredits, SceneIdentityRow, SourceIdentityRow,
MATCHER_VERSION,
};
use super::models::{ use super::models::{
ArtistMetadata, ArtistRelationships, ArtistType, LocationDiscoveryResponse, MatchConfidence, ArtistMetadata, ArtistRelationships, ArtistType, LocationDiscoveryResponse, MatchConfidence,
ResolvedArtist, ResolvedTrack, ResolvedArtist, ResolvedTrack,
@@ -28,6 +32,15 @@ const SCENE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
const QOBUZ_VALIDATION_TTL_SECS: i64 = 30 * 24 * 60 * 60; const QOBUZ_VALIDATION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL for the typed Qobuz artist-match cache (30 days, same as above) /// TTL for the typed Qobuz artist-match cache (30 days, same as above)
const QOBUZ_ARTIST_MATCH_TTL_SECS: i64 = 30 * 24 * 60 * 60; const QOBUZ_ARTIST_MATCH_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL of a cached exact-name search WINDOW (evidence, re-gated on every hit)
const ARTIST_QUERY_TTL_SECS: i64 = 7 * 24 * 60 * 60;
/// TTL of a cached ISRC → recording-credits set
const ISRC_CREDITS_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL of an ISRC-verified identity row (source or scene)
const VERIFIED_IDENTITY_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL of a provisional (unique-exact-name) scene identity row — a name alone
/// is not identity, so it may not outlive a week
const PROVISIONAL_IDENTITY_TTL_SECS: i64 = 7 * 24 * 60 * 60;
/// The Qobuz half of a scene validation, and NOTHING else. /// The Qobuz half of a scene validation, and NOTHING else.
/// ///
@@ -81,6 +94,15 @@ impl MusicBrainzCache {
Ok(cache) Ok(cache)
} }
/// An in-memory cache with the full schema — tests and dry runs.
pub fn open_in_memory() -> Result<Self, String> {
let conn = Connection::open_in_memory()
.map_err(|e| format!("Failed to open in-memory MusicBrainz cache: {}", e))?;
let cache = Self { conn };
cache.init_schema()?;
Ok(cache)
}
fn init_schema(&self) -> Result<(), String> { fn init_schema(&self) -> Result<(), String> {
self.conn self.conn
.execute_batch( .execute_batch(
@@ -183,6 +205,44 @@ impl MusicBrainzCache {
cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
-- #768 identity matcher (identity.rs). Versioned, identifier-
-- keyed, provenance-carrying; TTL is applied at READ time.
-- Old name-keyed rows (resolved_artists, mb_qobuz_artist_match)
-- stay on disk, inert: nothing on the corrected path reads them.
CREATE TABLE IF NOT EXISTS mb_artist_query_v2 (
query_key TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS mb_isrc_credits_v2 (
isrc TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS mb_source_identity_v2 (
qobuz_artist_id INTEGER NOT NULL,
matcher_version INTEGER NOT NULL,
mbid TEXT NOT NULL,
evidence_kind TEXT NOT NULL,
evidence_count INTEGER NOT NULL,
source_name TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
PRIMARY KEY (qobuz_artist_id, matcher_version)
);
CREATE TABLE IF NOT EXISTS mb_scene_identity_v2 (
mbid TEXT NOT NULL,
scope TEXT NOT NULL,
matcher_version INTEGER NOT NULL,
qobuz_artist_id INTEGER NOT NULL,
evidence_kind TEXT NOT NULL,
evidence_count INTEGER NOT NULL,
qobuz_name TEXT NOT NULL,
image TEXT,
albums_count INTEGER,
fetched_at INTEGER NOT NULL,
PRIMARY KEY (mbid, scope, matcher_version)
);
CREATE TABLE IF NOT EXISTS cache_stats ( CREATE TABLE IF NOT EXISTS cache_stats (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0 value INTEGER NOT NULL DEFAULT 0
@@ -715,6 +775,242 @@ impl MusicBrainzCache {
Ok(()) Ok(())
} }
// ============ #768 identity matcher (identity.rs) ============
//
// Every getter enforces TTL and matcher version in the query itself and
// treats a malformed row as a miss. The `*_at` variants exist so tests can
// plant an old row without waiting for the clock.
fn kind_ttl(kind: EvidenceKind) -> i64 {
match kind {
EvidenceKind::VerifiedIsrc => VERIFIED_IDENTITY_TTL_SECS,
EvidenceKind::UniqueExactName => PROVISIONAL_IDENTITY_TTL_SECS,
}
}
/// Cached exact-name search window for `query_key` (evidence only —
/// the caller re-runs the acceptance rule on it).
pub fn get_artist_query(&self, query_key: &str) -> Result<Option<Vec<ArtistCandidate>>, String> {
let min_fetched_at = Self::current_timestamp() - ARTIST_QUERY_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_artist_query_v2 WHERE query_key = ? AND fetched_at > ?",
params![query_key, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query artist-query cache: {}", e))?;
Ok(result.and_then(|data| serde_json::from_str(&data).ok()))
}
pub fn set_artist_query(&self, query_key: &str, window: &[ArtistCandidate]) -> Result<(), String> {
self.set_artist_query_at(query_key, window, Self::current_timestamp())
}
pub fn set_artist_query_at(
&self,
query_key: &str,
window: &[ArtistCandidate],
fetched_at: i64,
) -> Result<(), String> {
let json = serde_json::to_string(window)
.map_err(|e| format!("Failed to serialize artist query: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_artist_query_v2 (query_key, data, fetched_at) VALUES (?, ?, ?)",
params![query_key, json, fetched_at],
)
.map_err(|e| format!("Failed to cache artist query: {}", e))?;
Ok(())
}
/// Cached complete recording/credit set for a normalised ISRC.
pub fn get_isrc_credits(&self, isrc: &str) -> Result<Option<IsrcCredits>, String> {
let min_fetched_at = Self::current_timestamp() - ISRC_CREDITS_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_isrc_credits_v2 WHERE isrc = ? AND fetched_at > ?",
params![isrc, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query isrc-credits cache: {}", e))?;
Ok(result
.and_then(|data| serde_json::from_str::<IsrcCredits>(&data).ok())
.filter(|c| c.isrc == isrc))
}
pub fn set_isrc_credits(&self, credits: &IsrcCredits) -> Result<(), String> {
self.set_isrc_credits_at(credits, Self::current_timestamp())
}
pub fn set_isrc_credits_at(&self, credits: &IsrcCredits, fetched_at: i64) -> Result<(), String> {
let json = serde_json::to_string(credits)
.map_err(|e| format!("Failed to serialize isrc credits: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_isrc_credits_v2 (isrc, data, fetched_at) VALUES (?, ?, ?)",
params![credits.isrc, json, fetched_at],
)
.map_err(|e| format!("Failed to cache isrc credits: {}", e))?;
Ok(())
}
/// The VERIFIED source identity for a Qobuz artist id under the current
/// matcher version, if it has not expired. Provisional rows are never
/// stored here (identity.rs only writes `VerifiedIsrc`), and a row whose
/// kind or MBID is malformed is a miss.
pub fn get_source_identity(&self, qobuz_artist_id: u64) -> Result<Option<SourceIdentityRow>, String> {
let min_fetched_at = Self::current_timestamp() - VERIFIED_IDENTITY_TTL_SECS;
let result: Option<(String, String, i64, String)> = self
.conn
.query_row(
"SELECT mbid, evidence_kind, evidence_count, source_name
FROM mb_source_identity_v2
WHERE qobuz_artist_id = ? AND matcher_version = ? AND fetched_at > ?",
params![qobuz_artist_id as i64, MATCHER_VERSION as i64, min_fetched_at],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()
.map_err(|e| format!("Failed to query source identity: {}", e))?;
Ok(result.and_then(|(mbid, kind, count, source_name)| {
let evidence_kind = EvidenceKind::parse(&kind)?;
if evidence_kind != EvidenceKind::VerifiedIsrc || mbid.trim().is_empty() {
return None;
}
Some(SourceIdentityRow {
qobuz_artist_id,
mbid,
evidence_kind,
evidence_count: u32::try_from(count).unwrap_or(0),
source_name,
})
}))
}
pub fn set_source_identity(&self, row: &SourceIdentityRow) -> Result<(), String> {
self.set_source_identity_at(row, Self::current_timestamp())
}
pub fn set_source_identity_at(&self, row: &SourceIdentityRow, fetched_at: i64) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO mb_source_identity_v2
(qobuz_artist_id, matcher_version, mbid, evidence_kind, evidence_count, source_name, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
params![
row.qobuz_artist_id as i64,
MATCHER_VERSION as i64,
row.mbid,
row.evidence_kind.as_str(),
row.evidence_count as i64,
row.source_name,
fetched_at
],
)
.map_err(|e| format!("Failed to cache source identity: {}", e))?;
Ok(())
}
/// The scene identity for (MBID, catalog scope) under the current matcher
/// version, with the TTL of ITS OWN evidence kind (verified 30 d,
/// provisional 7 d). Malformed rows are a miss.
pub fn get_scene_identity(&self, mbid: &str, scope: &str) -> Result<Option<SceneIdentityRow>, String> {
let now = Self::current_timestamp();
let result: Option<(i64, String, i64, String, Option<String>, Option<i64>, i64)> = self
.conn
.query_row(
"SELECT qobuz_artist_id, evidence_kind, evidence_count, qobuz_name, image, albums_count, fetched_at
FROM mb_scene_identity_v2
WHERE mbid = ? AND scope = ? AND matcher_version = ?",
params![mbid, scope, MATCHER_VERSION as i64],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
))
},
)
.optional()
.map_err(|e| format!("Failed to query scene identity: {}", e))?;
Ok(result.and_then(
|(qobuz_id, kind, count, qobuz_name, image, albums_count, fetched_at)| {
let evidence_kind = EvidenceKind::parse(&kind)?;
if fetched_at <= now - Self::kind_ttl(evidence_kind) {
return None;
}
let qobuz_id = u64::try_from(qobuz_id).ok().filter(|id| *id > 0)?;
Some(SceneIdentityRow {
mbid: mbid.to_string(),
scope: scope.to_string(),
qobuz_id,
evidence_kind,
evidence_count: u32::try_from(count).unwrap_or(0),
qobuz_name,
image,
albums_count: albums_count.and_then(|n| u32::try_from(n).ok()),
})
},
))
}
pub fn set_scene_identity(&self, row: &SceneIdentityRow) -> Result<(), String> {
self.set_scene_identity_at(row, Self::current_timestamp())
}
pub fn set_scene_identity_at(&self, row: &SceneIdentityRow, fetched_at: i64) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO mb_scene_identity_v2
(mbid, scope, matcher_version, qobuz_artist_id, evidence_kind, evidence_count, qobuz_name, image, albums_count, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
row.mbid,
row.scope,
MATCHER_VERSION as i64,
row.qobuz_id as i64,
row.evidence_kind.as_str(),
row.evidence_count as i64,
row.qobuz_name,
row.image,
row.albums_count.map(|n| n as i64),
fetched_at
],
)
.map_err(|e| format!("Failed to cache scene identity: {}", e))?;
Ok(())
}
/// Plant a row under an ARBITRARY matcher version (tests: an old matcher's
/// row must not satisfy the current lookup).
#[cfg(test)]
fn set_source_identity_versioned(&self, row: &SourceIdentityRow, version: i64) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO mb_source_identity_v2
(qobuz_artist_id, matcher_version, mbid, evidence_kind, evidence_count, source_name, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
params![
row.qobuz_artist_id as i64,
version,
row.mbid,
row.evidence_kind.as_str(),
row.evidence_count as i64,
row.source_name,
Self::current_timestamp()
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
// ============ Maintenance ============ // ============ Maintenance ============
/// Clear expired entries from all tables /// Clear expired entries from all tables
@@ -731,6 +1027,12 @@ impl MusicBrainzCache {
("mb_scene_cache", SCENE_TTL_SECS), ("mb_scene_cache", SCENE_TTL_SECS),
("mb_qobuz_validation", QOBUZ_VALIDATION_TTL_SECS), ("mb_qobuz_validation", QOBUZ_VALIDATION_TTL_SECS),
("mb_qobuz_artist_match", QOBUZ_ARTIST_MATCH_TTL_SECS), ("mb_qobuz_artist_match", QOBUZ_ARTIST_MATCH_TTL_SECS),
("mb_artist_query_v2", ARTIST_QUERY_TTL_SECS),
("mb_isrc_credits_v2", ISRC_CREDITS_TTL_SECS),
("mb_source_identity_v2", VERIFIED_IDENTITY_TTL_SECS),
// The longer of the two kinds; the read path applies the
// provisional one itself.
("mb_scene_identity_v2", VERIFIED_IDENTITY_TTL_SECS),
]; ];
for (table, ttl) in &tables_and_ttls { for (table, ttl) in &tables_and_ttls {
@@ -767,6 +1069,10 @@ impl MusicBrainzCache {
DELETE FROM mb_qobuz_artist_match; DELETE FROM mb_qobuz_artist_match;
DELETE FROM resolved_tracks; DELETE FROM resolved_tracks;
DELETE FROM resolved_artists; DELETE FROM resolved_artists;
DELETE FROM mb_artist_query_v2;
DELETE FROM mb_isrc_credits_v2;
DELETE FROM mb_source_identity_v2;
DELETE FROM mb_scene_identity_v2;
UPDATE cache_stats SET value = 0; UPDATE cache_stats SET value = 0;
", ",
) )
@@ -825,3 +1131,193 @@ impl MusicBrainzCache {
); );
} }
} }
#[cfg(test)]
mod identity_cache_tests {
use super::*;
fn cand(mbid: &str, name: &str) -> ArtistCandidate {
ArtistCandidate {
mbid: mbid.into(),
name: name.into(),
score: Some(100),
sort_name: None,
artist_type: None,
country: None,
disambiguation: None,
}
}
fn source_row(qobuz: u64, mbid: &str, kind: EvidenceKind) -> SourceIdentityRow {
SourceIdentityRow {
qobuz_artist_id: qobuz,
mbid: mbid.into(),
evidence_kind: kind,
evidence_count: 1,
source_name: "Eve".into(),
}
}
fn scene_row(mbid: &str, scope: &str, qobuz: u64, kind: EvidenceKind) -> SceneIdentityRow {
SceneIdentityRow {
mbid: mbid.into(),
scope: scope.into(),
qobuz_id: qobuz,
evidence_kind: kind,
evidence_count: 1,
qobuz_name: "Eve".into(),
image: None,
albums_count: Some(3),
}
}
#[test]
fn two_qobuz_ids_with_the_same_name_do_not_share_a_source_mapping() {
let c = MusicBrainzCache::open_in_memory().unwrap();
c.set_source_identity(&source_row(7, "mb-a", EvidenceKind::VerifiedIsrc)).unwrap();
assert_eq!(c.get_source_identity(7).unwrap().unwrap().mbid, "mb-a");
assert!(c.get_source_identity(8).unwrap().is_none());
}
#[test]
fn two_mbids_with_the_same_name_do_not_share_a_scene_mapping() {
let c = MusicBrainzCache::open_in_memory().unwrap();
c.set_scene_identity(&scene_row("mb-a", "FR", 7, EvidenceKind::VerifiedIsrc)).unwrap();
assert_eq!(c.get_scene_identity("mb-a", "FR").unwrap().unwrap().qobuz_id, 7);
assert!(c.get_scene_identity("mb-b", "FR").unwrap().is_none());
assert!(c.get_scene_identity("mb-a", "US").unwrap().is_none(), "scope is part of the key");
}
#[test]
fn legacy_name_keyed_rows_cannot_satisfy_a_v2_lookup() {
let c = MusicBrainzCache::open_in_memory().unwrap();
// The pre-fix tables still accept writes; the v2 getters never read them.
c.put_artist(&ResolvedArtist {
mbid: "deep-purple".into(),
name: "Deep Purple".into(),
sort_name: None,
artist_type: ArtistType::Group,
country: None,
disambiguation: None,
confidence: MatchConfidence::Exact,
})
.unwrap();
c.set_qobuz_artist_match(
"swim deep",
&QobuzArtistMatch {
qobuz_id: 42,
name: "Swim Deep".into(),
image: None,
albums_count: Some(3),
},
)
.unwrap();
assert!(c.get_source_identity(42).unwrap().is_none());
assert!(c.get_scene_identity("85e67b0f-afd2-46c2-88b2-c3ba9b0883f2", "FR").unwrap().is_none());
assert!(c.get_artist_query(&super::super::identity::artist_query_key("Swim Deep")).unwrap().is_none());
}
#[test]
fn a_provisional_kind_never_reads_back_as_a_verified_source_identity() {
let c = MusicBrainzCache::open_in_memory().unwrap();
c.set_source_identity(&source_row(7, "mb-a", EvidenceKind::UniqueExactName)).unwrap();
assert!(c.get_source_identity(7).unwrap().is_none());
}
#[test]
fn expired_rows_miss_at_read_time_without_any_cleanup_call() {
let c = MusicBrainzCache::open_in_memory().unwrap();
let now = MusicBrainzCache::current_timestamp();
c.set_source_identity_at(
&source_row(7, "mb-a", EvidenceKind::VerifiedIsrc),
now - VERIFIED_IDENTITY_TTL_SECS - 1,
)
.unwrap();
assert!(c.get_source_identity(7).unwrap().is_none());
// Scene: provisional expires after 7 days, verified after 30.
c.set_scene_identity_at(
&scene_row("mb-p", "FR", 1, EvidenceKind::UniqueExactName),
now - PROVISIONAL_IDENTITY_TTL_SECS - 1,
)
.unwrap();
c.set_scene_identity_at(
&scene_row("mb-v", "FR", 2, EvidenceKind::VerifiedIsrc),
now - PROVISIONAL_IDENTITY_TTL_SECS - 1,
)
.unwrap();
assert!(c.get_scene_identity("mb-p", "FR").unwrap().is_none());
assert_eq!(c.get_scene_identity("mb-v", "FR").unwrap().unwrap().qobuz_id, 2);
c.set_artist_query_at("k", &[cand("a", "Eve")], now - ARTIST_QUERY_TTL_SECS - 1)
.unwrap();
assert!(c.get_artist_query("k").unwrap().is_none());
c.set_isrc_credits_at(
&IsrcCredits {
isrc: "GBAAA0000001".into(),
recordings: vec![],
},
now - ISRC_CREDITS_TTL_SECS - 1,
)
.unwrap();
assert!(c.get_isrc_credits("GBAAA0000001").unwrap().is_none());
}
#[test]
fn a_previous_matcher_version_row_is_a_miss() {
let c = MusicBrainzCache::open_in_memory().unwrap();
c.set_source_identity_versioned(
&source_row(7, "mb-a", EvidenceKind::VerifiedIsrc),
MATCHER_VERSION as i64 - 1,
)
.unwrap();
assert!(c.get_source_identity(7).unwrap().is_none());
}
#[test]
fn malformed_provenance_is_a_miss() {
let c = MusicBrainzCache::open_in_memory().unwrap();
c.conn
.execute(
"INSERT INTO mb_source_identity_v2 (qobuz_artist_id, matcher_version, mbid, evidence_kind, evidence_count, source_name, fetched_at) VALUES (7, ?, '', 'verified_isrc', 1, 'Eve', ?)",
params![MATCHER_VERSION as i64, MusicBrainzCache::current_timestamp()],
)
.unwrap();
assert!(c.get_source_identity(7).unwrap().is_none(), "empty mbid");
c.conn
.execute(
"INSERT INTO mb_scene_identity_v2 (mbid, scope, matcher_version, qobuz_artist_id, evidence_kind, evidence_count, qobuz_name, image, albums_count, fetched_at) VALUES ('m', 'FR', ?, 9, 'guessed', 1, 'Eve', NULL, NULL, ?)",
params![MATCHER_VERSION as i64, MusicBrainzCache::current_timestamp()],
)
.unwrap();
assert!(c.get_scene_identity("m", "FR").unwrap().is_none(), "unknown kind");
}
#[test]
fn search_window_and_isrc_credits_round_trip() {
let c = MusicBrainzCache::open_in_memory().unwrap();
let window = vec![cand("a", "Eve"), cand("b", "Eve")];
c.set_artist_query("k", &window).unwrap();
assert_eq!(c.get_artist_query("k").unwrap().unwrap(), window);
let credits = IsrcCredits {
isrc: "GBAAA0000001".into(),
recordings: vec![super::super::identity::RecordingCredits {
recording_mbid: "r".into(),
credit_mbids: vec!["a".into(), "b".into()],
}],
};
c.set_isrc_credits(&credits).unwrap();
assert_eq!(c.get_isrc_credits("GBAAA0000001").unwrap().unwrap(), credits);
}
#[test]
fn clear_all_and_cleanup_cover_the_v2_tables() {
let c = MusicBrainzCache::open_in_memory().unwrap();
c.set_source_identity(&source_row(7, "mb-a", EvidenceKind::VerifiedIsrc)).unwrap();
c.clear_all().unwrap();
assert!(c.get_source_identity(7).unwrap().is_none());
let old = MusicBrainzCache::current_timestamp() - VERIFIED_IDENTITY_TTL_SECS - 1;
c.set_source_identity_at(&source_row(7, "mb-a", EvidenceKind::VerifiedIsrc), old).unwrap();
assert_eq!(c.cleanup_expired().unwrap(), 1);
}
}
@@ -8,6 +8,7 @@ use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use super::identity;
use super::models::*; use super::models::*;
use crate::error::{IntegrationError, IntegrationResult}; use crate::error::{IntegrationError, IntegrationResult};
@@ -196,12 +197,11 @@ impl MusicBrainzClient {
} }
// Exact quoted field expression, URL-encoded exactly once (#768:
// `artist:Swim Deep` let "Deep" leak into the global query and ranked
// Deep Purple first at score 100).
let base = self.base_url().await; let base = self.base_url().await;
let encoded_name = urlencoding::encode(name); let url = identity::artist_search_url(base, name, limit);
let url = format!(
"{}/artist?query=artist:{}&limit={}&fmt=json",
base, encoded_name, limit
);
let response = self.send_with_retry(&url).await?; let response = self.send_with_retry(&url).await?;
response.json().await.map_err(Into::into) response.json().await.map_err(Into::into)
@@ -255,40 +255,29 @@ impl MusicBrainzClient {
Ok(None) Ok(None)
} }
/// Resolve an artist to get MusicBrainz ID /// The quoted-name search window as cacheable candidates.
/// pub async fn search_artist_candidates(
/// Prefers exact name matches over highest score to avoid disambiguation &self,
/// issues (e.g., multiple artists named "The Warning"). name: &str,
) -> IntegrationResult<Vec<identity::ArtistCandidate>> {
let response = self
.search_artist(name, identity::NAME_SEARCH_LIMIT)
.await?;
Ok(response.artists.iter().map(identity::ArtistCandidate::from).collect())
}
/// Resolve an artist NAME to a MusicBrainz id under the containment rule
/// (`identity::select_exact_name`): exactly one result in the quoted-name
/// window with the same trimmed, case-insensitive name and score >= 90.
/// Zero or several acceptable candidates resolve to `None`. There is no
/// first-result fallback any more — that fallback is how #768 handed
/// Swim Deep the biography of Deep Purple.
pub async fn resolve_artist(&self, name: &str) -> IntegrationResult<Option<ResolvedArtist>> { pub async fn resolve_artist(&self, name: &str) -> IntegrationResult<Option<ResolvedArtist>> {
let response = self.search_artist(name, 10).await?; let window = self.search_artist_candidates(name).await?;
Ok(match identity::select_exact_name(name, &window) {
if response.artists.is_empty() { identity::ExactNameSelection::Unique(c) => Some(c.to_resolved()),
return Ok(None); _ => None,
} })
// Prefer exact name match (case-insensitive)
let target = name.trim().to_lowercase();
let best = response
.artists
.iter()
.find(|a| a.name.trim().to_lowercase() == target && a.score.unwrap_or(0) >= 90)
.or_else(|| response.artists.first());
if let Some(artist) = best {
let confidence = MatchConfidence::from_score(artist.score);
return Ok(Some(ResolvedArtist {
mbid: artist.id.clone(),
name: artist.name.clone(),
sort_name: artist.sort_name.clone(),
artist_type: ArtistType::from(artist.artist_type.as_deref()),
country: artist.country.clone(),
disambiguation: artist.disambiguation.clone(),
confidence,
}));
}
Ok(None)
} }
// ============ Extended API Methods ============ // ============ Extended API Methods ============
@@ -867,24 +856,33 @@ impl MusicBrainzClient {
/// Escape special characters in Lucene queries /// Escape special characters in Lucene queries
fn escape_query(s: &str) -> String { fn escape_query(s: &str) -> String {
s.replace('\\', "\\\\") identity::lucene_escape(s)
.replace('"', "\\\"") }
.replace(':', "\\:") }
.replace('(', "\\(")
.replace(')', "\\)") impl identity::IdentityFetcher for MusicBrainzClient {
.replace('[', "\\[") fn artist_candidates<'a>(
.replace(']', "\\]") &'a self,
.replace('{', "\\{") name: &'a str,
.replace('}', "\\}") ) -> identity::BoxFuture<'a, Result<Vec<identity::ArtistCandidate>, identity::FetchError>> {
.replace('^', "\\^") Box::pin(async move {
.replace('~', "\\~") self.search_artist_candidates(name)
.replace('*', "\\*") .await
.replace('?', "\\?") .map_err(|e| identity::FetchError::Unavailable(e.to_string()))
.replace('!', "\\!") })
.replace('+', "\\+") }
.replace('-', "\\-")
.replace('&', "\\&") fn isrc_credits<'a>(
.replace('|', "\\|") &'a self,
isrc: &'a str,
) -> identity::BoxFuture<'a, Result<identity::IsrcCredits, identity::FetchError>> {
Box::pin(async move {
let response = self
.search_recording_by_isrc(isrc)
.await
.map_err(|e| identity::FetchError::Unavailable(e.to_string()))?;
Ok(identity::IsrcCredits::from_search(isrc, &response))
})
} }
} }
File diff suppressed because it is too large. Load diff
@@ -266,7 +266,10 @@ pub fn build_scene_cache_key(area_id: &str, seeds: &AffinitySeeds) -> String {
/// the per-genre limit, the `LocationCandidate` shape. Bumping is how old /// the per-genre limit, the `LocationCandidate` shape. Bumping is how old
/// entries get abandoned; there is no migration and none is wanted, since a /// entries get abandoned; there is no migration and none is wanted, since a
/// stale scene is indistinguishable from a fresh one on the wire. /// stale scene is indistinguishable from a fresh one on the wire.
pub const SCENE_CACHE_KEY_VERSION: &str = "v1"; /// v2 (2026-09-11, #768): the catalog scope became a REAL key component and
/// rows are re-derived from the identifier-keyed identity cache on replay;
/// v1 entries (name-validated by `max(albums_count)`) are orphaned on purpose.
pub const SCENE_CACHE_KEY_VERSION: &str = "v2";
/// Everything a scene discovery response actually depends on. /// Everything a scene discovery response actually depends on.
/// ///
@@ -26,6 +26,7 @@
//! ``` //! ```
mod client; mod client;
pub mod identity;
mod models; mod models;
#[cfg(feature = "cache")] #[cfg(feature = "cache")]
+60 -14
View File
@@ -634,6 +634,25 @@ pub async fn load_artist(
runtime: &Arc<AppRuntime<LoggingAdapter>>, runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str, artist_id: &str,
) -> Result<ArtistViewData, String> { ) -> Result<ArtistViewData, String> {
load_artist_with_evidence(runtime, artist_id)
.await
.map(|(data, _)| data)
}
/// The raw identity evidence the Artist Page hands the MusicBrainz source
/// resolver (#768): the page's Qobuz id and the ISRCs of its OWN Popular
/// Tracks. Collected off the raw `/artist/page` response before the lossy
/// `map_artist`, so no field is added to the rendered document and no second
/// Qobuz request is made. Never reaches QML.
pub(crate) struct SourceEvidence {
pub qobuz_id: u64,
pub isrcs: Vec<String>,
}
pub(crate) async fn load_artist_with_evidence(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str,
) -> Result<(ArtistViewData, SourceEvidence), String> {
let id: u64 = artist_id let id: u64 = artist_id
.parse() .parse()
.map_err(|_| format!("invalid artist id: {artist_id}"))?; .map_err(|_| format!("invalid artist id: {artist_id}"))?;
@@ -642,7 +661,19 @@ pub async fn load_artist(
.get_artist_page(id, None) .get_artist_page(id, None)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(map_artist(page)) let isrcs = qbz_integrations::musicbrainz::identity::pick_source_isrcs(
page.id,
page.top_tracks
.iter()
.flatten()
.map(|t| (t.isrc.as_deref(), t.artist.as_ref().map(|a| a.id))),
qbz_integrations::musicbrainz::identity::MAX_SOURCE_ISRCS,
);
let evidence = SourceEvidence {
qobuz_id: page.id,
isrcs,
};
Ok((map_artist(page), evidence))
} }
fn map_artist(page: PageArtistResponse) -> ArtistViewData { fn map_artist(page: PageArtistResponse) -> ArtistViewData {
@@ -1340,7 +1371,7 @@ pub async fn load_artist_view(
artist_id: &str, artist_id: &str,
) -> Result<String, String> { ) -> Result<String, String> {
let t = Instant::now(); let t = Instant::now();
let mut data = load_artist(runtime, artist_id).await?; let (mut data, evidence) = load_artist_with_evidence(runtime, artist_id).await?;
// The embedded artist/page buckets use release-date order. A persisted // The embedded artist/page buckets use release-date order. A persisted
// popularity choice must fetch the server ranking before the first paint. // popularity choice must fetch the server ranking before the first paint.
for section in &mut data.release_sections { for section in &mut data.release_sections {
@@ -1411,6 +1442,7 @@ pub async fn load_artist_view(
generation, generation,
artist_id.to_string(), artist_id.to_string(),
data.name.clone(), data.name.clone(),
evidence,
similar_names, similar_names,
mb_on, mb_on,
); );
@@ -1598,6 +1630,7 @@ fn spawn_enrichment(
generation: u64, generation: u64,
artist_id: String, artist_id: String,
artist_name: String, artist_name: String,
evidence: SourceEvidence,
similar_names: Vec<String>, similar_names: Vec<String>,
mb_on: bool, mb_on: bool,
) { ) {
@@ -1619,7 +1652,7 @@ fn spawn_enrichment(
return; return;
} }
crate::spawn(async move { crate::spawn(async move {
let meta = match load_mb_metadata(&runtime, &artist_name).await { let meta = match load_mb_metadata(&runtime, &evidence, &artist_name).await {
Ok(Some(meta)) => meta, Ok(Some(meta)) => meta,
Ok(None) => { Ok(None) => {
publish_mb_unavailable(generation); publish_mb_unavailable(generation);
@@ -1735,27 +1768,40 @@ struct MbMetadata {
origin: MbOriginJson, origin: MbOriginJson,
} }
/// Resolve the artist name to an MBID, then fetch its metadata. `Ok(None)` = /// Resolve the SOURCE artist (Qobuz id + name + own Popular-Track ISRCs) to
/// MB disabled or no confident match — the caller hides every MB section. /// an MBID, then fetch its metadata. `Ok(None)` = MB disabled, unavailable,
/// or no identity strong enough to show — the caller hides every MB section.
/// Missing Network data beats another band's biography (#768). Identity and
/// voting live in core/`identity`; this only passes evidence and renders.
async fn load_mb_metadata( async fn load_mb_metadata(
runtime: &Arc<AppRuntime<LoggingAdapter>>, runtime: &Arc<AppRuntime<LoggingAdapter>>,
evidence: &SourceEvidence,
artist_name: &str, artist_name: &str,
) -> Result<Option<MbMetadata>, String> { ) -> Result<Option<MbMetadata>, String> {
if !runtime.core().musicbrainz_is_enabled().await { if !runtime.core().musicbrainz_is_enabled().await {
return Ok(None); return Ok(None);
} }
let resolved = runtime let resolution = runtime
.core() .core()
.musicbrainz_resolve_artist(artist_name) .musicbrainz_resolve_source_artist(evidence.qobuz_id, artist_name, &evidence.isrcs)
.await .await;
.map_err(|e| e.to_string())?; log::info!(
let Some(resolved) = resolved else { "[qbz-qt] artist {} {:?}: MB identity {} ({} isrcs offered, {} MB requests, cache {})",
evidence.qobuz_id,
artist_name,
resolution.identity.label(),
evidence.isrcs.len(),
resolution.requests,
resolution.cache
);
let Some(mbid) = resolution
.identity
.displayable_mbid()
.map(str::to_string)
.filter(|m| !m.is_empty())
else {
return Ok(None); return Ok(None);
}; };
let mbid = resolved.mbid;
if mbid.is_empty() {
return Ok(None);
}
let meta = runtime let meta = runtime
.core() .core()
.musicbrainz_get_artist_metadata(&mbid) .musicbrainz_get_artist_metadata(&mbid)