Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support. Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings. Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""Liked-artist multi-source matching — lifted from web_server.py.
|
|
|
|
Both function bodies are byte-identical to the originals. The
|
|
``spotify_client`` proxy + ``_get_*_client`` shims let the bodies resolve
|
|
their original names without any modification.
|
|
"""
|
|
import logging
|
|
import time
|
|
|
|
from config.settings import config_manager
|
|
from core.metadata.registry import (
|
|
get_deezer_client,
|
|
get_discogs_client,
|
|
get_itunes_client,
|
|
get_musicbrainz_client,
|
|
get_spotify_client,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_itunes_client():
|
|
"""Mirror of web_server._get_itunes_client — delegates to registry."""
|
|
return get_itunes_client()
|
|
|
|
|
|
def _get_deezer_client():
|
|
"""Mirror of web_server._get_deezer_client — delegates to registry."""
|
|
return get_deezer_client()
|
|
|
|
|
|
def _get_discogs_client(token=None):
|
|
"""Mirror of web_server._get_discogs_client — delegates to registry."""
|
|
return get_discogs_client(token)
|
|
|
|
|
|
def _get_musicbrainz_client():
|
|
"""Mirror of web_server._get_musicbrainz_client — delegates to registry."""
|
|
return get_musicbrainz_client()
|
|
|
|
|
|
class _SpotifyClientProxy:
|
|
"""Resolves the global Spotify client lazily so a Spotify re-auth that
|
|
rebinds the cached client in core.metadata.registry is visible to the
|
|
lifted bodies."""
|
|
|
|
def __getattr__(self, name):
|
|
client = get_spotify_client()
|
|
if client is None:
|
|
raise AttributeError(name)
|
|
return getattr(client, name)
|
|
|
|
def __bool__(self):
|
|
return get_spotify_client() is not None
|
|
|
|
|
|
spotify_client = _SpotifyClientProxy()
|
|
|
|
|
|
def _match_liked_artists_to_all_sources(database, profile_id: int):
|
|
"""Match pending liked artists to ALL metadata sources (Spotify, iTunes, Deezer, Discogs).
|
|
Uses the same matching pattern as the watchlist scanner: DB-first, then API search
|
|
with fuzzy name matching. Stores all resolved IDs so source switching works instantly."""
|
|
pending = database.get_liked_artists_pending_match(profile_id, limit=200)
|
|
if not pending:
|
|
return
|
|
|
|
# Source → column mapping
|
|
source_cols = {
|
|
'spotify': 'spotify_artist_id',
|
|
'itunes': 'itunes_artist_id',
|
|
'deezer': 'deezer_artist_id',
|
|
'discogs': 'discogs_artist_id',
|
|
'musicbrainz': 'musicbrainz_artist_id',
|
|
}
|
|
id_cols = list(source_cols.values())
|
|
|
|
# Reject known placeholder images and local server paths
|
|
_placeholder_hashes = {'2a96cbd8b46e442fc41c2b86b821562f'}
|
|
def _valid_image(url):
|
|
if not url or not url.strip():
|
|
return None
|
|
if any(ph in url for ph in _placeholder_hashes):
|
|
return None
|
|
# Reject local media server paths (Plex/Jellyfin) — not loadable in browser
|
|
if url.startswith('/') or url.startswith('\\'):
|
|
return None
|
|
if not url.startswith('http'):
|
|
return None
|
|
return url
|
|
|
|
# Build search clients for each source
|
|
from core.deezer_client import DeezerClient
|
|
search_clients = {}
|
|
if spotify_client and spotify_client.is_spotify_authenticated():
|
|
search_clients['spotify'] = spotify_client
|
|
try:
|
|
search_clients['itunes'] = _get_itunes_client()
|
|
except Exception as e:
|
|
logger.debug("itunes client init failed: %s", e)
|
|
try:
|
|
search_clients['deezer'] = _get_deezer_client()
|
|
except Exception as e:
|
|
logger.debug("deezer client init failed: %s", e)
|
|
try:
|
|
dc = _get_discogs_client()
|
|
# Only use Discogs if token is configured
|
|
from config.settings import config_manager as _cm
|
|
if _cm.get('discogs.token', ''):
|
|
search_clients['discogs'] = dc
|
|
except Exception as e:
|
|
logger.debug("discogs client init failed: %s", e)
|
|
try:
|
|
search_clients['musicbrainz'] = _get_musicbrainz_client()
|
|
except Exception as e:
|
|
logger.debug("musicbrainz client init failed: %s", e)
|
|
|
|
# Reuse watchlist scanner's fuzzy matching logic
|
|
from core.watchlist_scanner import WatchlistScanner
|
|
_normalize = WatchlistScanner._normalize_artist_name
|
|
|
|
def _best_match(results, artist_name):
|
|
"""Pick best match from search results using name similarity (same as watchlist scanner)."""
|
|
if not results:
|
|
return None
|
|
# Exact normalized match
|
|
for r in results:
|
|
if _normalize(r.name) == _normalize(artist_name):
|
|
return r
|
|
# Fuzzy scoring
|
|
best = None
|
|
best_sim = 0
|
|
for r in results:
|
|
# Simple normalized comparison
|
|
n1 = _normalize(artist_name)
|
|
n2 = _normalize(r.name)
|
|
if n1 == n2:
|
|
return r
|
|
# Levenshtein-style similarity
|
|
max_len = max(len(n1), len(n2))
|
|
if max_len == 0:
|
|
continue
|
|
distance = sum(1 for a, b in zip(n1, n2, strict=False) if a != b) + abs(len(n1) - len(n2))
|
|
sim = (max_len - distance) / max_len
|
|
if sim > best_sim:
|
|
best_sim = sim
|
|
best = r
|
|
if best and best_sim >= 0.85:
|
|
return best
|
|
return None
|
|
|
|
api_calls = 0
|
|
matched = 0
|
|
|
|
for entry in pending:
|
|
name = entry['artist_name']
|
|
pool_id = entry['id']
|
|
harvested_ids = {}
|
|
best_image = None
|
|
|
|
# Pre-load existing IDs from the entry itself
|
|
for col in id_cols:
|
|
if entry.get(col):
|
|
harvested_ids[col] = entry[col]
|
|
|
|
# --- DB STRATEGIES (free, no API calls) ---
|
|
|
|
# 1. Library artists table
|
|
try:
|
|
conn = database._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT * FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1", (name,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
r = dict(row)
|
|
for col in id_cols:
|
|
if r.get(col) and col not in harvested_ids:
|
|
harvested_ids[col] = str(r[col])
|
|
if _valid_image(r.get('thumb_url')):
|
|
best_image = r['thumb_url']
|
|
except Exception as e:
|
|
logger.debug("library artist lookup failed: %s", e)
|
|
|
|
# 2. Watchlist artists
|
|
try:
|
|
conn = database._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT * FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE AND profile_id = ? LIMIT 1",
|
|
(name, profile_id)
|
|
)
|
|
row = cursor.fetchone()
|
|
if row:
|
|
wl = dict(row)
|
|
for col in id_cols:
|
|
if wl.get(col) and col not in harvested_ids:
|
|
harvested_ids[col] = str(wl[col])
|
|
if _valid_image(wl.get('image_url')) and not best_image:
|
|
best_image = wl['image_url']
|
|
except Exception as e:
|
|
logger.debug("watchlist artist lookup failed: %s", e)
|
|
|
|
# 3. Metadata cache (all sources)
|
|
try:
|
|
conn = database._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT entity_id, source, image_url FROM metadata_cache_entities WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE",
|
|
(name,)
|
|
)
|
|
for row in cursor.fetchall():
|
|
col = source_cols.get(row['source'])
|
|
if col and col not in harvested_ids:
|
|
harvested_ids[col] = row['entity_id']
|
|
if _valid_image(row['image_url']) and not best_image:
|
|
best_image = row['image_url']
|
|
except Exception as e:
|
|
logger.debug("metadata cache lookup failed: %s", e)
|
|
|
|
# --- API STRATEGIES (search each missing source) ---
|
|
# Same pattern as watchlist scanner's _backfill_missing_ids
|
|
for source, col in source_cols.items():
|
|
if col in harvested_ids:
|
|
continue # Already have this source's ID
|
|
client = search_clients.get(source)
|
|
if not client:
|
|
continue
|
|
if api_calls >= 200: # Hard cap per refresh cycle
|
|
break
|
|
try:
|
|
results = client.search_artists(name, limit=5)
|
|
best = _best_match(results, name)
|
|
if best:
|
|
harvested_ids[col] = best.id
|
|
if hasattr(best, 'image_url') and _valid_image(best.image_url) and not best_image:
|
|
best_image = best.image_url
|
|
api_calls += 1
|
|
time.sleep(0.4) # Rate limit breathing room
|
|
except Exception as e:
|
|
logger.debug(f"[Your Artists] {source} search failed for '{name}': {e}")
|
|
api_calls += 1
|
|
|
|
# Save all harvested IDs
|
|
if harvested_ids:
|
|
# Determine best active source/ID — prefer Spotify, then iTunes, Deezer, Discogs
|
|
resolved_source = None
|
|
resolved_id = None
|
|
for src in ('spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz'):
|
|
col = source_cols[src]
|
|
if col in harvested_ids:
|
|
resolved_source = src
|
|
resolved_id = harvested_ids[col]
|
|
break
|
|
|
|
database.update_liked_artist_match(
|
|
pool_id, active_source=resolved_source, active_source_id=resolved_id,
|
|
image_url=best_image, all_ids=harvested_ids
|
|
)
|
|
matched += 1
|
|
|
|
database.sync_liked_artists_watchlist_flags(profile_id)
|
|
logger.info(f"[Your Artists] Matched {matched}/{len(pending)} artists to {len(search_clients)} sources ({api_calls} API calls)")
|
|
|
|
# Image backfill: fetch images for matched artists that have IDs but no image
|
|
_backfill_liked_artist_images(database, profile_id, search_clients)
|
|
|
|
|
|
def _backfill_liked_artist_images(database, profile_id: int, search_clients: dict):
|
|
"""Fetch images for matched artists missing artwork using their stored source IDs."""
|
|
try:
|
|
conn = database._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id
|
|
FROM liked_artists_pool
|
|
WHERE profile_id = ? AND match_status = 'matched'
|
|
AND (image_url IS NULL OR image_url = ''
|
|
OR image_url LIKE '%2a96cbd8b46e442fc41c2b86b821562f%'
|
|
OR image_url NOT LIKE 'http%')
|
|
LIMIT 100
|
|
""", (profile_id,))
|
|
rows = cursor.fetchall()
|
|
if not rows:
|
|
return
|
|
|
|
logger.info(f"[Your Artists] Backfilling images for {len(rows)} artists...")
|
|
filled = 0
|
|
|
|
for row in rows:
|
|
r = dict(row)
|
|
image_url = None
|
|
|
|
# Try Spotify artist lookup (has best images)
|
|
if r.get('spotify_artist_id') and 'spotify' in search_clients:
|
|
try:
|
|
sp = search_clients['spotify']
|
|
if hasattr(sp, 'sp') and sp.sp:
|
|
artist_data = sp.sp.artist(r['spotify_artist_id'])
|
|
if artist_data and artist_data.get('images'):
|
|
image_url = artist_data['images'][0]['url']
|
|
except Exception as e:
|
|
logger.debug("spotify artist image fetch failed: %s", e)
|
|
|
|
# Try Deezer (direct image URL from ID)
|
|
if not image_url and r.get('deezer_artist_id'):
|
|
image_url = f"https://api.deezer.com/artist/{r['deezer_artist_id']}/image?size=big"
|
|
|
|
if image_url:
|
|
try:
|
|
cursor2 = conn.cursor()
|
|
cursor2.execute(
|
|
"UPDATE liked_artists_pool SET image_url = ? WHERE id = ?",
|
|
(image_url, r['id'])
|
|
)
|
|
filled += 1
|
|
except Exception as e:
|
|
logger.debug("liked artist image update failed: %s", e)
|
|
time.sleep(0.3)
|
|
|
|
conn.commit()
|
|
if filled:
|
|
logger.info(f"[Your Artists] Backfilled {filled}/{len(rows)} artist images")
|
|
except Exception as e:
|
|
logger.debug(f"[Your Artists] Image backfill error: {e}")
|