Enrichment matched artists by NAME ONLY (0.85 gate), so for a common name
('Rone' has ~5 artists) it stored whichever the source ranked first — often the
wrong one, which then drove a wrong/sparse library 'Standard' discography while
'Enhanced' (the real owned albums) showed the full set.
Fix — use the decisive signal the library already has (the albums you OWN):
- worker_utils: pick_artist_by_catalog() + catalog_overlap_score() +
owned_album_titles()/release_titles(). When 2+ candidates clear the name gate,
fetch each one's catalog and choose the one overlapping the owned albums; falls
back to the current best-by-name pick when there's nothing to disambiguate or
no overlap (so the common single-candidate path makes no extra API calls).
- Wired into Spotify (covers Spotify-Free, same client), iTunes, Deezer (now
multi-candidate search_artists + get_artist_info store), and MusicBrainz
(match_artist gains owned_titles; release-groups as the catalog).
Re-match path (#868):
- build_reset_query now also clears the stored source-ID column for artist/album
item resets — previously a 're-match' only nulled match_status, so the worker's
existing-id short-circuit re-confirmed the WRONG id and never re-resolved. Tracks
excluded (ids live in tags, not a column).
- MusicBrainz also self-corrects its 90-day name->mbid cache: match_artist bypasses
a cached mbid whose catalog has ZERO overlap with the owned albums, so a re-match
isn't blocked by a stale wrong cache entry.
Tests: shared selector (9), per-worker disambiguation for all 4 sources + MB
backward-compat + MB cache-revalidation (8), reset-clears-id (2). 99 worker/
enrichment tests green.
798 lines
34 KiB
Python
798 lines
34 KiB
Python
from typing import Optional, Dict, Any
|
|
import json
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from difflib import SequenceMatcher
|
|
from utils.logging_config import get_logger
|
|
from core.musicbrainz_client import MusicBrainzClient
|
|
from core.worker_utils import catalog_overlap_score, pick_artist_by_catalog
|
|
from database.music_database import MusicDatabase
|
|
|
|
logger = get_logger("musicbrainz_service")
|
|
|
|
class MusicBrainzService:
|
|
"""Service layer for MusicBrainz integration with caching and matching logic"""
|
|
|
|
def __init__(self, database: MusicDatabase, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""):
|
|
self.db = database
|
|
self.mb_client = MusicBrainzClient(app_name, app_version, contact_email)
|
|
self.retry_days = 30 # Retry 'not_found' items after 30 days
|
|
|
|
def _calculate_similarity(self, str1: str, str2: str) -> float:
|
|
"""Calculate string similarity score (0.0 to 1.0)"""
|
|
if not str1 or not str2:
|
|
return 0.0
|
|
|
|
# Normalize for comparison
|
|
s1 = str1.lower().strip()
|
|
s2 = str2.lower().strip()
|
|
|
|
if s1 == s2:
|
|
return 1.0
|
|
|
|
return SequenceMatcher(None, s1, s2).ratio()
|
|
|
|
def _check_cache(self, entity_type: str, entity_name: str, artist_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
"""Check if we have a cached MusicBrainz result"""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Fix: Match exact artist_name (not OR artist_name IS NULL)
|
|
# This prevents getting wrong cached results
|
|
if artist_name is not None:
|
|
cursor.execute("""
|
|
SELECT musicbrainz_id, metadata_json, match_confidence, last_updated
|
|
FROM musicbrainz_cache
|
|
WHERE entity_type = ? AND entity_name = ? AND artist_name = ?
|
|
ORDER BY last_updated DESC
|
|
LIMIT 1
|
|
""", (entity_type, entity_name, artist_name))
|
|
else:
|
|
cursor.execute("""
|
|
SELECT musicbrainz_id, metadata_json, match_confidence, last_updated
|
|
FROM musicbrainz_cache
|
|
WHERE entity_type = ? AND entity_name = ? AND artist_name IS NULL
|
|
ORDER BY last_updated DESC
|
|
LIMIT 1
|
|
""", (entity_type, entity_name))
|
|
|
|
row = cursor.fetchone()
|
|
|
|
if row:
|
|
# Shorter TTL for null results (failed lookups) so they get retried sooner
|
|
last_updated = datetime.fromisoformat(row[3]) if row[3] else None
|
|
ttl_days = 30 if row[0] is None else 90 # row[0] is musicbrainz_id
|
|
if last_updated and (datetime.now() - last_updated).days > ttl_days:
|
|
logger.debug(f"Cache entry for {entity_type} '{entity_name}' is stale (> {ttl_days} days)")
|
|
return None
|
|
|
|
# Parse JSON with error handling
|
|
try:
|
|
metadata = json.loads(row[1]) if row[1] else None
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"Invalid JSON in cache for {entity_type} '{entity_name}', ignoring")
|
|
metadata = None
|
|
|
|
return {
|
|
'musicbrainz_id': row[0],
|
|
'metadata': metadata,
|
|
'confidence': row[2]
|
|
}
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking cache: {e}")
|
|
return None
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _save_to_cache(self, entity_type: str, entity_name: str, artist_name: Optional[str],
|
|
musicbrainz_id: Optional[str], metadata: Optional[Dict], confidence: int):
|
|
"""Save MusicBrainz result to cache"""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
metadata_json = json.dumps(metadata) if metadata else None
|
|
|
|
cursor.execute("""
|
|
INSERT OR REPLACE INTO musicbrainz_cache
|
|
(entity_type, entity_name, artist_name, musicbrainz_id, metadata_json, match_confidence, last_updated)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""", (entity_type, entity_name, artist_name, musicbrainz_id, metadata_json, confidence, datetime.now()))
|
|
|
|
conn.commit()
|
|
|
|
logger.debug(f"Cached {entity_type} '{entity_name}' (MBID: {musicbrainz_id}, confidence: {confidence})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error saving to cache: {e}")
|
|
if conn:
|
|
conn.rollback()
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _candidate_release_titles(self, mbid: str) -> list:
|
|
"""Release-group titles for a candidate MBID — the catalog side of
|
|
same-name artist disambiguation."""
|
|
if not mbid:
|
|
return []
|
|
try:
|
|
data = self.mb_client.get_artist(mbid, includes=['release-groups'])
|
|
except Exception:
|
|
return []
|
|
groups = (data or {}).get('release-groups') or []
|
|
return [g.get('title') for g in groups if isinstance(g, dict) and g.get('title')]
|
|
|
|
def match_artist(self, artist_name: str, owned_titles: Optional[list] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Match an artist by name to MusicBrainz.
|
|
|
|
``owned_titles`` — the library artist's owned album titles. When given and
|
|
more than one strong same-name candidate exists, the one whose release
|
|
groups overlap those owned titles is chosen (disambiguates the ~5 "Rone"s);
|
|
omitted → falls back to the highest-confidence candidate as before.
|
|
|
|
Returns:
|
|
Dict with 'mbid', 'name', 'confidence' or None if no good match
|
|
"""
|
|
# Check cache first
|
|
cached = self._check_cache('artist', artist_name)
|
|
if cached:
|
|
cached_mbid = cached.get('musicbrainz_id')
|
|
# Don't trust a cached mbid whose catalog has ZERO overlap with the
|
|
# albums this library owns — that's the wrong same-name artist (and a
|
|
# re-match would otherwise be blocked for up to the 90-day cache TTL,
|
|
# #868). Fall through to a fresh, disambiguated resolve in that case.
|
|
stale_wrong_match = bool(
|
|
cached_mbid and owned_titles
|
|
and catalog_overlap_score(owned_titles, self._candidate_release_titles(cached_mbid)) == 0
|
|
)
|
|
if not stale_wrong_match:
|
|
logger.debug(f"Cache hit for artist '{artist_name}'")
|
|
return {
|
|
'mbid': cached_mbid,
|
|
'name': artist_name,
|
|
'confidence': cached['confidence'],
|
|
'cached': True
|
|
}
|
|
logger.debug(f"Cached MB match for '{artist_name}' has no owned-catalog overlap — re-resolving")
|
|
|
|
# Search MusicBrainz
|
|
try:
|
|
results = self.mb_client.search_artist(artist_name, limit=5)
|
|
|
|
if not results:
|
|
logger.info(f"No MusicBrainz results for artist '{artist_name}'")
|
|
self._save_to_cache('artist', artist_name, None, None, None, 0)
|
|
return None
|
|
|
|
# Score every candidate (name similarity 60% + MB's own relevance 40%).
|
|
scored = []
|
|
for result in results:
|
|
mb_name = result.get('name', '')
|
|
mb_score = result.get('score', 0) # MusicBrainz search score
|
|
similarity = self._calculate_similarity(artist_name, mb_name)
|
|
# Cap at 100 to prevent edge cases where MB score > 100
|
|
confidence = min(100, int((similarity * 60) + (mb_score / 100 * 40)))
|
|
scored.append((confidence, result))
|
|
scored.sort(key=lambda s: s[0], reverse=True)
|
|
|
|
# Among the strong (>=70) candidates, disambiguate same-name artists by
|
|
# which one's release groups overlap the albums this library owns.
|
|
gated = [r for conf, r in scored if conf >= 70]
|
|
best_match = None
|
|
best_confidence = scored[0][0] if scored else 0
|
|
if gated:
|
|
chosen, _overlap = pick_artist_by_catalog(
|
|
gated, owned_titles or [],
|
|
lambda r: self._candidate_release_titles(r.get('id')),
|
|
)
|
|
best_match = chosen
|
|
best_confidence = next(conf for conf, r in scored if r is chosen)
|
|
|
|
# Only return matches with confidence >= 70%
|
|
if best_match and best_confidence >= 70:
|
|
mbid = best_match.get('id')
|
|
mb_name = best_match.get('name')
|
|
|
|
# Save to cache
|
|
self._save_to_cache('artist', artist_name, None, mbid, best_match, best_confidence)
|
|
|
|
logger.info(f"Matched artist '{artist_name}' → '{mb_name}' (MBID: {mbid}, confidence: {best_confidence})")
|
|
|
|
return {
|
|
'mbid': mbid,
|
|
'name': mb_name,
|
|
'confidence': best_confidence,
|
|
'cached': False
|
|
}
|
|
else:
|
|
logger.info(f"Low confidence match for artist '{artist_name}' (best: {best_confidence})")
|
|
self._save_to_cache('artist', artist_name, None, None, None, best_confidence)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error matching artist '{artist_name}': {e}")
|
|
return None
|
|
|
|
# Version qualifiers that distinguish releases (Deluxe, Remastered, etc.)
|
|
_VERSION_QUALIFIERS = re.compile(
|
|
r'\b(deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
|
r'limited|bonus|platinum|gold|super\s*deluxe|standard)\b',
|
|
re.IGNORECASE
|
|
)
|
|
|
|
def _extract_version_qualifier(self, title: str) -> str:
|
|
"""Extract version qualifiers from an album title, normalized and sorted."""
|
|
qualifiers = sorted(set(q.lower() for q in self._VERSION_QUALIFIERS.findall(title)))
|
|
return ' '.join(qualifiers)
|
|
|
|
def match_release(self, album_name: str, artist_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Match a release (album) by name to MusicBrainz
|
|
|
|
Returns:
|
|
Dict with 'mbid', 'title', 'confidence' or None if no good match
|
|
"""
|
|
# Check cache first
|
|
cached = self._check_cache('release', album_name, artist_name)
|
|
if cached:
|
|
logger.debug(f"Cache hit for release '{album_name}'")
|
|
return {
|
|
'mbid': cached['musicbrainz_id'],
|
|
'title': album_name,
|
|
'confidence': cached['confidence'],
|
|
'cached': True
|
|
}
|
|
|
|
# Search MusicBrainz
|
|
try:
|
|
results = self.mb_client.search_release(album_name, artist_name, limit=5)
|
|
|
|
if not results:
|
|
logger.info(f"No MusicBrainz results for release '{album_name}'")
|
|
self._save_to_cache('release', album_name, artist_name, None, None, 0)
|
|
return None
|
|
|
|
# Extract version qualifier from search query for preference matching
|
|
query_qualifier = self._extract_version_qualifier(album_name)
|
|
|
|
# Find best match
|
|
best_match = None
|
|
best_confidence = 0
|
|
|
|
for result in results:
|
|
mb_title = result.get('title', '')
|
|
mb_score = result.get('score', 0)
|
|
|
|
# Calculate title similarity
|
|
title_similarity = self._calculate_similarity(album_name, mb_title)
|
|
|
|
# If we have artist info, check artist match too
|
|
artist_bonus = 0
|
|
if artist_name and 'artist-credit' in result:
|
|
artist_credits = result['artist-credit']
|
|
for credit in artist_credits:
|
|
if isinstance(credit, dict) and 'artist' in credit:
|
|
mb_artist = credit['artist'].get('name', '')
|
|
artist_similarity = self._calculate_similarity(artist_name, mb_artist)
|
|
if artist_similarity > 0.7:
|
|
artist_bonus = 20
|
|
break
|
|
|
|
# Version qualifier matching: prefer releases with the same
|
|
# edition qualifier (Deluxe, Remastered, etc.) as the query.
|
|
# This prevents "Playing the Angel (Deluxe)" from matching the
|
|
# standard "Playing the Angel" release.
|
|
version_bonus = 0
|
|
if query_qualifier:
|
|
mb_qualifier = self._extract_version_qualifier(mb_title)
|
|
if query_qualifier == mb_qualifier:
|
|
version_bonus = 10 # Same edition — strong preference
|
|
elif mb_qualifier and mb_qualifier in query_qualifier:
|
|
version_bonus = 5 # Partial match (e.g. "deluxe" in "super deluxe")
|
|
elif not mb_qualifier:
|
|
version_bonus = -5 # Query has qualifier but result doesn't — penalize
|
|
|
|
# Combine scores - cap at 100
|
|
confidence = min(100, int((title_similarity * 50) + (mb_score / 100 * 30) + artist_bonus + version_bonus))
|
|
|
|
# Numeric difference = different release. 'Vol.4' vs 'Vol.4.5'
|
|
# scores 0.97 string similarity, so a near-identical wrong
|
|
# volume could win and its MBID then feeds CAA art with NO
|
|
# downstream validation (CAA is MBID-keyed — Sokhi's wrong
|
|
# covers). Halving lands any such candidate below the 70 gate
|
|
# while leaving the exact-volume result untouched.
|
|
from core.text.title_match import numeric_tokens_differ
|
|
if numeric_tokens_differ(album_name, mb_title):
|
|
confidence = int(confidence * 0.5)
|
|
|
|
if confidence > best_confidence:
|
|
best_confidence = confidence
|
|
best_match = result
|
|
|
|
# Only return matches with confidence >= 70%
|
|
if best_match and best_confidence >= 70:
|
|
mbid = best_match.get('id')
|
|
mb_title = best_match.get('title')
|
|
|
|
# Save to cache
|
|
self._save_to_cache('release', album_name, artist_name, mbid, best_match, best_confidence)
|
|
|
|
logger.info(f"Matched release '{album_name}' → '{mb_title}' (MBID: {mbid}, confidence: {best_confidence})")
|
|
|
|
return {
|
|
'mbid': mbid,
|
|
'title': mb_title,
|
|
'confidence': best_confidence,
|
|
'cached': False
|
|
}
|
|
else:
|
|
logger.info(f"Low confidence match for release '{album_name}' (best: {best_confidence})")
|
|
self._save_to_cache('release', album_name, artist_name, None, None, best_confidence)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error matching release '{album_name}': {e}")
|
|
return None
|
|
|
|
def match_recording(self, track_name: str, artist_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Match a recording (track) by name to MusicBrainz
|
|
|
|
Returns:
|
|
Dict with 'mbid', 'title', 'confidence' or None if no good match
|
|
"""
|
|
# Check cache first
|
|
cached = self._check_cache('recording', track_name, artist_name)
|
|
if cached:
|
|
logger.debug(f"Cache hit for recording '{track_name}'")
|
|
return {
|
|
'mbid': cached['musicbrainz_id'],
|
|
'title': track_name,
|
|
'confidence': cached['confidence'],
|
|
'cached': True
|
|
}
|
|
|
|
# Search MusicBrainz
|
|
try:
|
|
results = self.mb_client.search_recording(track_name, artist_name, limit=5)
|
|
|
|
if not results:
|
|
logger.info(f"No MusicBrainz results for recording '{track_name}'")
|
|
self._save_to_cache('recording', track_name, artist_name, None, None, 0)
|
|
return None
|
|
|
|
# Find best match
|
|
best_match = None
|
|
best_confidence = 0
|
|
|
|
for result in results:
|
|
mb_title = result.get('title', '')
|
|
mb_score = result.get('score', 0)
|
|
|
|
# Calculate title similarity
|
|
title_similarity = self._calculate_similarity(track_name, mb_title)
|
|
|
|
# Hard gate: title must be at least 60% similar.
|
|
# Without this, artist bonus + MB score can push totally
|
|
# different titles (e.g. "Sweet Surrender" → "Answers")
|
|
# past the confidence threshold.
|
|
if title_similarity < 0.6:
|
|
continue
|
|
|
|
# If we have artist info, check artist match too
|
|
artist_bonus = 0
|
|
if artist_name and 'artist-credit' in result:
|
|
artist_credits = result['artist-credit']
|
|
for credit in artist_credits:
|
|
if isinstance(credit, dict) and 'artist' in credit:
|
|
mb_artist = credit['artist'].get('name', '')
|
|
artist_similarity = self._calculate_similarity(artist_name, mb_artist)
|
|
if artist_similarity > 0.7:
|
|
artist_bonus = 20
|
|
break
|
|
|
|
# Combine scores - cap at 100
|
|
confidence = min(100, int((title_similarity * 50) + (mb_score / 100 * 30) + artist_bonus))
|
|
|
|
if confidence > best_confidence:
|
|
best_confidence = confidence
|
|
best_match = result
|
|
|
|
# Only return matches with confidence >= 70%
|
|
if best_match and best_confidence >= 70:
|
|
mbid = best_match.get('id')
|
|
mb_title = best_match.get('title')
|
|
|
|
# Save to cache
|
|
self._save_to_cache('recording', track_name, artist_name, mbid, best_match, best_confidence)
|
|
|
|
logger.info(f"Matched recording '{track_name}' → '{mb_title}' (MBID: {mbid}, confidence: {best_confidence})")
|
|
|
|
return {
|
|
'mbid': mbid,
|
|
'title': mb_title,
|
|
'confidence': best_confidence,
|
|
'cached': False
|
|
}
|
|
else:
|
|
logger.info(f"Low confidence match for recording '{track_name}' (best: {best_confidence})")
|
|
self._save_to_cache('recording', track_name, artist_name, None, None, best_confidence)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error matching recording '{track_name}': {e}")
|
|
return None
|
|
|
|
def lookup_artist_aliases(self, artist_name: str) -> list:
|
|
"""Find alternate-spelling aliases for an artist by NAME.
|
|
|
|
Multi-tier resolution:
|
|
1. Library DB row (`artists.aliases` populated by the MB
|
|
worker when the artist was enriched). Fast path — no
|
|
network.
|
|
2. Existing musicbrainz_cache entry (entity_type='artist_aliases')
|
|
— caches a prior live MB lookup for this name.
|
|
3. Live MB lookup: search artist → fetch aliases for the best
|
|
MBID → cache the result.
|
|
|
|
Always returns a list (possibly empty) — never raises. Empty
|
|
result on any tier means "no alternate spellings found, fall
|
|
back to direct match" which is identical to pre-fix behaviour.
|
|
|
|
Used by the AcoustID verifier when an artist comparison fails
|
|
the direct similarity check. Caching means each unique artist
|
|
name only hits MB once per cache TTL even if 100 download
|
|
candidates fail verification with that artist.
|
|
"""
|
|
if not artist_name:
|
|
return []
|
|
|
|
# Tier 1: library DB
|
|
library = self.get_artist_aliases(artist_name)
|
|
if library:
|
|
return library
|
|
|
|
# Tier 2: cached live lookup (re-uses musicbrainz_cache table)
|
|
cached = self._check_cache('artist_aliases', artist_name)
|
|
if cached:
|
|
metadata = cached.get('metadata') or {}
|
|
aliases = metadata.get('aliases') if isinstance(metadata, dict) else None
|
|
if isinstance(aliases, list):
|
|
return [str(x).strip() for x in aliases if x]
|
|
# Cache hit with empty result — respect it (don't re-query)
|
|
return []
|
|
|
|
# Tier 3: live MB lookup. Search → fetch by MBID → cache.
|
|
# Issue #586 — strict search queries `artist:"..."` only and
|
|
# MISSES alias / sortname indexes. When MB's canonical name is
|
|
# the non-Latin form (e.g. `Дмитрий Яблонский`), the user's
|
|
# Latin input ("Dmitry Yablonsky") finds nothing under strict.
|
|
# Fall back to non-strict (bare query, hits alias + sortname
|
|
# indexes) when strict returns empty OR all results fail the
|
|
# trust gate.
|
|
scored = self._search_and_score_artists(artist_name, strict=True)
|
|
if not scored or self._best_score(scored) < 0.85:
|
|
non_strict = self._search_and_score_artists(artist_name, strict=False)
|
|
if non_strict and (not scored or self._best_score(non_strict) > self._best_score(scored)):
|
|
scored = non_strict
|
|
|
|
if not scored:
|
|
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
|
|
return []
|
|
|
|
scored.sort(key=lambda x: -x[0])
|
|
best_score, best_mbid, best_mb_score = scored[0]
|
|
|
|
# The genuine cross-script match (romaji↔kanji, latin↔cyrillic)
|
|
# has near-zero LOCAL similarity, so its COMBINED score sinks
|
|
# below an unrelated same-script decoy — even though MB itself is
|
|
# certain. "Sawano Hiroyuki": a decoy entity led on combined
|
|
# (sim 0.82, mb_score 83, combined 0.82 — just under the 0.85 bar)
|
|
# while the real artist '澤野弘之' had mb_score 100 but combined
|
|
# 0.30, sorted last. So evaluate the MB-SCORE leader independently
|
|
# of the combined ranking for the mb-only escape, not scored[0].
|
|
mb_leader = max(scored, key=lambda x: x[2]) # (combined, mbid, raw_mb)
|
|
mb_scores_desc = sorted((x[2] for x in scored), reverse=True)
|
|
mb_unambiguous = len(mb_scores_desc) < 2 or (mb_scores_desc[0] - mb_scores_desc[1]) >= 5
|
|
|
|
# Trust gate. Two ways to pass:
|
|
# 1. Combined score >= 0.85 (the historical strict bar that
|
|
# catches same-script matches) → trust the combined leader.
|
|
# 2. MB's OWN score is very high (>= 95) AND that MB-score leader
|
|
# is unambiguous → trust IT. Bridges the cross-script case
|
|
# where local similarity is near zero ("Dmitry Yablonsky" vs
|
|
# "Дмитрий Яблонский" sim ~0) but MB's index is confident.
|
|
passes_combined = best_score >= 0.85
|
|
passes_mb_only = mb_leader[2] >= 95 and mb_unambiguous
|
|
if not (passes_combined or passes_mb_only):
|
|
logger.debug(
|
|
"lookup_artist_aliases: best match for %r below trust "
|
|
"threshold (combined=%.2f, best_mb=%d, leader_mb=%d)",
|
|
artist_name, best_score, best_mb_score, mb_leader[2],
|
|
)
|
|
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
|
|
return []
|
|
|
|
# Pick the entity to pull aliases from. Combined-strong matches use
|
|
# the combined leader; the mb-only escape uses the MB-score leader
|
|
# (which may differ from scored[0] in the cross-script case above).
|
|
if passes_combined:
|
|
chosen_mbid, chosen_conf = best_mbid, best_score
|
|
else:
|
|
chosen_mbid, chosen_conf = mb_leader[1], mb_leader[2] / 100.0
|
|
|
|
# Ambiguity detection: when 2+ results both score high (within
|
|
# 0.1 of the best combined), the search hit multiple distinct
|
|
# artists with similar names. Pulling aliases for one could
|
|
# produce wrong matches. Skip + cache empty. The unambiguous
|
|
# MB-score leader (passes_mb_only) is exempt — its decisiveness
|
|
# was already checked via mb_unambiguous.
|
|
if len(scored) >= 2 and (scored[0][0] - scored[1][0]) < 0.1 and not passes_mb_only:
|
|
logger.debug(
|
|
"lookup_artist_aliases: ambiguous match for %r — top "
|
|
"two results within 0.1 (%.2f / %.2f). Skipping alias lookup.",
|
|
artist_name, scored[0][0], scored[1][0],
|
|
)
|
|
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
|
|
return []
|
|
|
|
aliases = self.fetch_artist_aliases(chosen_mbid)
|
|
self._save_to_cache(
|
|
'artist_aliases', artist_name, None, chosen_mbid,
|
|
{'aliases': aliases}, int(chosen_conf * 100),
|
|
)
|
|
return aliases
|
|
|
|
def _search_and_score_artists(self, artist_name: str, strict: bool):
|
|
"""Search MB for an artist and score each result.
|
|
|
|
Returns a list of (combined_score, mbid, raw_mb_score) tuples.
|
|
Combined score: 70% local similarity + 30% MB's own relevance
|
|
score (0..1). raw_mb_score preserved separately so the trust
|
|
gate can prefer high-MB-score results in cross-script cases
|
|
where local similarity is near zero.
|
|
|
|
Returns empty list on any failure.
|
|
"""
|
|
try:
|
|
results = self.mb_client.search_artist(artist_name, limit=3, strict=strict)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"lookup_artist_aliases: search_artist(%r, strict=%s) raised: %s",
|
|
artist_name, strict, e,
|
|
)
|
|
return []
|
|
scored = []
|
|
for result in results or []:
|
|
mb_name = result.get('name', '')
|
|
mb_score = result.get('score', 0)
|
|
sim = self._calculate_similarity(artist_name, mb_name)
|
|
combined = (sim * 0.7) + (mb_score / 100 * 0.3)
|
|
mbid = result.get('id')
|
|
if mbid:
|
|
scored.append((combined, mbid, mb_score))
|
|
return scored
|
|
|
|
@staticmethod
|
|
def _best_score(scored):
|
|
return max((s[0] for s in scored), default=0.0) if scored else 0.0
|
|
|
|
def fetch_artist_aliases(self, mbid: str) -> list:
|
|
"""Fetch the alias list for an artist from MusicBrainz.
|
|
|
|
Issue #442 — Japanese kanji / Cyrillic / etc. spellings of an
|
|
artist's name are stored as `aliases` on the MusicBrainz
|
|
artist record. Pull them so SoulSync can recognise that
|
|
`澤野弘之` and `Hiroyuki Sawano` refer to the same artist.
|
|
|
|
Issue #586 — for some artists MB's CANONICAL `name` is the
|
|
non-Latin spelling (e.g. `Дмитрий Яблонский`) while the
|
|
Latin spelling lives in `aliases` — but the inverse also
|
|
happens, where the Latin canonical name has the Cyrillic in
|
|
aliases. Either way the canonical `name` and `sort-name` are
|
|
themselves valid alternate spellings for matching purposes,
|
|
so include them alongside the explicit alias entries.
|
|
|
|
Returns the deduplicated list of alias `name` strings. Returns
|
|
empty list (NOT None) on any failure — caller should treat
|
|
empty as "no aliases available, fall back to direct match" so
|
|
a transient MB outage never causes a stricter verification
|
|
decision than today.
|
|
"""
|
|
if not mbid:
|
|
return []
|
|
try:
|
|
data = self.mb_client.get_artist(mbid, includes=['aliases'])
|
|
except Exception as e:
|
|
logger.debug("fetch_artist_aliases: get_artist(%s) raised: %s", mbid, e)
|
|
return []
|
|
if not data:
|
|
return []
|
|
|
|
seen = set()
|
|
cleaned = []
|
|
|
|
def _add(value):
|
|
if not isinstance(value, str):
|
|
return
|
|
text = value.strip()
|
|
if not text:
|
|
return
|
|
key = text.lower()
|
|
if key in seen:
|
|
return
|
|
seen.add(key)
|
|
cleaned.append(text)
|
|
|
|
# Canonical name + sort-name treated as aliases for matching —
|
|
# they're the strongest cross-script bridge when MB's
|
|
# canonical spelling differs from the user's input.
|
|
_add(data.get('name'))
|
|
_add(data.get('sort-name'))
|
|
|
|
# MB returns each alias as a dict with `name`, `sort-name`,
|
|
# `locale`, `primary`, `type`, etc. We only care about the
|
|
# display name — that's what `actual` artist strings will
|
|
# match against. Also pull alias sort-name when present
|
|
# (some entries have a different sortable form).
|
|
for entry in data.get('aliases') or []:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
_add(entry.get('name'))
|
|
_add(entry.get('sort-name'))
|
|
return cleaned
|
|
|
|
def update_artist_aliases(self, artist_id: int, aliases: list) -> None:
|
|
"""Persist the alias list to `artists.aliases` as a JSON array.
|
|
|
|
Idempotent — overwrites any existing value. Empty list
|
|
clears the column (caller may want this if MB has no aliases
|
|
for the artist anymore).
|
|
"""
|
|
if artist_id is None:
|
|
return
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"UPDATE artists SET aliases = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
(json.dumps(aliases) if aliases else None, artist_id),
|
|
)
|
|
conn.commit()
|
|
logger.debug("Updated artist %s aliases (%d entries)", artist_id, len(aliases or []))
|
|
except Exception as e:
|
|
logger.error(f"Error updating artist aliases for {artist_id}: {e}")
|
|
if conn:
|
|
conn.rollback()
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def get_artist_aliases(self, artist_name: str) -> list:
|
|
"""Look up cached aliases for an artist by NAME (not id).
|
|
|
|
Used by the verifier where the expected artist comes from a
|
|
download's metadata-source data — we don't have a library
|
|
row's `id` to query, just the display name. Returns empty
|
|
list when the artist isn't in the library or has no aliases
|
|
recorded. The verifier falls back to live MB lookup in that
|
|
case.
|
|
"""
|
|
if not artist_name:
|
|
return []
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT aliases FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1",
|
|
(artist_name,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if not row or not row[0]:
|
|
return []
|
|
try:
|
|
parsed = json.loads(row[0])
|
|
except (TypeError, json.JSONDecodeError):
|
|
return []
|
|
if not isinstance(parsed, list):
|
|
return []
|
|
return [str(x).strip() for x in parsed if x]
|
|
except Exception as e:
|
|
logger.debug("get_artist_aliases lookup failed for %r: %s", artist_name, e)
|
|
return []
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def update_artist_mbid(self, artist_id: int, mbid: Optional[str], status: str):
|
|
"""Update artist with MusicBrainz ID"""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
UPDATE artists
|
|
SET musicbrainz_id = ?,
|
|
musicbrainz_last_attempted = ?,
|
|
musicbrainz_match_status = ?
|
|
WHERE id = ?
|
|
""", (mbid, datetime.now(), status, artist_id))
|
|
|
|
conn.commit()
|
|
|
|
logger.debug(f"Updated artist {artist_id} with MBID: {mbid}, status: {status}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating artist {artist_id}: {e}")
|
|
if conn:
|
|
conn.rollback()
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def update_album_mbid(self, album_id: int, mbid: Optional[str], status: str):
|
|
"""Update album with MusicBrainz release ID"""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
UPDATE albums
|
|
SET musicbrainz_release_id = ?,
|
|
musicbrainz_last_attempted = ?,
|
|
musicbrainz_match_status = ?
|
|
WHERE id = ?
|
|
""", (mbid, datetime.now(), status, album_id))
|
|
|
|
conn.commit()
|
|
|
|
logger.debug(f"Updated album {album_id} with MBID: {mbid}, status: {status}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating album {album_id}: {e}")
|
|
if conn:
|
|
conn.rollback()
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def update_track_mbid(self, track_id: int, mbid: Optional[str], status: str):
|
|
"""Update track with MusicBrainz recording ID"""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
UPDATE tracks
|
|
SET musicbrainz_recording_id = ?,
|
|
musicbrainz_last_attempted = ?,
|
|
musicbrainz_match_status = ?
|
|
WHERE id = ?
|
|
""", (mbid, datetime.now(), status, track_id))
|
|
|
|
conn.commit()
|
|
|
|
logger.debug(f"Updated track {track_id} with MBID: {mbid}, status: {status}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating track {track_id}: {e}")
|
|
if conn:
|
|
conn.rollback()
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|