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:
7 files changed
+2555
-207
No files matched your search
+309
-136
@@ -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")]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user