Merge pull request #640 from Nezreka/codex/musicbrainz-metadata-source

Codex/musicbrainz metadata source
This commit is contained in:
BoulderBadgeDad 2026-05-18 19:36:21 -07:00 committed by GitHub
commit 801cd10134
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 853 additions and 140 deletions

View file

@ -368,6 +368,7 @@ def serialize_similar_artist(obj, fields: Optional[Set[str]] = None) -> dict:
"source_artist_id": d.get("source_artist_id"), "source_artist_id": d.get("source_artist_id"),
"similar_artist_spotify_id": d.get("similar_artist_spotify_id"), "similar_artist_spotify_id": d.get("similar_artist_spotify_id"),
"similar_artist_itunes_id": d.get("similar_artist_itunes_id"), "similar_artist_itunes_id": d.get("similar_artist_itunes_id"),
"similar_artist_musicbrainz_id": d.get("similar_artist_musicbrainz_id"),
"similar_artist_name": d.get("similar_artist_name"), "similar_artist_name": d.get("similar_artist_name"),
"similarity_rank": d.get("similarity_rank"), "similarity_rank": d.get("similarity_rank"),
"occurrence_count": d.get("occurrence_count"), "occurrence_count": d.get("occurrence_count"),

View file

@ -12,6 +12,7 @@ from core.metadata.registry import (
get_deezer_client, get_deezer_client,
get_discogs_client, get_discogs_client,
get_itunes_client, get_itunes_client,
get_musicbrainz_client,
get_spotify_client, get_spotify_client,
) )
@ -33,6 +34,11 @@ def _get_discogs_client(token=None):
return get_discogs_client(token) 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: class _SpotifyClientProxy:
"""Resolves the global Spotify client lazily so a Spotify re-auth that """Resolves the global Spotify client lazily so a Spotify re-auth that
rebinds the cached client in core.metadata.registry is visible to the rebinds the cached client in core.metadata.registry is visible to the
@ -65,6 +71,7 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
'itunes': 'itunes_artist_id', 'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id', 'deezer': 'deezer_artist_id',
'discogs': 'discogs_artist_id', 'discogs': 'discogs_artist_id',
'musicbrainz': 'musicbrainz_artist_id',
} }
id_cols = list(source_cols.values()) id_cols = list(source_cols.values())
@ -103,6 +110,10 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
search_clients['discogs'] = dc search_clients['discogs'] = dc
except Exception as e: except Exception as e:
logger.debug("discogs client init failed: %s", 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 # Reuse watchlist scanner's fuzzy matching logic
from core.watchlist_scanner import WatchlistScanner from core.watchlist_scanner import WatchlistScanner
@ -234,7 +245,7 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
# Determine best active source/ID — prefer Spotify, then iTunes, Deezer, Discogs # Determine best active source/ID — prefer Spotify, then iTunes, Deezer, Discogs
resolved_source = None resolved_source = None
resolved_id = None resolved_id = None
for src in ('spotify', 'itunes', 'deezer', 'discogs'): for src in ('spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz'):
col = source_cols[src] col = source_cols[src]
if col in harvested_ids: if col in harvested_ids:
resolved_source = src resolved_source = src

View file

@ -140,7 +140,7 @@ def get_artist_map_data():
placeholders = ','.join(['?'] * len(watchlist_ids)) placeholders = ','.join(['?'] * len(watchlist_ids))
cursor.execute(f""" cursor.execute(f"""
SELECT source_artist_id, similar_artist_name, similar_artist_spotify_id, SELECT source_artist_id, similar_artist_name, similar_artist_spotify_id,
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
similarity_rank, occurrence_count, image_url, genres, popularity similarity_rank, occurrence_count, image_url, genres, popularity
FROM similar_artists FROM similar_artists
WHERE profile_id = ? AND source_artist_id IN ({placeholders}) WHERE profile_id = ? AND source_artist_id IN ({placeholders})
@ -173,6 +173,7 @@ def get_artist_map_data():
'spotify_id': r.get('similar_artist_spotify_id') or '', 'spotify_id': r.get('similar_artist_spotify_id') or '',
'itunes_id': r.get('similar_artist_itunes_id') or '', 'itunes_id': r.get('similar_artist_itunes_id') or '',
'deezer_id': r.get('similar_artist_deezer_id') or '', 'deezer_id': r.get('similar_artist_deezer_id') or '',
'musicbrainz_id': r.get('similar_artist_musicbrainz_id') or '',
'rank': r.get('similarity_rank', 5), 'rank': r.get('similarity_rank', 5),
'occurrence': r.get('occurrence_count', 1), 'occurrence': r.get('occurrence_count', 1),
'popularity': r.get('popularity', 0), 'popularity': r.get('popularity', 0),
@ -245,7 +246,7 @@ def get_artist_map_data():
} }
# Apply cache data to nodes # Apply cache data to nodes
source_id_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'} source_id_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
for n in nodes: for n in nodes:
nn = _norm(n['name']) nn = _norm(n['name'])
cached = cache_by_name.get(nn) cached = cache_by_name.get(nn)
@ -262,7 +263,7 @@ def get_artist_map_data():
break break
# Backfill genres if missing # Backfill genres if missing
if not n.get('genres') or len(n.get('genres', [])) == 0: if not n.get('genres') or len(n.get('genres', [])) == 0:
for source in ('spotify', 'deezer', 'itunes', 'discogs'): for source in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'):
if source in cached and cached[source].get('genres'): if source in cached and cached[source].get('genres'):
n['genres'] = cached[source]['genres'][:5] n['genres'] = cached[source]['genres'][:5]
break break
@ -369,14 +370,14 @@ def get_artist_map_genres():
def _norm(n): def _norm(n):
return (n or '').lower().strip() return (n or '').lower().strip()
def _add(name, image_url=None, genres=None, spotify_id=None, itunes_id=None, deezer_id=None, discogs_id=None, source='unknown', popularity=0): def _add(name, image_url=None, genres=None, spotify_id=None, itunes_id=None, deezer_id=None, discogs_id=None, musicbrainz_id=None, source='unknown', popularity=0):
n = _norm(name) n = _norm(name)
if not n or len(n) < 2: if not n or len(n) < 2:
return return
if n not in artists_by_name: if n not in artists_by_name:
artists_by_name[n] = { artists_by_name[n] = {
'name': name, 'image_url': '', 'genres': set(), 'name': name, 'image_url': '', 'genres': set(),
'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'musicbrainz_id': '',
'sources': set(), 'popularity': 0 'sources': set(), 'popularity': 0
} }
a = artists_by_name[n] a = artists_by_name[n]
@ -394,6 +395,8 @@ def get_artist_map_genres():
a['deezer_id'] = str(deezer_id) a['deezer_id'] = str(deezer_id)
if discogs_id and not a['discogs_id']: if discogs_id and not a['discogs_id']:
a['discogs_id'] = str(discogs_id) a['discogs_id'] = str(discogs_id)
if musicbrainz_id and not a['musicbrainz_id']:
a['musicbrainz_id'] = str(musicbrainz_id)
if popularity > a['popularity']: if popularity > a['popularity']:
a['popularity'] = popularity a['popularity'] = popularity
a['sources'].add(source) a['sources'].add(source)
@ -410,14 +413,14 @@ def get_artist_map_genres():
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else [] genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
except Exception as e: except Exception as e:
logger.debug("cache artist genres parse failed: %s", e) logger.debug("cache artist genres parse failed: %s", e)
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'} src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
kwargs = {src_map.get(r['source'], 'spotify_id'): r['entity_id']} kwargs = {src_map.get(r['source'], 'spotify_id'): r['entity_id']}
_add(r['name'], image_url=r['image_url'], genres=genres, source='cache', popularity=r['popularity'] or 0, **kwargs) _add(r['name'], image_url=r['image_url'], genres=genres, source='cache', popularity=r['popularity'] or 0, **kwargs)
# 2. Similar artists # 2. Similar artists
cursor.execute(""" cursor.execute("""
SELECT similar_artist_name, similar_artist_spotify_id, similar_artist_itunes_id, SELECT similar_artist_name, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_deezer_id, image_url, genres, popularity similar_artist_deezer_id, similar_artist_musicbrainz_id, image_url, genres, popularity
FROM similar_artists WHERE profile_id = ? FROM similar_artists WHERE profile_id = ?
""", (profile_id,)) """, (profile_id,))
for r in cursor.fetchall(): for r in cursor.fetchall():
@ -429,7 +432,9 @@ def get_artist_map_genres():
logger.debug("similar artist genres parse failed: %s", e) logger.debug("similar artist genres parse failed: %s", e)
_add(r['similar_artist_name'], image_url=r['image_url'], genres=genres, _add(r['similar_artist_name'], image_url=r['image_url'], genres=genres,
spotify_id=r['similar_artist_spotify_id'], itunes_id=r['similar_artist_itunes_id'], spotify_id=r['similar_artist_spotify_id'], itunes_id=r['similar_artist_itunes_id'],
deezer_id=r['similar_artist_deezer_id'], source='similar', popularity=r['popularity'] or 0) deezer_id=r['similar_artist_deezer_id'],
musicbrainz_id=r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else None,
source='similar', popularity=r['popularity'] or 0)
# 3. Watchlist artists # 3. Watchlist artists
cursor.execute(""" cursor.execute("""
@ -483,6 +488,7 @@ def get_artist_map_genres():
'itunes_id': a['itunes_id'], 'itunes_id': a['itunes_id'],
'deezer_id': a['deezer_id'], 'deezer_id': a['deezer_id'],
'discogs_id': a['discogs_id'], 'discogs_id': a['discogs_id'],
'musicbrainz_id': a['musicbrainz_id'],
'popularity': a['popularity'], 'popularity': a['popularity'],
'type': 'watchlist' if 'watchlist' in a['sources'] else 'similar', 'type': 'watchlist' if 'watchlist' in a['sources'] else 'similar',
}) })
@ -626,7 +632,7 @@ def get_artist_map_explore():
# Find the center artist # Find the center artist
center_name = artist_name center_name = artist_name
center_image = '' center_image = ''
center_ids = {'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': ''} center_ids = {'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'musicbrainz_id': ''}
center_genres = [] center_genres = []
# Search metadata cache for the center artist # Search metadata cache for the center artist
@ -648,7 +654,7 @@ def get_artist_map_explore():
center_name = row['name'] center_name = row['name']
if row['image_url'] and row['image_url'].startswith('http'): if row['image_url'] and row['image_url'].startswith('http'):
center_image = row['image_url'] center_image = row['image_url']
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'} src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
k = src_map.get(row['source'], 'spotify_id') k = src_map.get(row['source'], 'spotify_id')
center_ids[k] = row['entity_id'] center_ids[k] = row['entity_id']
if row['genres']: if row['genres']:
@ -659,14 +665,14 @@ def get_artist_map_explore():
# Check watchlist + library if not in cache # Check watchlist + library if not in cache
if not artist_found and not artist_id: if not artist_found and not artist_id:
cursor.execute("SELECT artist_name, image_url, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE LIMIT 1", (artist_name,)) cursor.execute("SELECT artist_name, image_url, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, musicbrainz_artist_id FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE LIMIT 1", (artist_name,))
wr = cursor.fetchone() wr = cursor.fetchone()
if wr: if wr:
artist_found = True artist_found = True
center_name = wr['artist_name'] center_name = wr['artist_name']
if wr['image_url'] and str(wr['image_url']).startswith('http'): if wr['image_url'] and str(wr['image_url']).startswith('http'):
center_image = wr['image_url'] center_image = wr['image_url']
for k, col in [('spotify_id', 'spotify_artist_id'), ('itunes_id', 'itunes_artist_id'), ('deezer_id', 'deezer_artist_id'), ('discogs_id', 'discogs_artist_id')]: for k, col in [('spotify_id', 'spotify_artist_id'), ('itunes_id', 'itunes_artist_id'), ('deezer_id', 'deezer_artist_id'), ('discogs_id', 'discogs_artist_id'), ('musicbrainz_id', 'musicbrainz_artist_id')]:
if wr[col]: if wr[col]:
center_ids[k] = str(wr[col]) center_ids[k] = str(wr[col])
else: else:
@ -717,7 +723,7 @@ def get_artist_map_explore():
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE
""", (center_name,)) """, (center_name,))
for r in cursor.fetchall(): for r in cursor.fetchall():
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'} src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
k = src_map.get(r['source'], 'spotify_id') k = src_map.get(r['source'], 'spotify_id')
if not center_ids.get(k): if not center_ids.get(k):
center_ids[k] = r['entity_id'] center_ids[k] = r['entity_id']
@ -746,7 +752,7 @@ def get_artist_map_explore():
placeholders = ','.join(['?'] * len(id_values)) placeholders = ','.join(['?'] * len(id_values))
cursor.execute(f""" cursor.execute(f"""
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id, SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
image_url, genres, popularity, similarity_rank image_url, genres, popularity, similarity_rank
FROM similar_artists FROM similar_artists
WHERE source_artist_id IN ({placeholders}) AND profile_id = ? WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
@ -757,7 +763,7 @@ def get_artist_map_explore():
# Also search by name (the center artist might be a watchlist source) # Also search by name (the center artist might be a watchlist source)
cursor.execute(""" cursor.execute("""
SELECT DISTINCT sa.similar_artist_name, sa.similar_artist_spotify_id, SELECT DISTINCT sa.similar_artist_name, sa.similar_artist_spotify_id,
sa.similar_artist_itunes_id, sa.similar_artist_deezer_id, sa.similar_artist_itunes_id, sa.similar_artist_deezer_id, sa.similar_artist_musicbrainz_id,
sa.image_url, sa.genres, sa.popularity, sa.similarity_rank sa.image_url, sa.genres, sa.popularity, sa.similarity_rank
FROM similar_artists sa FROM similar_artists sa
JOIN watchlist_artists wa ON sa.source_artist_id = COALESCE(wa.spotify_artist_id, wa.itunes_artist_id, CAST(wa.id AS TEXT)) JOIN watchlist_artists wa ON sa.source_artist_id = COALESCE(wa.spotify_artist_id, wa.itunes_artist_id, CAST(wa.id AS TEXT))
@ -789,7 +795,8 @@ def get_artist_map_explore():
image_url=sa.get('image_url'), image_url=sa.get('image_url'),
genres=sa.get('genres'), genres=sa.get('genres'),
popularity=sa.get('popularity', 0), popularity=sa.get('popularity', 0),
similar_artist_deezer_id=sa.get('deezer_id') similar_artist_deezer_id=sa.get('deezer_id'),
similar_artist_musicbrainz_id=sa.get('musicbrainz_id'),
) )
except Exception as e: except Exception as e:
logger.debug("similar artist insert failed: %s", e) logger.debug("similar artist insert failed: %s", e)
@ -798,7 +805,7 @@ def get_artist_map_explore():
placeholders = ','.join(['?'] * len(id_values)) placeholders = ','.join(['?'] * len(id_values))
cursor.execute(f""" cursor.execute(f"""
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id, SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
image_url, genres, popularity, similarity_rank image_url, genres, popularity, similarity_rank
FROM similar_artists FROM similar_artists
WHERE source_artist_id IN ({placeholders}) AND profile_id = ? WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
@ -809,7 +816,7 @@ def get_artist_map_explore():
# Fallback: query by name-based source ID # Fallback: query by name-based source ID
cursor.execute(""" cursor.execute("""
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id, SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
image_url, genres, popularity, similarity_rank image_url, genres, popularity, similarity_rank
FROM similar_artists FROM similar_artists
WHERE source_artist_id = ? AND profile_id = ? WHERE source_artist_id = ? AND profile_id = ?
@ -841,6 +848,7 @@ def get_artist_map_explore():
'spotify_id': r['similar_artist_spotify_id'] or '', 'spotify_id': r['similar_artist_spotify_id'] or '',
'itunes_id': r['similar_artist_itunes_id'] or '', 'itunes_id': r['similar_artist_itunes_id'] or '',
'deezer_id': r['similar_artist_deezer_id'] or '', 'deezer_id': r['similar_artist_deezer_id'] or '',
'musicbrainz_id': r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else '',
'discogs_id': '', 'discogs_id': '',
'popularity': r['popularity'] or 0, 'popularity': r['popularity'] or 0,
'rank': r['similarity_rank'] or 5, 'rank': r['similarity_rank'] or 5,
@ -861,7 +869,8 @@ def get_artist_map_explore():
cursor.execute(f""" cursor.execute(f"""
SELECT DISTINCT source_artist_id, similar_artist_name, SELECT DISTINCT source_artist_id, similar_artist_name,
similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_deezer_id, image_url, genres, popularity, similarity_rank similar_artist_deezer_id, similar_artist_musicbrainz_id,
image_url, genres, popularity, similarity_rank
FROM similar_artists FROM similar_artists
WHERE source_artist_id IN ({placeholders}) AND profile_id = ? WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
ORDER BY similarity_rank ASC ORDER BY similarity_rank ASC
@ -902,6 +911,7 @@ def get_artist_map_explore():
'spotify_id': r['similar_artist_spotify_id'] or '', 'spotify_id': r['similar_artist_spotify_id'] or '',
'itunes_id': r['similar_artist_itunes_id'] or '', 'itunes_id': r['similar_artist_itunes_id'] or '',
'deezer_id': r['similar_artist_deezer_id'] or '', 'deezer_id': r['similar_artist_deezer_id'] or '',
'musicbrainz_id': r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else '',
'discogs_id': '', 'discogs_id': '',
'popularity': r['popularity'] or 0, 'popularity': r['popularity'] or 0,
'rank': r['similarity_rank'] or 5, 'rank': r['similarity_rank'] or 5,
@ -935,7 +945,7 @@ def get_artist_map_explore():
except Exception as e: except Exception as e:
logger.debug("explorer node genres parse failed: %s", e) logger.debug("explorer node genres parse failed: %s", e)
# Harvest missing IDs from cache # Harvest missing IDs from cache
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'} src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
k = src_map.get(cr['source']) k = src_map.get(cr['source'])
if k and not n.get(k): if k and not n.get(k):
n[k] = cr['entity_id'] n[k] = cr['entity_id']

View file

@ -96,6 +96,8 @@ def get_discover_hero():
artist_id = artist.spotify_artist_id artist_id = artist.spotify_artist_id
elif active_source == 'deezer': elif active_source == 'deezer':
artist_id = getattr(artist, 'deezer_artist_id', None) or artist.itunes_artist_id artist_id = getattr(artist, 'deezer_artist_id', None) or artist.itunes_artist_id
elif active_source == 'musicbrainz':
artist_id = getattr(artist, 'musicbrainz_artist_id', None) or artist.itunes_artist_id
else: else:
artist_id = artist.itunes_artist_id artist_id = artist.itunes_artist_id
if not artist_id: if not artist_id:
@ -125,7 +127,7 @@ def get_discover_hero():
valid_artists = list(similar_artists) valid_artists = list(similar_artists)
# FALLBACK: If no valid artists for fallback source, try to resolve IDs on-the-fly # FALLBACK: If no valid artists for fallback source, try to resolve IDs on-the-fly
if active_source in ('itunes', 'deezer') and not valid_artists: if active_source in ('itunes', 'deezer', 'musicbrainz') and not valid_artists:
logger.warning(f"[{active_source} Fallback] No artists with {active_source} IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists") logger.warning(f"[{active_source} Fallback] No artists with {active_source} IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists")
resolved_count = 0 resolved_count = 0
for artist in similar_artists: for artist in similar_artists:
@ -135,13 +137,20 @@ def get_discover_hero():
continue continue
# Try to resolve ID by name # Try to resolve ID by name
try: try:
search_results = itunes_client.search_artists(artist.similar_artist_name, limit=1) resolve_client = itunes_client
if active_source == 'musicbrainz':
from core.metadata.registry import get_musicbrainz_client
resolve_client = get_musicbrainz_client()
search_results = resolve_client.search_artists(artist.similar_artist_name, limit=1)
if search_results and len(search_results) > 0: if search_results and len(search_results) > 0:
resolved_id = search_results[0].id resolved_id = search_results[0].id
# Cache the resolved ID for future use # Cache the resolved ID for future use
if active_source == 'deezer': if active_source == 'deezer':
database.update_similar_artist_deezer_id(artist.id, resolved_id) database.update_similar_artist_deezer_id(artist.id, resolved_id)
artist.similar_artist_deezer_id = resolved_id artist.similar_artist_deezer_id = resolved_id
elif active_source == 'musicbrainz':
database.update_similar_artist_musicbrainz_id(artist.id, resolved_id)
artist.similar_artist_musicbrainz_id = resolved_id
else: else:
database.update_similar_artist_itunes_id(artist.id, resolved_id) database.update_similar_artist_itunes_id(artist.id, resolved_id)
artist.similar_artist_itunes_id = resolved_id artist.similar_artist_itunes_id = resolved_id
@ -173,12 +182,15 @@ def get_discover_hero():
artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id
elif active_source == 'deezer': elif active_source == 'deezer':
artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
elif active_source == 'musicbrainz':
artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
else: else:
artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
artist_data = { artist_data = {
"spotify_artist_id": artist.similar_artist_spotify_id, "spotify_artist_id": artist.similar_artist_spotify_id,
"itunes_artist_id": artist.similar_artist_itunes_id, "itunes_artist_id": artist.similar_artist_itunes_id,
"musicbrainz_artist_id": getattr(artist, 'similar_artist_musicbrainz_id', None),
"artist_id": artist_id, "artist_id": artist_id,
"artist_name": artist.similar_artist_name, "artist_name": artist.similar_artist_name,
"occurrence_count": artist.occurrence_count, "occurrence_count": artist.occurrence_count,
@ -207,11 +219,19 @@ def get_discover_hero():
artist.id, artist_data.get('image_url'), artist.id, artist_data.get('image_url'),
artist_data.get('genres'), artist_data.get('popularity') artist_data.get('genres'), artist_data.get('popularity')
) )
elif active_source in ('itunes', 'deezer'): elif active_source in ('itunes', 'deezer', 'musicbrainz'):
fb_artist_id = getattr(artist, 'similar_artist_deezer_id', None) if active_source == 'deezer' else None if active_source == 'deezer':
fb_artist_id = fb_artist_id or artist.similar_artist_itunes_id fb_artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
fetch_client = itunes_client
elif active_source == 'musicbrainz':
fb_artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None)
from core.metadata.registry import get_musicbrainz_client
fetch_client = get_musicbrainz_client()
else:
fb_artist_id = artist.similar_artist_itunes_id
fetch_client = itunes_client
if fb_artist_id: if fb_artist_id:
fb_artist_data = itunes_client.get_artist(fb_artist_id) fb_artist_data = fetch_client.get_artist(fb_artist_id)
if fb_artist_data: if fb_artist_data:
artist_data['artist_name'] = fb_artist_data.get('name', artist.similar_artist_name) artist_data['artist_name'] = fb_artist_data.get('name', artist.similar_artist_name)
artist_data['image_url'] = fb_artist_data.get('images', [{}])[0].get('url') if fb_artist_data.get('images') else None artist_data['image_url'] = fb_artist_data.get('images', [{}])[0].get('url') if fb_artist_data.get('images') else None

View file

@ -31,6 +31,7 @@ from core.metadata.registry import (
get_discogs_client, get_discogs_client,
get_hydrabase_client, get_hydrabase_client,
get_itunes_client, get_itunes_client,
get_musicbrainz_client,
get_primary_client, get_primary_client,
get_primary_source, get_primary_source,
get_spotify_client_for_profile, get_spotify_client_for_profile,
@ -82,6 +83,7 @@ __all__ = [
"get_metadata_cache", "get_metadata_cache",
"get_metadata_source_status", "get_metadata_source_status",
"get_metadata_service", "get_metadata_service",
"get_musicbrainz_client",
"get_musicmap_similar_artists", "get_musicmap_similar_artists",
"get_primary_client", "get_primary_client",
"get_primary_source", "get_primary_source",

View file

@ -18,13 +18,14 @@ logger = get_logger("metadata.registry")
MetadataClientFactory = Callable[[], Any] MetadataClientFactory = Callable[[], Any]
METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase") METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase", "musicbrainz")
METADATA_SOURCE_LABELS = { METADATA_SOURCE_LABELS = {
"spotify": "Spotify", "spotify": "Spotify",
"itunes": "iTunes", "itunes": "iTunes",
"deezer": "Deezer", "deezer": "Deezer",
"discogs": "Discogs", "discogs": "Discogs",
"hydrabase": "Hydrabase", "hydrabase": "Hydrabase",
"musicbrainz": "MusicBrainz",
} }
_UNSET = object() _UNSET = object()
@ -148,6 +149,14 @@ def _get_amazon_factory(client_factory: Optional[MetadataClientFactory]) -> Meta
return AmazonClient return AmazonClient
def _get_musicbrainz_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
if client_factory is not None:
return client_factory
from core.musicbrainz_search import MusicBrainzSearchClient
return MusicBrainzSearchClient
def get_spotify_client(client_factory: Optional[MetadataClientFactory] = None): def get_spotify_client(client_factory: Optional[MetadataClientFactory] = None):
"""Get shared Spotify client. """Get shared Spotify client.
@ -280,6 +289,18 @@ def get_amazon_client(client_factory: Optional[MetadataClientFactory] = None):
return client return client
def get_musicbrainz_client(client_factory: Optional[MetadataClientFactory] = None):
"""Get cached MusicBrainz primary source client."""
cache_key = "musicbrainz"
factory = _get_musicbrainz_factory(client_factory)
with _client_cache_lock:
client = _client_cache.get(cache_key)
if client is None:
client = factory()
_client_cache[cache_key] = client
return client
def is_hydrabase_enabled() -> bool: def is_hydrabase_enabled() -> bool:
"""Return True when Hydrabase is connected and app-enabled.""" """Return True when Hydrabase is connected and app-enabled."""
try: try:
@ -308,24 +329,26 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr
def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str: def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str:
"""Return configured primary metadata source.""" """Return configured primary metadata source."""
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer" _default = METADATA_SOURCE_PRIORITY[0]
source = _get_config_value("metadata.fallback_source", _default) or _default
if source == "spotify": if source == "spotify":
try: try:
spotify = get_spotify_client(client_factory=spotify_client_factory) spotify = get_spotify_client(client_factory=spotify_client_factory)
if not spotify or not spotify.is_spotify_authenticated(): if not spotify or not spotify.is_spotify_authenticated():
return "deezer" return _default
except Exception: except Exception:
return "deezer" return _default
return source return source
def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str: def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str:
"""Return the active metadata source after Spotify is disconnected.""" """Return the active metadata source after Spotify is disconnected."""
source = configured_source if configured_source is not None else _get_config_value("metadata.fallback_source", "deezer") _default = METADATA_SOURCE_PRIORITY[0]
source = source or "deezer" source = configured_source if configured_source is not None else _get_config_value("metadata.fallback_source", _default)
return "deezer" if source == "spotify" else source source = source or _default
return _default if source == "spotify" else source
def get_metadata_source_label(source: str) -> str: def get_metadata_source_label(source: str) -> str:
@ -352,6 +375,7 @@ def get_primary_client(
deezer_client_factory: Optional[MetadataClientFactory] = None, deezer_client_factory: Optional[MetadataClientFactory] = None,
discogs_client_factory: Optional[MetadataClientFactory] = None, discogs_client_factory: Optional[MetadataClientFactory] = None,
amazon_client_factory: Optional[MetadataClientFactory] = None, amazon_client_factory: Optional[MetadataClientFactory] = None,
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
): ):
"""Return client for configured primary source.""" """Return client for configured primary source."""
return get_client_for_source( return get_client_for_source(
@ -361,6 +385,7 @@ def get_primary_client(
deezer_client_factory=deezer_client_factory, deezer_client_factory=deezer_client_factory,
discogs_client_factory=discogs_client_factory, discogs_client_factory=discogs_client_factory,
amazon_client_factory=amazon_client_factory, amazon_client_factory=amazon_client_factory,
musicbrainz_client_factory=musicbrainz_client_factory,
) )
@ -371,6 +396,7 @@ def get_primary_source_status(
deezer_client_factory: Optional[MetadataClientFactory] = None, deezer_client_factory: Optional[MetadataClientFactory] = None,
discogs_client_factory: Optional[MetadataClientFactory] = None, discogs_client_factory: Optional[MetadataClientFactory] = None,
amazon_client_factory: Optional[MetadataClientFactory] = None, amazon_client_factory: Optional[MetadataClientFactory] = None,
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Return a generic status snapshot for the active primary metadata source.""" """Return a generic status snapshot for the active primary metadata source."""
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer" source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
@ -385,6 +411,7 @@ def get_primary_source_status(
deezer_client_factory=deezer_client_factory, deezer_client_factory=deezer_client_factory,
discogs_client_factory=discogs_client_factory, discogs_client_factory=discogs_client_factory,
amazon_client_factory=amazon_client_factory, amazon_client_factory=amazon_client_factory,
musicbrainz_client_factory=musicbrainz_client_factory,
) )
if source == "spotify": if source == "spotify":
connected = bool(client and client.is_spotify_authenticated()) connected = bool(client and client.is_spotify_authenticated())
@ -412,6 +439,7 @@ def get_client_for_source(
deezer_client_factory: Optional[MetadataClientFactory] = None, deezer_client_factory: Optional[MetadataClientFactory] = None,
discogs_client_factory: Optional[MetadataClientFactory] = None, discogs_client_factory: Optional[MetadataClientFactory] = None,
amazon_client_factory: Optional[MetadataClientFactory] = None, amazon_client_factory: Optional[MetadataClientFactory] = None,
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
): ):
"""Return exact client for a source, or None if unavailable.""" """Return exact client for a source, or None if unavailable."""
if source == "spotify": if source == "spotify":
@ -438,4 +466,7 @@ def get_client_for_source(
if source == "amazon": if source == "amazon":
return get_amazon_client(client_factory=amazon_client_factory) return get_amazon_client(client_factory=amazon_client_factory)
if source == "musicbrainz":
return get_musicbrainz_client(client_factory=musicbrainz_client_factory)
return None return None

View file

@ -333,7 +333,52 @@ class Album:
@classmethod @classmethod
def from_musicbrainz_dict(cls, raw: Dict[str, Any]) -> 'Album': def from_musicbrainz_dict(cls, raw: Dict[str, Any]) -> 'Album':
"""MusicBrainz ``/release/{mbid}`` response shape (release, not release-group).""" """MusicBrainz album shape.
Accepts both raw ``/release/{mbid}`` responses and the normalized
MusicBrainz search adapter shape used by app-facing metadata clients.
"""
if raw.get('name') and not raw.get('title'):
artists = raw.get('artists') or []
artist_names = []
primary_artist_id = ''
for artist in artists:
if isinstance(artist, dict):
name = _str(artist.get('name'))
if name:
artist_names.append(name)
if not primary_artist_id and artist.get('id'):
primary_artist_id = _str(artist['id'])
else:
name = _str(artist)
if name:
artist_names.append(name)
images = raw.get('images') or []
image_url = ''
if images and isinstance(images[0], dict):
image_url = _str(images[0].get('url'))
image_url = image_url or _str(raw.get('image_url'))
external_ids = {}
if raw.get('id'):
external_ids['musicbrainz'] = _str(raw['id'])
return cls(
id=_str(raw.get('id')),
name=_str(raw.get('name')),
artists=artist_names or ['Unknown Artist'],
release_date=_str(raw.get('release_date')),
total_tracks=_int(raw.get('total_tracks')),
album_type=_str(raw.get('album_type'), default='album') or 'album',
image_url=image_url or None,
artist_id=primary_artist_id or None,
genres=list(raw.get('genres') or []),
source='musicbrainz',
external_ids=external_ids,
external_urls=dict(raw.get('external_urls') or {}),
)
artist_credit = raw.get('artist-credit') or [] artist_credit = raw.get('artist-credit') or []
artist_names = [] artist_names = []
primary_artist_id = '' primary_artist_id = ''

View file

@ -45,6 +45,7 @@ from core.metadata.registry import (
get_amazon_client, get_amazon_client,
get_client_for_source, get_client_for_source,
get_deezer_client, get_deezer_client,
get_musicbrainz_client,
get_discogs_client, get_discogs_client,
get_hydrabase_client, get_hydrabase_client,
get_itunes_client, get_itunes_client,
@ -77,6 +78,7 @@ except Exception: # pragma: no cover - optional dependency fallback
__all__ = [ __all__ = [
"METADATA_SOURCE_PRIORITY", "METADATA_SOURCE_PRIORITY",
"get_amazon_client", "get_amazon_client",
"get_musicbrainz_client",
"MetadataCache", "MetadataCache",
"MetadataLookupOptions", "MetadataLookupOptions",
"MetadataProvider", "MetadataProvider",

View file

@ -678,7 +678,131 @@ class MusicBrainzSearchClient:
return sorted(releases, key=_key)[0] return sorted(releases, key=_key)[0]
def get_album(self, album_mbid: str) -> Optional[Dict[str, Any]]: def is_authenticated(self) -> bool:
return True
def reload_config(self) -> None:
pass
def get_track_features(self, track_id: str) -> None:
return None
def get_user_info(self) -> None:
return None
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Return Spotify-compatible track detail dict by recording MBID."""
try:
rec = self._client.get_recording(track_id, includes=['releases', 'artist-credits', 'release-groups'])
if not rec:
return None
releases = rec.get('releases', []) or []
releases.sort(key=self._release_preference_key)
first_rel = releases[0] if releases else {}
rg = first_rel.get('release-group', {}) or {}
release_id = first_rel.get('id', '')
rg_id = rg.get('id', '')
image_url = self._cached_art(release_id, rg_id)
artists = _extract_artist_credit(rec.get('artist-credit', []))
return {
'id': rec.get('id', ''),
'name': rec.get('title', ''),
'artists': [{'name': a, 'id': ''} for a in artists],
'album': {
'id': rg_id or release_id,
'name': first_rel.get('title', ''),
'images': [{'url': image_url, 'height': 250, 'width': 250}] if image_url else [],
'release_date': first_rel.get('date') or rg.get('first-release-date') or '',
},
'duration_ms': rec.get('length') or 0,
'track_number': 1,
'disc_number': 1,
'preview_url': None,
'popularity': 0,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/recording/{track_id}'},
}
except Exception as e:
logger.error(f'get_track_details({track_id}) error: {e}')
return None
def get_album_tracks(self, album_mbid: str) -> Optional[Dict[str, Any]]:
"""Return {items: [...], total: N} track listing for a release/release-group MBID."""
album = self.get_album(album_mbid, include_tracks=True)
if album is None:
return None
flat = album.get('tracks', [])
if isinstance(flat, dict):
return flat
return {'items': flat, 'total': len(flat)}
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
"""Return Spotify-compatible artist detail dict."""
try:
artist = self._client.get_artist(artist_id, includes=['tags', 'url-rels'])
if not artist:
return None
genres = [t['name'] for t in (artist.get('tags') or []) if isinstance(t, dict) and t.get('name')]
return {
'id': artist.get('id', artist_id),
'name': artist.get('name', ''),
'genres': genres,
'followers': {'total': 0},
'popularity': 0,
'images': [],
'external_urls': {'musicbrainz': f'https://musicbrainz.org/artist/{artist_id}'},
}
except Exception as e:
logger.error(f'get_artist({artist_id}) error: {e}')
return None
def get_artist_top_tracks(self, artist_id: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Return top recordings for an artist, deduplicated by title and sorted by year."""
try:
recs = self._client.search_recordings_by_artist_mbid(artist_id, limit=100)
for r in recs:
rels = r.get('releases') or []
if rels:
rels.sort(key=self._release_preference_key)
r['releases'] = rels
studio = [r for r in recs if self._has_studio_release(r)]
recs = studio or recs
seen: set = set()
deduped = []
for r in recs:
key = (r.get('title') or '').lower().strip()
if not key or key in seen:
continue
seen.add(key)
deduped.append(r)
results = []
for r in deduped[:limit]:
releases = r.get('releases', [])
first_rel = releases[0] if releases else {}
rg = first_rel.get('release-group', {}) or {}
release_id = first_rel.get('id', '')
rg_id = rg.get('id', '')
artists = _extract_artist_credit(r.get('artist-credit', []))
image_url = self._cached_art(release_id, rg_id)
results.append({
'id': r.get('id', ''),
'name': r.get('title', ''),
'artists': [{'name': a, 'id': ''} for a in artists],
'album': {
'id': rg_id or release_id,
'name': first_rel.get('title', ''),
'images': [{'url': image_url}] if image_url else [],
},
'duration_ms': r.get('length') or 0,
'popularity': 0,
'preview_url': None,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/recording/{r.get("id", "")}'},
})
return results
except Exception as e:
logger.error(f'get_artist_top_tracks({artist_id}) error: {e}')
return []
def get_album(self, album_mbid: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
"""Get full album details with track listing for download modal. """Get full album details with track listing for download modal.
The MBID passed in could be either: The MBID passed in could be either:
@ -713,10 +837,15 @@ class MusicBrainzSearchClient:
album['external_urls'] = { album['external_urls'] = {
'musicbrainz': f'https://musicbrainz.org/release-group/{album_mbid}' 'musicbrainz': f'https://musicbrainz.org/release-group/{album_mbid}'
} }
if not include_tracks:
album.pop('tracks', None)
return album return album
# Path B: release MBID (text-search fallback path) # Path B: release MBID (text-search fallback path)
return self._render_release_as_album(album_mbid) album = self._render_release_as_album(album_mbid)
if album and not include_tracks:
album.pop('tracks', None)
return album
except Exception as e: except Exception as e:
logger.error(f"MusicBrainz album detail failed for {album_mbid}: {e}") logger.error(f"MusicBrainz album detail failed for {album_mbid}: {e}")
return None return None
@ -789,7 +918,7 @@ class MusicBrainzSearchClient:
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'}, 'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
} }
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single') -> List: def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single', limit: int = 200) -> List:
"""Get artist's releases for discography view.""" """Get artist's releases for discography view."""
try: try:
artist = self._client.get_artist(artist_mbid, includes=['release-groups']) artist = self._client.get_artist(artist_mbid, includes=['release-groups'])
@ -814,7 +943,7 @@ class MusicBrainzSearchClient:
image_url=image_url, image_url=image_url,
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'}, external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'},
)) ))
return albums return albums[:limit]
except Exception as e: except Exception as e:
logger.warning(f"MusicBrainz artist albums failed: {e}") logger.warning(f"MusicBrainz artist albums failed: {e}")
return [] return []

View file

@ -902,7 +902,9 @@ class PersonalizedPlaylistsService:
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT similar_artist_spotify_id, similar_artist_name SELECT similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_deezer_id, similar_artist_musicbrainz_id,
similar_artist_name
FROM similar_artists FROM similar_artists
WHERE source_artist_id = ? WHERE source_artist_id = ?
ORDER BY similarity_rank ASC ORDER BY similarity_rank ASC
@ -911,9 +913,16 @@ class PersonalizedPlaylistsService:
db_results = cursor.fetchall() db_results = cursor.fetchall()
if db_results: if db_results:
source_id_col = {
'spotify': 'similar_artist_spotify_id',
'itunes': 'similar_artist_itunes_id',
'deezer': 'similar_artist_deezer_id',
'musicbrainz': 'similar_artist_musicbrainz_id',
}.get(active_source, 'similar_artist_itunes_id')
for row in db_results: for row in db_results:
artist_id = row['similar_artist_spotify_id'] r = dict(row)
artist_name = row['similar_artist_name'] artist_id = r.get(source_id_col) or r.get('similar_artist_spotify_id') or r.get('similar_artist_itunes_id')
artist_name = r['similar_artist_name']
if artist_id and artist_id not in seen_artist_ids: if artist_id and artist_id not in seen_artist_ids:
all_similar_artists.append({'id': artist_id, 'name': artist_name}) all_similar_artists.append({'id': artist_id, 'name': artist_name})
seen_artist_ids.add(artist_id) seen_artist_ids.add(artist_id)

View file

@ -28,6 +28,7 @@ SOURCE_ID_COLUMNS = (
('itunes', 'itunes_artist_id'), ('itunes', 'itunes_artist_id'),
('deezer', 'deezer_id'), ('deezer', 'deezer_id'),
('discogs', 'discogs_id'), ('discogs', 'discogs_id'),
('musicbrainz', 'musicbrainz_id'),
) )

View file

@ -520,12 +520,8 @@ class WatchlistScanner:
return list(get_source_priority(get_primary_source())) return list(get_source_priority(get_primary_source()))
def _discovery_source_priority(self) -> List[str]: def _discovery_source_priority(self) -> List[str]:
"""Return discovery sources in configured priority order. """Return discovery sources in configured priority order."""
return [source for source in self._watchlist_source_priority() if source in {'spotify', 'itunes', 'deezer', 'musicbrainz'}]
Discovery pool writes only support Spotify, iTunes, and Deezer IDs, so
we filter the broader metadata priority list down to those sources.
"""
return [source for source in self._watchlist_source_priority() if source in {'spotify', 'itunes', 'deezer'}]
@staticmethod @staticmethod
def _artist_id_attribute_for_source(source: str) -> Optional[str]: def _artist_id_attribute_for_source(source: str) -> Optional[str]:
@ -535,6 +531,7 @@ class WatchlistScanner:
'itunes': 'itunes_artist_id', 'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id', 'deezer': 'deezer_artist_id',
'discogs': 'discogs_artist_id', 'discogs': 'discogs_artist_id',
'musicbrainz': 'musicbrainz_artist_id',
}.get(source) }.get(source)
@staticmethod @staticmethod
@ -544,6 +541,7 @@ class WatchlistScanner:
'spotify': 'similar_artist_spotify_id', 'spotify': 'similar_artist_spotify_id',
'itunes': 'similar_artist_itunes_id', 'itunes': 'similar_artist_itunes_id',
'deezer': 'similar_artist_deezer_id', 'deezer': 'similar_artist_deezer_id',
'musicbrainz': 'similar_artist_musicbrainz_id',
}.get(source) }.get(source)
@staticmethod @staticmethod
@ -574,6 +572,9 @@ class WatchlistScanner:
elif source == 'discogs': elif source == 'discogs':
self.database.update_watchlist_discogs_id(watchlist_artist.id, source_id) self.database.update_watchlist_discogs_id(watchlist_artist.id, source_id)
watchlist_artist.discogs_artist_id = source_id watchlist_artist.discogs_artist_id = source_id
elif source == 'musicbrainz':
self.database.update_watchlist_musicbrainz_id(watchlist_artist.id, source_id)
watchlist_artist.musicbrainz_artist_id = source_id
def _resolve_watchlist_artist_source_id(self, watchlist_artist: WatchlistArtist, source: str, client: Any) -> Optional[str]: def _resolve_watchlist_artist_source_id(self, watchlist_artist: WatchlistArtist, source: str, client: Any) -> Optional[str]:
"""Resolve the artist ID for an exact source, searching by name if needed.""" """Resolve the artist ID for an exact source, searching by name if needed."""
@ -904,7 +905,7 @@ class WatchlistScanner:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, SELECT id, artist_name, spotify_artist_id, itunes_artist_id,
deezer_artist_id, discogs_artist_id deezer_artist_id, discogs_artist_id, musicbrainz_artist_id
FROM watchlist_artists FROM watchlist_artists
WHERE profile_id = ? AND (image_url IS NULL OR image_url = '' OR image_url = 'None' WHERE profile_id = ? AND (image_url IS NULL OR image_url = '' OR image_url = 'None'
OR image_url NOT LIKE 'http%') OR image_url NOT LIKE 'http%')
@ -959,7 +960,8 @@ class WatchlistScanner:
if img: if img:
aid = (row['spotify_artist_id'] or row['itunes_artist_id'] aid = (row['spotify_artist_id'] or row['itunes_artist_id']
or row['deezer_artist_id'] or row['discogs_artist_id']) or row['deezer_artist_id'] or row['discogs_artist_id']
or row['musicbrainz_artist_id'])
if aid: if aid:
self.database.update_watchlist_artist_image(aid, img) self.database.update_watchlist_artist_image(aid, img)
else: else:
@ -992,7 +994,7 @@ class WatchlistScanner:
""" """
# Per-artist metadata source override — if set, use that source first with fallback # Per-artist metadata source override — if set, use that source first with fallback
preferred = getattr(watchlist_artist, 'preferred_metadata_source', None) preferred = getattr(watchlist_artist, 'preferred_metadata_source', None)
if preferred and preferred in ('spotify', 'deezer', 'itunes', 'discogs'): if preferred and preferred in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'):
source_priority = list(get_source_priority(preferred)) source_priority = list(get_source_priority(preferred))
else: else:
source_priority = self._watchlist_source_priority() source_priority = self._watchlist_source_priority()
@ -1164,7 +1166,7 @@ class WatchlistScanner:
# Keep this as a plain source list; resolve the client right before each use. # Keep this as a plain source list; resolve the client right before each use.
providers_to_backfill = [ providers_to_backfill = [
source for source in self._watchlist_source_priority() source for source in self._watchlist_source_priority()
if source in {'spotify', 'itunes', 'deezer', 'discogs'} if source in {'spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz'}
] ]
for provider in providers_to_backfill: for provider in providers_to_backfill:
@ -1221,6 +1223,7 @@ class WatchlistScanner:
or artist.itunes_artist_id or artist.itunes_artist_id
or artist.deezer_artist_id or artist.deezer_artist_id
or artist.discogs_artist_id or artist.discogs_artist_id
or getattr(artist, 'musicbrainz_artist_id', None)
or str(artist.id) or str(artist.id)
) )
@ -1595,6 +1598,7 @@ class WatchlistScanner:
'itunes': 'itunes_artist_id', 'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id', 'deezer': 'deezer_artist_id',
'discogs': 'discogs_artist_id', 'discogs': 'discogs_artist_id',
'musicbrainz': 'musicbrainz_artist_id',
}.get(provider) }.get(provider)
if not id_attr: if not id_attr:
@ -1614,6 +1618,7 @@ class WatchlistScanner:
'itunes': self._match_to_itunes, 'itunes': self._match_to_itunes,
'deezer': self._match_to_deezer, 'deezer': self._match_to_deezer,
'discogs': self._match_to_discogs, 'discogs': self._match_to_discogs,
'musicbrainz': self._match_to_musicbrainz,
}.get(provider) }.get(provider)
update_fn = { update_fn = {
@ -1621,6 +1626,7 @@ class WatchlistScanner:
'itunes': self.database.update_watchlist_itunes_id, 'itunes': self.database.update_watchlist_itunes_id,
'deezer': self.database.update_watchlist_deezer_id, 'deezer': self.database.update_watchlist_deezer_id,
'discogs': self.database.update_watchlist_discogs_id, 'discogs': self.database.update_watchlist_discogs_id,
'musicbrainz': self.database.update_watchlist_musicbrainz_id,
}.get(provider) }.get(provider)
if not match_fn or not update_fn: if not match_fn or not update_fn:
@ -1780,6 +1786,17 @@ class WatchlistScanner:
logger.warning(f"Could not match {artist_name} to Discogs: {e}") logger.warning(f"Could not match {artist_name} to Discogs: {e}")
return None return None
def _match_to_musicbrainz(self, artist_name: str) -> Optional[str]:
"""Match artist name to MusicBrainz ID using fuzzy name comparison."""
try:
from core.metadata.registry import get_musicbrainz_client
client = get_musicbrainz_client()
results = client.search_artists(artist_name, limit=5)
return self._best_artist_match(results, artist_name)
except Exception as e:
logger.warning(f"Could not match {artist_name} to MusicBrainz: {e}")
return None
def _get_lookback_period_setting(self) -> str: def _get_lookback_period_setting(self) -> str:
""" """
Get the discovery lookback period setting from database. Get the discovery lookback period setting from database.
@ -2372,6 +2389,7 @@ class WatchlistScanner:
'spotify': 'spotify_id', 'spotify': 'spotify_id',
'itunes': 'itunes_id', 'itunes': 'itunes_id',
'deezer': 'deezer_id', 'deezer': 'deezer_id',
'musicbrainz': 'musicbrainz_id',
} }
searched_source_ids = {} searched_source_ids = {}
available_sources = [] available_sources = []
@ -2403,6 +2421,7 @@ class WatchlistScanner:
'spotify_id': None, 'spotify_id': None,
'itunes_id': None, 'itunes_id': None,
'deezer_id': None, 'deezer_id': None,
'musicbrainz_id': None,
'image_url': None, 'image_url': None,
'genres': [], 'genres': [],
'popularity': 0, 'popularity': 0,
@ -2470,6 +2489,8 @@ class WatchlistScanner:
return self.database.update_similar_artist_deezer_id(similar_artist_id, source_id) return self.database.update_similar_artist_deezer_id(similar_artist_id, source_id)
if source == 'itunes': if source == 'itunes':
return self.database.update_similar_artist_itunes_id(similar_artist_id, source_id) return self.database.update_similar_artist_itunes_id(similar_artist_id, source_id)
if source == 'musicbrainz':
return self.database.update_similar_artist_musicbrainz_id(similar_artist_id, source_id)
return False return False
def _backfill_similar_artists_fallback_ids(self, source_artist_id: str, profile_id: int = 1) -> int: def _backfill_similar_artists_fallback_ids(self, source_artist_id: str, profile_id: int = 1) -> int:
@ -2480,7 +2501,7 @@ class WatchlistScanner:
writable similar-artist ID columns. This keeps old cached rows usable writable similar-artist ID columns. This keeps old cached rows usable
when the active metadata provider changes. when the active metadata provider changes.
""" """
backfill_sources = [source for source in self._discovery_source_priority() if source in {'itunes', 'deezer'}] backfill_sources = [source for source in self._discovery_source_priority() if source in {'itunes', 'deezer', 'musicbrainz'}]
if not backfill_sources: if not backfill_sources:
logger.debug("No fallback metadata providers available for similar-artist backfill") logger.debug("No fallback metadata providers available for similar-artist backfill")
return 0 return 0
@ -2582,14 +2603,18 @@ class WatchlistScanner:
image_url=similar_artist.get('image_url'), image_url=similar_artist.get('image_url'),
genres=similar_artist.get('genres'), genres=similar_artist.get('genres'),
popularity=similar_artist.get('popularity', 0), popularity=similar_artist.get('popularity', 0),
similar_artist_deezer_id=similar_artist.get('deezer_id') similar_artist_deezer_id=similar_artist.get('deezer_id'),
similar_artist_musicbrainz_id=similar_artist.get('musicbrainz_id'),
) )
if success: if success:
stored_count += 1 stored_count += 1
fallback_id = similar_artist.get('deezer_id') or similar_artist.get('itunes_id') ids = ', '.join(
fallback_label = 'Deezer' if similar_artist.get('deezer_id') else 'iTunes' f"{k}: {similar_artist.get(v)}"
logger.debug(f" #{rank}: {similar_artist['name']} (Spotify: {similar_artist.get('spotify_id')}, {fallback_label}: {fallback_id})") for k, v in [('Spotify', 'spotify_id'), ('iTunes', 'itunes_id'), ('Deezer', 'deezer_id'), ('MB', 'musicbrainz_id')]
if similar_artist.get(v)
)
logger.debug(f" #{rank}: {similar_artist['name']} ({ids})")
except Exception as e: except Exception as e:
logger.warning(f"Error storing similar artist {similar_artist.get('name', 'Unknown')}: {e}") logger.warning(f"Error storing similar artist {similar_artist.get('name', 'Unknown')}: {e}")
@ -2685,6 +2710,8 @@ class WatchlistScanner:
cache_callback = lambda found_id, artist_id=similar_artist.id: self.database.update_similar_artist_itunes_id(artist_id, found_id) cache_callback = lambda found_id, artist_id=similar_artist.id: self.database.update_similar_artist_itunes_id(artist_id, found_id)
elif source == 'deezer': elif source == 'deezer':
cache_callback = lambda found_id, artist_id=similar_artist.id: self.database.update_similar_artist_deezer_id(artist_id, found_id) cache_callback = lambda found_id, artist_id=similar_artist.id: self.database.update_similar_artist_deezer_id(artist_id, found_id)
elif source == 'musicbrainz':
cache_callback = lambda found_id, artist_id=similar_artist.id: self.database.update_similar_artist_musicbrainz_id(artist_id, found_id)
artist_id = self._resolve_artist_id_for_source( artist_id = self._resolve_artist_id_for_source(
source, source,
@ -2820,7 +2847,7 @@ class WatchlistScanner:
track_data['deezer_track_id'] = track.get('id') track_data['deezer_track_id'] = track.get('id')
track_data['deezer_album_id'] = album_data.get('id') track_data['deezer_album_id'] = album_data.get('id')
track_data['deezer_artist_id'] = selected_artist_id track_data['deezer_artist_id'] = selected_artist_id
else: elif selected_source == 'itunes':
track_data['itunes_track_id'] = track.get('id') track_data['itunes_track_id'] = track.get('id')
track_data['itunes_album_id'] = album_data.get('id') track_data['itunes_album_id'] = album_data.get('id')
track_data['itunes_artist_id'] = selected_artist_id track_data['itunes_artist_id'] = selected_artist_id
@ -2954,7 +2981,7 @@ class WatchlistScanner:
track_data['deezer_track_id'] = track.get('id') track_data['deezer_track_id'] = track.get('id')
track_data['deezer_album_id'] = album_data.get('id') track_data['deezer_album_id'] = album_data.get('id')
track_data['deezer_artist_id'] = artist_id_for_genres or '' track_data['deezer_artist_id'] = artist_id_for_genres or ''
else: elif db_source == 'itunes':
track_data['itunes_track_id'] = track.get('id') track_data['itunes_track_id'] = track.get('id')
track_data['itunes_album_id'] = album_data.get('id') track_data['itunes_album_id'] = album_data.get('id')
track_data['itunes_artist_id'] = artist_id_for_genres or '' track_data['itunes_artist_id'] = artist_id_for_genres or ''
@ -3176,7 +3203,7 @@ class WatchlistScanner:
track_data['deezer_track_id'] = track['id'] track_data['deezer_track_id'] = track['id']
track_data['deezer_album_id'] = album_data['id'] track_data['deezer_album_id'] = album_data['id']
track_data['deezer_artist_id'] = selected_artist_id track_data['deezer_artist_id'] = selected_artist_id
else: elif selected_source == 'itunes':
track_data['itunes_track_id'] = track['id'] track_data['itunes_track_id'] = track['id']
track_data['itunes_album_id'] = album_data['id'] track_data['itunes_album_id'] = album_data['id']
track_data['itunes_artist_id'] = selected_artist_id track_data['itunes_artist_id'] = selected_artist_id
@ -3351,6 +3378,8 @@ class WatchlistScanner:
selected_watchlist_id = artist.itunes_artist_id or artist_id selected_watchlist_id = artist.itunes_artist_id or artist_id
elif source == 'deezer': elif source == 'deezer':
selected_watchlist_id = getattr(artist, 'deezer_artist_id', None) or artist_id selected_watchlist_id = getattr(artist, 'deezer_artist_id', None) or artist_id
elif source == 'musicbrainz':
selected_watchlist_id = artist_id
break break
if not selected_source or not selected_artist_id or not selected_albums: if not selected_source or not selected_artist_id or not selected_albums:
@ -3384,6 +3413,8 @@ class WatchlistScanner:
cache_callback = lambda found_id, similar_id=artist.id: self.database.update_similar_artist_itunes_id(similar_id, found_id) cache_callback = lambda found_id, similar_id=artist.id: self.database.update_similar_artist_itunes_id(similar_id, found_id)
elif source == 'deezer': elif source == 'deezer':
cache_callback = lambda found_id, similar_id=artist.id: self.database.update_similar_artist_deezer_id(similar_id, found_id) cache_callback = lambda found_id, similar_id=artist.id: self.database.update_similar_artist_deezer_id(similar_id, found_id)
elif source == 'musicbrainz':
cache_callback = lambda found_id, similar_id=artist.id: self.database.update_similar_artist_musicbrainz_id(similar_id, found_id)
artist_id = self._resolve_artist_id_for_source( artist_id = self._resolve_artist_id_for_source(
source, source,
@ -3415,6 +3446,8 @@ class WatchlistScanner:
selected_similar_id = artist.similar_artist_itunes_id or artist_id selected_similar_id = artist.similar_artist_itunes_id or artist_id
elif source == 'deezer': elif source == 'deezer':
selected_similar_id = getattr(artist, 'similar_artist_deezer_id', None) or artist_id selected_similar_id = getattr(artist, 'similar_artist_deezer_id', None) or artist_id
elif source == 'musicbrainz':
selected_similar_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist_id
break break
if not selected_source or not selected_artist_id or not selected_albums: if not selected_source or not selected_artist_id or not selected_albums:

View file

@ -91,6 +91,7 @@ class WatchlistArtist:
itunes_artist_id: Optional[str] = None # Cross-provider support itunes_artist_id: Optional[str] = None # Cross-provider support
deezer_artist_id: Optional[str] = None # Cross-provider support deezer_artist_id: Optional[str] = None # Cross-provider support
discogs_artist_id: Optional[str] = None # Cross-provider support discogs_artist_id: Optional[str] = None # Cross-provider support
musicbrainz_artist_id: Optional[str] = None # Cross-provider support
include_albums: bool = True include_albums: bool = True
include_eps: bool = True include_eps: bool = True
include_singles: bool = True include_singles: bool = True
@ -118,6 +119,7 @@ class SimilarArtist:
genres: Optional[List[str]] = None # Cached genres genres: Optional[List[str]] = None # Cached genres
popularity: int = 0 # Cached popularity score popularity: int = 0 # Cached popularity score
similar_artist_deezer_id: Optional[str] = None # Deezer artist ID similar_artist_deezer_id: Optional[str] = None # Deezer artist ID
similar_artist_musicbrainz_id: Optional[str] = None # MusicBrainz artist ID
@dataclass @dataclass
class DiscoveryTrack: class DiscoveryTrack:
@ -334,6 +336,7 @@ class MusicDatabase:
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_artist_id TEXT, deezer_artist_id TEXT,
discogs_artist_id TEXT, discogs_artist_id TEXT,
musicbrainz_artist_id TEXT,
artist_name TEXT NOT NULL, artist_name TEXT NOT NULL,
date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP, date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_scan_timestamp TIMESTAMP, last_scan_timestamp TIMESTAMP,
@ -1174,6 +1177,10 @@ class MusicDatabase:
cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_deezer_id TEXT") cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_deezer_id TEXT")
logger.info("Added similar_artist_deezer_id column to similar_artists table") logger.info("Added similar_artist_deezer_id column to similar_artists table")
if 'similar_artist_musicbrainz_id' not in similar_artists_columns:
cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_musicbrainz_id TEXT")
logger.info("Added similar_artist_musicbrainz_id column to similar_artists table")
# Migration: Add iTunes columns to recent_releases for dual-source discovery # Migration: Add iTunes columns to recent_releases for dual-source discovery
cursor.execute("PRAGMA table_info(recent_releases)") cursor.execute("PRAGMA table_info(recent_releases)")
recent_releases_columns = [column[1] for column in cursor.fetchall()] recent_releases_columns = [column[1] for column in cursor.fetchall()]
@ -1288,6 +1295,8 @@ class MusicDatabase:
source_artist_id TEXT NOT NULL, source_artist_id TEXT NOT NULL,
similar_artist_spotify_id TEXT, similar_artist_spotify_id TEXT,
similar_artist_itunes_id TEXT, similar_artist_itunes_id TEXT,
similar_artist_deezer_id TEXT,
similar_artist_musicbrainz_id TEXT,
similar_artist_name TEXT NOT NULL, similar_artist_name TEXT NOT NULL,
similarity_rank INTEGER DEFAULT 1, similarity_rank INTEGER DEFAULT 1,
occurrence_count INTEGER DEFAULT 1, occurrence_count INTEGER DEFAULT 1,
@ -1298,8 +1307,10 @@ class MusicDatabase:
migration_cursor.execute(""" migration_cursor.execute("""
INSERT OR IGNORE INTO similar_artists_new INSERT OR IGNORE INTO similar_artists_new
(source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_deezer_id, similar_artist_musicbrainz_id,
similar_artist_name, similarity_rank, occurrence_count, last_updated) similar_artist_name, similarity_rank, occurrence_count, last_updated)
SELECT source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, SELECT source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_deezer_id, similar_artist_musicbrainz_id,
similar_artist_name, similarity_rank, occurrence_count, last_updated similar_artist_name, similarity_rank, occurrence_count, last_updated
FROM similar_artists FROM similar_artists
""") """)
@ -1312,6 +1323,7 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_source ON similar_artists (source_artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_source ON similar_artists (source_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_spotify ON similar_artists (similar_artist_spotify_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_spotify ON similar_artists (similar_artist_spotify_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_itunes ON similar_artists (similar_artist_itunes_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_itunes ON similar_artists (similar_artist_itunes_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_musicbrainz ON similar_artists (similar_artist_musicbrainz_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_occurrence ON similar_artists (occurrence_count)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_occurrence ON similar_artists (occurrence_count)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_name ON similar_artists (similar_artist_name)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_name ON similar_artists (similar_artist_name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_spotify_track ON discovery_pool (spotify_track_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_spotify_track ON discovery_pool (spotify_track_id)")
@ -1492,6 +1504,7 @@ class MusicDatabase:
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_artist_id TEXT, deezer_artist_id TEXT,
discogs_artist_id TEXT, discogs_artist_id TEXT,
musicbrainz_artist_id TEXT,
image_url TEXT, image_url TEXT,
genres TEXT, genres TEXT,
source_services TEXT DEFAULT '[]', source_services TEXT DEFAULT '[]',
@ -1508,6 +1521,10 @@ class MusicDatabase:
""") """)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)")
cursor.execute("PRAGMA table_info(liked_artists_pool)")
liked_artist_columns = {column[1] for column in cursor.fetchall()}
if 'musicbrainz_artist_id' not in liked_artist_columns:
cursor.execute("ALTER TABLE liked_artists_pool ADD COLUMN musicbrainz_artist_id TEXT")
# Liked albums pool — aggregated saved/liked albums from connected services # Liked albums pool — aggregated saved/liked albums from connected services
cursor.execute(""" cursor.execute("""
@ -1643,6 +1660,10 @@ class MusicDatabase:
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN amazon_artist_id TEXT") cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN amazon_artist_id TEXT")
logger.info("Added amazon_artist_id column to watchlist_artists table for Amazon Music support") logger.info("Added amazon_artist_id column to watchlist_artists table for Amazon Music support")
if 'musicbrainz_artist_id' not in columns:
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN musicbrainz_artist_id TEXT")
logger.info("Added musicbrainz_artist_id column to watchlist_artists table for MusicBrainz support")
except Exception as e: except Exception as e:
logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}") logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}")
# Don't raise - this is a migration, database can still function # Don't raise - this is a migration, database can still function
@ -1732,6 +1753,7 @@ class MusicDatabase:
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_artist_id TEXT, deezer_artist_id TEXT,
discogs_artist_id TEXT, discogs_artist_id TEXT,
musicbrainz_artist_id TEXT,
profile_id INTEGER DEFAULT 1, profile_id INTEGER DEFAULT 1,
UNIQUE(profile_id, spotify_artist_id), UNIQUE(profile_id, spotify_artist_id),
UNIQUE(profile_id, itunes_artist_id) UNIQUE(profile_id, itunes_artist_id)
@ -1759,7 +1781,8 @@ class MusicDatabase:
lookback_days INTEGER DEFAULT NULL, lookback_days INTEGER DEFAULT NULL,
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_artist_id TEXT, deezer_artist_id TEXT,
discogs_artist_id TEXT discogs_artist_id TEXT,
musicbrainz_artist_id TEXT
) )
""") """)
@ -1771,7 +1794,8 @@ class MusicDatabase:
'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_albums', 'include_eps', 'include_singles', 'include_live',
'include_remixes', 'include_acoustic', 'include_compilations', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days', 'include_instrumentals', 'lookback_days',
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id'] 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id',
'musicbrainz_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols] shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_cols) cols_str = ', '.join(shared_cols)
cursor.execute(f"INSERT INTO watchlist_artists_new ({cols_str}) SELECT {cols_str} FROM watchlist_artists") cursor.execute(f"INSERT INTO watchlist_artists_new ({cols_str}) SELECT {cols_str} FROM watchlist_artists")
@ -2614,6 +2638,7 @@ class MusicDatabase:
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_artist_id TEXT, deezer_artist_id TEXT,
discogs_artist_id TEXT, discogs_artist_id TEXT,
musicbrainz_artist_id TEXT,
profile_id INTEGER DEFAULT 1, profile_id INTEGER DEFAULT 1,
UNIQUE(profile_id, spotify_artist_id), UNIQUE(profile_id, spotify_artist_id),
UNIQUE(profile_id, itunes_artist_id) UNIQUE(profile_id, itunes_artist_id)
@ -2626,7 +2651,8 @@ class MusicDatabase:
'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_albums', 'include_eps', 'include_singles', 'include_live',
'include_remixes', 'include_acoustic', 'include_compilations', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days', 'include_instrumentals', 'lookback_days',
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id'] 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id',
'musicbrainz_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in col_names] shared_cols = [c for c in new_cols if c in col_names]
cols_str = ', '.join(shared_cols) cols_str = ', '.join(shared_cols)
@ -2958,6 +2984,7 @@ class MusicDatabase:
similar_artist_spotify_id TEXT, similar_artist_spotify_id TEXT,
similar_artist_itunes_id TEXT, similar_artist_itunes_id TEXT,
similar_artist_deezer_id TEXT, similar_artist_deezer_id TEXT,
similar_artist_musicbrainz_id TEXT,
similar_artist_name TEXT NOT NULL, similar_artist_name TEXT NOT NULL,
similarity_rank INTEGER DEFAULT 1, similarity_rank INTEGER DEFAULT 1,
occurrence_count INTEGER DEFAULT 1, occurrence_count INTEGER DEFAULT 1,
@ -2974,7 +3001,8 @@ class MusicDatabase:
new_cols = ['id', 'source_artist_id', 'similar_artist_spotify_id', new_cols = ['id', 'source_artist_id', 'similar_artist_spotify_id',
'similar_artist_itunes_id', 'similar_artist_deezer_id', 'similar_artist_itunes_id', 'similar_artist_deezer_id',
'similar_artist_name', 'similarity_rank', 'occurrence_count', 'similar_artist_musicbrainz_id', 'similar_artist_name',
'similarity_rank', 'occurrence_count',
'last_updated', 'image_url', 'genres', 'popularity', 'last_updated', 'image_url', 'genres', 'popularity',
'metadata_updated_at', 'last_featured', 'profile_id'] 'metadata_updated_at', 'last_featured', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols] shared_cols = [c for c in new_cols if c in old_cols]
@ -7735,7 +7763,8 @@ class MusicDatabase:
# Check if artist already exists by name (case-insensitive) for this profile # Check if artist already exists by name (case-insensitive) for this profile
cursor.execute(""" cursor.execute("""
SELECT id, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id SELECT id, spotify_artist_id, itunes_artist_id, deezer_artist_id,
discogs_artist_id, musicbrainz_artist_id
FROM watchlist_artists FROM watchlist_artists
WHERE LOWER(artist_name) = LOWER(?) AND profile_id = ? WHERE LOWER(artist_name) = LOWER(?) AND profile_id = ?
LIMIT 1 LIMIT 1
@ -7748,7 +7777,13 @@ class MusicDatabase:
if existing: if existing:
# Artist already on watchlist — update with new source ID if missing # Artist already on watchlist — update with new source ID if missing
col_map = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'} col_map = {
'spotify': 'spotify_artist_id',
'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id',
'discogs': 'discogs_artist_id',
'musicbrainz': 'musicbrainz_artist_id',
}
col = col_map.get(source) col = col_map.get(source)
if col and not existing[col]: if col and not existing[col]:
cursor.execute(f""" cursor.execute(f"""
@ -7784,6 +7819,13 @@ class MusicDatabase:
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", (artist_id, artist_name, profile_id)) """, (artist_id, artist_name, profile_id))
logger.info(f"Added artist '{artist_name}' to watchlist (Discogs ID: {artist_id}, profile: {profile_id})") logger.info(f"Added artist '{artist_name}' to watchlist (Discogs ID: {artist_id}, profile: {profile_id})")
elif source == 'musicbrainz':
cursor.execute("""
INSERT INTO watchlist_artists
(musicbrainz_artist_id, artist_name, date_added, updated_at, profile_id)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", (artist_id, artist_name, profile_id))
logger.info(f"Added artist '{artist_name}' to watchlist (MusicBrainz ID: {artist_id}, profile: {profile_id})")
else: else:
cursor.execute(""" cursor.execute("""
INSERT INTO watchlist_artists INSERT INTO watchlist_artists
@ -7800,7 +7842,7 @@ class MusicDatabase:
return False return False
def remove_artist_from_watchlist(self, artist_id: str, profile_id: int = 1) -> bool: def remove_artist_from_watchlist(self, artist_id: str, profile_id: int = 1) -> bool:
"""Remove an artist from the watchlist (checks Spotify, iTunes, Deezer, and Discogs IDs)""" """Remove an artist from the watchlist (checks cross-provider artist IDs)"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -7808,15 +7850,17 @@ class MusicDatabase:
# Get artist name for logging (check all ID columns) # Get artist name for logging (check all ID columns)
cursor.execute(""" cursor.execute("""
SELECT artist_name FROM watchlist_artists SELECT artist_name FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?) AND profile_id = ? WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id, profile_id)) OR discogs_artist_id = ? OR musicbrainz_artist_id = ?) AND profile_id = ?
""", (artist_id, artist_id, artist_id, artist_id, artist_id, profile_id))
result = cursor.fetchone() result = cursor.fetchone()
artist_name = result['artist_name'] if result else "Unknown" artist_name = result['artist_name'] if result else "Unknown"
cursor.execute(""" cursor.execute("""
DELETE FROM watchlist_artists DELETE FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?) AND profile_id = ? WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id, profile_id)) OR discogs_artist_id = ? OR musicbrainz_artist_id = ?) AND profile_id = ?
""", (artist_id, artist_id, artist_id, artist_id, artist_id, profile_id))
if cursor.rowcount > 0: if cursor.rowcount > 0:
conn.commit() conn.commit()
@ -7831,7 +7875,7 @@ class MusicDatabase:
return False return False
def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1, artist_name: str = None) -> bool: def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1, artist_name: str = None) -> bool:
"""Check if an artist is currently in the watchlist (checks Spotify, iTunes, Deezer, Discogs IDs and name)""" """Check if an artist is currently in the watchlist (checks cross-provider IDs and name)"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -7840,15 +7884,18 @@ class MusicDatabase:
if artist_name: if artist_name:
cursor.execute(""" cursor.execute("""
SELECT 1 FROM watchlist_artists SELECT 1 FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR LOWER(artist_name) = LOWER(?)) AND profile_id = ? WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
OR discogs_artist_id = ? OR musicbrainz_artist_id = ?
OR LOWER(artist_name) = LOWER(?)) AND profile_id = ?
LIMIT 1 LIMIT 1
""", (artist_id, artist_id, artist_id, artist_id, artist_name, profile_id)) """, (artist_id, artist_id, artist_id, artist_id, artist_id, artist_name, profile_id))
else: else:
cursor.execute(""" cursor.execute("""
SELECT 1 FROM watchlist_artists SELECT 1 FROM watchlist_artists
WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?) AND profile_id = ? WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
OR discogs_artist_id = ? OR musicbrainz_artist_id = ?) AND profile_id = ?
LIMIT 1 LIMIT 1
""", (artist_id, artist_id, artist_id, artist_id, profile_id)) """, (artist_id, artist_id, artist_id, artist_id, artist_id, profile_id))
result = cursor.fetchone() result = cursor.fetchone()
return result is not None return result is not None
@ -7870,7 +7917,7 @@ class MusicDatabase:
# Build SELECT query based on existing columns # Build SELECT query based on existing columns
base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added', base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
'last_scan_timestamp', 'created_at', 'updated_at'] 'last_scan_timestamp', 'created_at', 'updated_at']
optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'include_albums', 'include_eps', 'include_singles', optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'musicbrainz_artist_id', 'include_albums', 'include_eps', 'include_singles',
'include_live', 'include_remixes', 'include_acoustic', 'include_compilations', 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days', 'preferred_metadata_source'] 'include_instrumentals', 'lookback_days', 'preferred_metadata_source']
@ -7899,6 +7946,7 @@ class MusicDatabase:
itunes_artist_id = row['itunes_artist_id'] if 'itunes_artist_id' in existing_columns else None itunes_artist_id = row['itunes_artist_id'] if 'itunes_artist_id' in existing_columns else None
deezer_artist_id = row['deezer_artist_id'] if 'deezer_artist_id' in existing_columns else None deezer_artist_id = row['deezer_artist_id'] if 'deezer_artist_id' in existing_columns else None
discogs_artist_id = row['discogs_artist_id'] if 'discogs_artist_id' in existing_columns else None discogs_artist_id = row['discogs_artist_id'] if 'discogs_artist_id' in existing_columns else None
musicbrainz_artist_id = row['musicbrainz_artist_id'] if 'musicbrainz_artist_id' in existing_columns else None
include_albums = bool(row['include_albums']) if 'include_albums' in existing_columns else True include_albums = bool(row['include_albums']) if 'include_albums' in existing_columns else True
include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True
include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True
@ -7922,6 +7970,7 @@ class MusicDatabase:
itunes_artist_id=itunes_artist_id, itunes_artist_id=itunes_artist_id,
deezer_artist_id=deezer_artist_id, deezer_artist_id=deezer_artist_id,
discogs_artist_id=discogs_artist_id, discogs_artist_id=discogs_artist_id,
musicbrainz_artist_id=musicbrainz_artist_id,
include_albums=include_albums, include_albums=include_albums,
include_eps=include_eps, include_eps=include_eps,
include_singles=include_singles, include_singles=include_singles,
@ -8121,8 +8170,9 @@ class MusicDatabase:
cursor.execute(""" cursor.execute("""
UPDATE watchlist_artists UPDATE watchlist_artists
SET image_url = ?, updated_at = CURRENT_TIMESTAMP SET image_url = ?, updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (image_url, artist_id, artist_id, artist_id, artist_id)) OR discogs_artist_id = ? OR musicbrainz_artist_id = ?
""", (image_url, artist_id, artist_id, artist_id, artist_id, artist_id))
conn.commit() conn.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
@ -8208,6 +8258,107 @@ class MusicDatabase:
logger.error(f"Error updating watchlist Discogs ID: {e}") logger.error(f"Error updating watchlist Discogs ID: {e}")
return False return False
def update_watchlist_musicbrainz_id(self, watchlist_id: int, musicbrainz_id: str) -> bool:
"""Update the MusicBrainz artist ID for a watchlist artist (cross-provider support)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE watchlist_artists
SET musicbrainz_artist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (musicbrainz_id, watchlist_id))
conn.commit()
logger.info(f"Updated MusicBrainz ID for watchlist artist {watchlist_id}: {musicbrainz_id}")
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating watchlist MusicBrainz ID: {e}")
return False
def backfill_watchlist_musicbrainz_ids_from_library(self, profile_id: int = 1) -> int:
"""Copy existing library MusicBrainz artist IDs onto matching watchlist rows.
The MusicBrainz enrichment worker writes IDs to ``artists.musicbrainz_id``.
Watchlist UI reads ``watchlist_artists.musicbrainz_artist_id``, so this
bridge lets existing enriched library matches show up as watchlist
MusicBrainz matches without waiting for a separate watchlist scan.
"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE watchlist_artists
SET musicbrainz_artist_id = (
SELECT a.musicbrainz_id
FROM artists a
WHERE a.musicbrainz_id IS NOT NULL
AND a.musicbrainz_id != ''
AND (
LOWER(a.name) = LOWER(watchlist_artists.artist_name)
OR (
watchlist_artists.spotify_artist_id IS NOT NULL
AND watchlist_artists.spotify_artist_id != ''
AND a.spotify_artist_id = watchlist_artists.spotify_artist_id
)
OR (
watchlist_artists.itunes_artist_id IS NOT NULL
AND watchlist_artists.itunes_artist_id != ''
AND a.itunes_artist_id = watchlist_artists.itunes_artist_id
)
OR (
watchlist_artists.deezer_artist_id IS NOT NULL
AND watchlist_artists.deezer_artist_id != ''
AND a.deezer_id = watchlist_artists.deezer_artist_id
)
OR (
watchlist_artists.discogs_artist_id IS NOT NULL
AND watchlist_artists.discogs_artist_id != ''
AND a.discogs_id = watchlist_artists.discogs_artist_id
)
)
LIMIT 1
),
updated_at = CURRENT_TIMESTAMP
WHERE profile_id = ?
AND (musicbrainz_artist_id IS NULL OR musicbrainz_artist_id = '')
AND EXISTS (
SELECT 1
FROM artists a
WHERE a.musicbrainz_id IS NOT NULL
AND a.musicbrainz_id != ''
AND (
LOWER(a.name) = LOWER(watchlist_artists.artist_name)
OR (
watchlist_artists.spotify_artist_id IS NOT NULL
AND watchlist_artists.spotify_artist_id != ''
AND a.spotify_artist_id = watchlist_artists.spotify_artist_id
)
OR (
watchlist_artists.itunes_artist_id IS NOT NULL
AND watchlist_artists.itunes_artist_id != ''
AND a.itunes_artist_id = watchlist_artists.itunes_artist_id
)
OR (
watchlist_artists.deezer_artist_id IS NOT NULL
AND watchlist_artists.deezer_artist_id != ''
AND a.deezer_id = watchlist_artists.deezer_artist_id
)
OR (
watchlist_artists.discogs_artist_id IS NOT NULL
AND watchlist_artists.discogs_artist_id != ''
AND a.discogs_id = watchlist_artists.discogs_artist_id
)
)
)
""", (profile_id,))
conn.commit()
if cursor.rowcount:
logger.info("Backfilled %s watchlist MusicBrainz artist IDs from library", cursor.rowcount)
return cursor.rowcount
except Exception as e:
logger.error(f"Error backfilling watchlist MusicBrainz IDs from library: {e}")
return 0
def update_watchlist_artist_itunes_id(self, spotify_artist_id: str, itunes_id: str) -> bool: def update_watchlist_artist_itunes_id(self, spotify_artist_id: str, itunes_id: str) -> bool:
"""Update the iTunes artist ID for a watchlist artist by Spotify ID (for cross-provider caching)""" """Update the iTunes artist ID for a watchlist artist by Spotify ID (for cross-provider caching)"""
try: try:
@ -8260,25 +8411,27 @@ class MusicDatabase:
image_url: Optional[str] = None, image_url: Optional[str] = None,
genres: Optional[list] = None, genres: Optional[list] = None,
popularity: int = 0, popularity: int = 0,
similar_artist_deezer_id: Optional[str] = None) -> bool: similar_artist_deezer_id: Optional[str] = None,
"""Add or update a similar artist recommendation (supports Spotify, iTunes, and Deezer IDs)""" similar_artist_musicbrainz_id: Optional[str] = None) -> bool:
"""Add or update a similar artist recommendation."""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
genres_json = json.dumps(genres) if genres else None genres_json = json.dumps(genres) if genres else None
# Use artist name as the unique key (allows storing both IDs for same artist)
cursor.execute(""" cursor.execute("""
INSERT INTO similar_artists INSERT INTO similar_artists
(source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_name, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_deezer_id, similar_artist_musicbrainz_id, similar_artist_name,
similarity_rank, occurrence_count, last_updated, profile_id, similarity_rank, occurrence_count, last_updated, profile_id,
image_url, genres, popularity, metadata_updated_at) image_url, genres, popularity, metadata_updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(profile_id, source_artist_id, similar_artist_name) ON CONFLICT(profile_id, source_artist_id, similar_artist_name)
DO UPDATE SET DO UPDATE SET
similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id), similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id),
similar_artist_itunes_id = COALESCE(excluded.similar_artist_itunes_id, similar_artist_itunes_id), similar_artist_itunes_id = COALESCE(excluded.similar_artist_itunes_id, similar_artist_itunes_id),
similar_artist_deezer_id = COALESCE(excluded.similar_artist_deezer_id, similar_artist_deezer_id), similar_artist_deezer_id = COALESCE(excluded.similar_artist_deezer_id, similar_artist_deezer_id),
similar_artist_musicbrainz_id = COALESCE(excluded.similar_artist_musicbrainz_id, similar_artist_musicbrainz_id),
similarity_rank = excluded.similarity_rank, similarity_rank = excluded.similarity_rank,
occurrence_count = occurrence_count + 1, occurrence_count = occurrence_count + 1,
last_updated = CURRENT_TIMESTAMP, last_updated = CURRENT_TIMESTAMP,
@ -8286,7 +8439,8 @@ class MusicDatabase:
genres = COALESCE(excluded.genres, genres), genres = COALESCE(excluded.genres, genres),
popularity = CASE WHEN excluded.popularity > 0 THEN excluded.popularity ELSE popularity END, popularity = CASE WHEN excluded.popularity > 0 THEN excluded.popularity ELSE popularity END,
metadata_updated_at = CASE WHEN excluded.image_url IS NOT NULL THEN CURRENT_TIMESTAMP ELSE metadata_updated_at END metadata_updated_at = CASE WHEN excluded.image_url IS NOT NULL THEN CURRENT_TIMESTAMP ELSE metadata_updated_at END
""", (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_name, """, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_deezer_id, similar_artist_musicbrainz_id, similar_artist_name,
similarity_rank, profile_id, image_url, genres_json, popularity)) similarity_rank, profile_id, image_url, genres_json, popularity))
conn.commit() conn.commit()
@ -8319,6 +8473,7 @@ class MusicDatabase:
occurrence_count=row['occurrence_count'], occurrence_count=row['occurrence_count'],
last_updated=datetime.fromisoformat(row['last_updated']), last_updated=datetime.fromisoformat(row['last_updated']),
similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None, similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
similar_artist_musicbrainz_id=row['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in row.keys() else None,
) for row in rows] ) for row in rows]
except Exception as e: except Exception as e:
@ -8328,11 +8483,14 @@ class MusicDatabase:
def get_similar_artists_missing_fallback_ids(self, source_artist_id: str, fallback_source: str = 'itunes', profile_id: int = 1) -> List[SimilarArtist]: def get_similar_artists_missing_fallback_ids(self, source_artist_id: str, fallback_source: str = 'itunes', profile_id: int = 1) -> List[SimilarArtist]:
"""Get similar artists missing fallback-provider IDs for backfill.""" """Get similar artists missing fallback-provider IDs for backfill."""
try: try:
if fallback_source not in {'itunes', 'deezer'}: if fallback_source not in {'itunes', 'deezer', 'musicbrainz'}:
logger.error("Unsupported similar-artist fallback source: %s", fallback_source) logger.error("Unsupported similar-artist fallback source: %s", fallback_source)
return [] return []
col = 'similar_artist_deezer_id' if fallback_source == 'deezer' else 'similar_artist_itunes_id' col = {
'deezer': 'similar_artist_deezer_id',
'musicbrainz': 'similar_artist_musicbrainz_id',
}.get(fallback_source, 'similar_artist_itunes_id')
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -8355,6 +8513,7 @@ class MusicDatabase:
occurrence_count=row['occurrence_count'], occurrence_count=row['occurrence_count'],
last_updated=datetime.fromisoformat(row['last_updated']), last_updated=datetime.fromisoformat(row['last_updated']),
similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None, similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
similar_artist_musicbrainz_id=row['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in row.keys() else None,
) for row in rows] ) for row in rows]
except Exception as e: except Exception as e:
@ -8399,6 +8558,25 @@ class MusicDatabase:
logger.error(f"Error updating similar artist Deezer ID: {e}") logger.error(f"Error updating similar artist Deezer ID: {e}")
return False return False
def update_similar_artist_musicbrainz_id(self, similar_artist_id: int, musicbrainz_id: str) -> bool:
"""Update a similar artist's MusicBrainz ID (for backfill)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE similar_artists
SET similar_artist_musicbrainz_id = ?
WHERE id = ?
""", (musicbrainz_id, similar_artist_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating similar artist MusicBrainz ID: {e}")
return False
def update_similar_artist_metadata(self, similar_artist_id: int, image_url: str = None, def update_similar_artist_metadata(self, similar_artist_id: int, image_url: str = None,
genres: list = None, popularity: int = None) -> bool: genres: list = None, popularity: int = None) -> bool:
"""Cache artist metadata (image, genres, popularity) to avoid repeated API calls""" """Cache artist metadata (image, genres, popularity) to avoid repeated API calls"""
@ -8420,7 +8598,7 @@ class MusicDatabase:
def update_similar_artist_metadata_by_external_id(self, external_id: str, source: str = 'spotify', def update_similar_artist_metadata_by_external_id(self, external_id: str, source: str = 'spotify',
image_url: str = None, genres: list = None, image_url: str = None, genres: list = None,
popularity: int = None) -> bool: popularity: int = None) -> bool:
"""Cache artist metadata by Spotify or iTunes ID (updates all rows for that artist)""" """Cache artist metadata by external source ID (updates all rows for that artist)."""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -8429,6 +8607,8 @@ class MusicDatabase:
where_clause = "similar_artist_spotify_id = ?" where_clause = "similar_artist_spotify_id = ?"
elif source == 'deezer': elif source == 'deezer':
where_clause = "similar_artist_deezer_id = ?" where_clause = "similar_artist_deezer_id = ?"
elif source == 'musicbrainz':
where_clause = "similar_artist_musicbrainz_id = ?"
else: else:
where_clause = "similar_artist_itunes_id = ?" where_clause = "similar_artist_itunes_id = ?"
cursor.execute(f""" cursor.execute(f"""
@ -8490,7 +8670,7 @@ class MusicDatabase:
exclude_library_server: str = None, exclude_library_server: str = None,
) -> List[SimilarArtist]: ) -> List[SimilarArtist]:
"""Get top similar artists excluding watchlist artists, with cycling support. """Get top similar artists excluding watchlist artists, with cycling support.
require_source: if set ('spotify','itunes','deezer'), only returns artists with that source ID. require_source: if set, only returns artists with that source ID.
exclude_library_server: if set, also excludes artists already present in that media server.""" exclude_library_server: if set, also excludes artists already present in that media server."""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
@ -8504,12 +8684,14 @@ class MusicDatabase:
source_filter = "AND sa.similar_artist_itunes_id IS NOT NULL AND sa.similar_artist_itunes_id != ''" source_filter = "AND sa.similar_artist_itunes_id IS NOT NULL AND sa.similar_artist_itunes_id != ''"
elif require_source == 'deezer': elif require_source == 'deezer':
source_filter = "AND sa.similar_artist_deezer_id IS NOT NULL AND sa.similar_artist_deezer_id != ''" source_filter = "AND sa.similar_artist_deezer_id IS NOT NULL AND sa.similar_artist_deezer_id != ''"
elif require_source == 'musicbrainz':
source_filter = "AND sa.similar_artist_musicbrainz_id IS NOT NULL AND sa.similar_artist_musicbrainz_id != ''"
library_artist_keys = None library_artist_keys = None
sql_limit = limit sql_limit = limit
if exclude_library_server: if exclude_library_server:
cursor.execute(""" cursor.execute("""
SELECT name, spotify_artist_id, itunes_artist_id, deezer_id SELECT name, spotify_artist_id, itunes_artist_id, deezer_id, musicbrainz_id
FROM artists FROM artists
WHERE server_source = ? WHERE server_source = ?
""", (exclude_library_server,)) """, (exclude_library_server,))
@ -8518,6 +8700,7 @@ class MusicDatabase:
'spotify': {r['spotify_artist_id'] for r in library_rows if r['spotify_artist_id']}, 'spotify': {r['spotify_artist_id'] for r in library_rows if r['spotify_artist_id']},
'itunes': {r['itunes_artist_id'] for r in library_rows if r['itunes_artist_id']}, 'itunes': {r['itunes_artist_id'] for r in library_rows if r['itunes_artist_id']},
'deezer': {r['deezer_id'] for r in library_rows if r['deezer_id']}, 'deezer': {r['deezer_id'] for r in library_rows if r['deezer_id']},
'musicbrainz': {r['musicbrainz_id'] for r in library_rows if r['musicbrainz_id']},
'names': { 'names': {
self._normalize_for_comparison(r['name']) self._normalize_for_comparison(r['name'])
for r in library_rows for r in library_rows
@ -8533,6 +8716,7 @@ class MusicDatabase:
MAX(sa.similar_artist_spotify_id) as similar_artist_spotify_id, MAX(sa.similar_artist_spotify_id) as similar_artist_spotify_id,
MAX(sa.similar_artist_itunes_id) as similar_artist_itunes_id, MAX(sa.similar_artist_itunes_id) as similar_artist_itunes_id,
MAX(sa.similar_artist_deezer_id) as similar_artist_deezer_id, MAX(sa.similar_artist_deezer_id) as similar_artist_deezer_id,
MAX(sa.similar_artist_musicbrainz_id) as similar_artist_musicbrainz_id,
sa.similar_artist_name, sa.similar_artist_name,
AVG(sa.similarity_rank) as similarity_rank, AVG(sa.similarity_rank) as similarity_rank,
SUM(sa.occurrence_count) as occurrence_count, SUM(sa.occurrence_count) as occurrence_count,
@ -8564,11 +8748,13 @@ class MusicDatabase:
spotify_id = row['similar_artist_spotify_id'] spotify_id = row['similar_artist_spotify_id']
itunes_id = row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None itunes_id = row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None
deezer_id = row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None deezer_id = row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None
musicbrainz_id = row['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in row.keys() else None
normalized_name = self._normalize_for_comparison(row['similar_artist_name']) normalized_name = self._normalize_for_comparison(row['similar_artist_name'])
if ( if (
(spotify_id and spotify_id in library_artist_keys['spotify']) (spotify_id and spotify_id in library_artist_keys['spotify'])
or (itunes_id and itunes_id in library_artist_keys['itunes']) or (itunes_id and itunes_id in library_artist_keys['itunes'])
or (deezer_id and deezer_id in library_artist_keys['deezer']) or (deezer_id and deezer_id in library_artist_keys['deezer'])
or (musicbrainz_id and musicbrainz_id in library_artist_keys['musicbrainz'])
or (normalized_name and normalized_name in library_artist_keys['names']) or (normalized_name and normalized_name in library_artist_keys['names'])
): ):
continue continue
@ -8584,6 +8770,7 @@ class MusicDatabase:
similar_artist_spotify_id=row['similar_artist_spotify_id'], similar_artist_spotify_id=row['similar_artist_spotify_id'],
similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None, similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None,
similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None, similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
similar_artist_musicbrainz_id=row['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in row.keys() else None,
similar_artist_name=row['similar_artist_name'], similar_artist_name=row['similar_artist_name'],
similarity_rank=int(row['similarity_rank']), similarity_rank=int(row['similarity_rank']),
occurrence_count=row['occurrence_count'], occurrence_count=row['occurrence_count'],
@ -10253,7 +10440,7 @@ class MusicDatabase:
# Store all discovered source IDs (COALESCE preserves existing values) # Store all discovered source IDs (COALESCE preserves existing values)
if all_ids: if all_ids:
for col in ('spotify_artist_id', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id'): for col in ('spotify_artist_id', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'musicbrainz_artist_id'):
val = all_ids.get(col) val = all_ids.get(col)
if val: if val:
set_parts.append(f"{col} = COALESCE({col}, ?)") set_parts.append(f"{col} = COALESCE({col}, ?)")

View file

@ -19,6 +19,12 @@ def test_top_similar_artists_can_exclude_active_server_library_artists(tmp_path)
similar_artist_deezer_id="dz-owned", similar_artist_deezer_id="dz-owned",
profile_id=1, profile_id=1,
) )
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Owned By MusicBrainz ID",
similar_artist_musicbrainz_id="mb-owned",
profile_id=1,
)
db.add_or_update_similar_artist( db.add_or_update_similar_artist(
source_artist_id="seed-1", source_artist_id="seed-1",
similar_artist_name="Owned By Name", similar_artist_name="Owned By Name",
@ -41,14 +47,15 @@ def test_top_similar_artists_can_exclude_active_server_library_artists(tmp_path)
with db._get_connection() as conn: with db._get_connection() as conn:
conn.executemany( conn.executemany(
""" """
INSERT INTO artists (name, server_source, spotify_artist_id, deezer_id) INSERT INTO artists (name, server_source, spotify_artist_id, deezer_id, musicbrainz_id)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", """,
[ [
("Library Alias", "navidrome", "sp-owned", None), ("Library Alias", "navidrome", "sp-owned", None, None),
("Library Deezer Alias", "navidrome", None, "dz-owned"), ("Library Deezer Alias", "navidrome", None, "dz-owned", None),
("owned by name", "navidrome", None, None), ("Library MusicBrainz Alias", "navidrome", None, None, "mb-owned"),
("Different Server Artist", "plex", "sp-other-server", None), ("owned by name", "navidrome", None, None, None),
("Different Server Artist", "plex", "sp-other-server", None, None),
], ],
) )
conn.commit() conn.commit()
@ -62,6 +69,27 @@ def test_top_similar_artists_can_exclude_active_server_library_artists(tmp_path)
assert _names(artists) == {"Different Server Artist", "Fresh Artist"} assert _names(artists) == {"Different Server Artist", "Fresh Artist"}
def test_top_similar_artists_can_require_musicbrainz_source(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="MB Artist",
similar_artist_musicbrainz_id="mb-artist",
profile_id=1,
)
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Spotify Only",
similar_artist_spotify_id="sp-artist",
profile_id=1,
)
artists = db.get_top_similar_artists(limit=20, profile_id=1, require_source="musicbrainz")
assert _names(artists) == {"MB Artist"}
assert artists[0].similar_artist_musicbrainz_id == "mb-artist"
def test_top_similar_artists_keeps_existing_behavior_without_library_filter(tmp_path): def test_top_similar_artists_keeps_existing_behavior_without_library_filter(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db")) db = MusicDatabase(str(tmp_path / "music.db"))
db.add_or_update_similar_artist( db.add_or_update_similar_artist(

View file

@ -20,6 +20,16 @@ def test_metadata_source_label_maps_known_sources():
assert registry.get_metadata_source_label("deezer") == "Deezer" assert registry.get_metadata_source_label("deezer") == "Deezer"
assert registry.get_metadata_source_label("discogs") == "Discogs" assert registry.get_metadata_source_label("discogs") == "Discogs"
assert registry.get_metadata_source_label("hydrabase") == "Hydrabase" assert registry.get_metadata_source_label("hydrabase") == "Hydrabase"
assert registry.get_metadata_source_label("musicbrainz") == "MusicBrainz"
def test_musicbrainz_is_first_class_metadata_client():
registry.clear_cached_metadata_clients()
client = object()
assert registry.get_client_for_source(
"musicbrainz",
musicbrainz_client_factory=lambda: client,
) is client
def test_metadata_source_label_falls_back_to_unmapped(): def test_metadata_source_label_falls_back_to_unmapped():

View file

@ -342,6 +342,31 @@ def test_album_from_musicbrainz_dict_release_group_type_overrides_default():
assert Album.from_musicbrainz_dict(raw).album_type == 'single' assert Album.from_musicbrainz_dict(raw).album_type == 'single'
def test_album_from_musicbrainz_dict_accepts_adapter_shape():
raw = {
'id': 'rg-or-release-mbid',
'name': 'Coffee Break',
'artists': [{'id': 'artist-mbid', 'name': 'Zeds Dead'}],
'release_date': '2011-07-12',
'total_tracks': 1,
'album_type': 'single',
'images': [{'url': 'https://cover.example/front.jpg'}],
'external_urls': {'musicbrainz': 'https://musicbrainz.org/release/rg-or-release-mbid'},
}
album = Album.from_musicbrainz_dict(raw)
assert album.id == 'rg-or-release-mbid'
assert album.name == 'Coffee Break'
assert album.artists == ['Zeds Dead']
assert album.artist_id == 'artist-mbid'
assert album.release_date == '2011-07-12'
assert album.total_tracks == 1
assert album.album_type == 'single'
assert album.image_url == 'https://cover.example/front.jpg'
assert album.external_ids['musicbrainz'] == 'rg-or-release-mbid'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Qobuz # Qobuz
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -69,6 +69,23 @@ def test_falls_back_to_discogs_as_last_resort() -> None:
assert pick(artist) == ('dg-999', 'discogs') assert pick(artist) == ('dg-999', 'discogs')
def test_falls_back_to_musicbrainz_after_other_sources() -> None:
pick = _make_picker('spotify')
artist = {
'musicbrainz_id': 'mb-999',
}
assert pick(artist) == ('mb-999', 'musicbrainz')
def test_active_source_musicbrainz_picks_musicbrainz_first() -> None:
pick = _make_picker('musicbrainz')
artist = {
'spotify_artist_id': 'sp-123',
'musicbrainz_id': 'mb-999',
}
assert pick(artist) == ('mb-999', 'musicbrainz')
def test_returns_none_when_artist_has_zero_source_ids() -> None: def test_returns_none_when_artist_has_zero_source_ids() -> None:
"""Drop only when the artist has no source IDs at all — that's """Drop only when the artist has no source IDs at all — that's
the only legitimate skip reason now.""" the only legitimate skip reason now."""

View file

@ -0,0 +1,81 @@
from database.music_database import MusicDatabase
def test_watchlist_artist_can_store_musicbrainz_match(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
assert db.add_artist_to_watchlist(
"mb-artist-1",
"MusicBrainz Artist",
profile_id=1,
source="musicbrainz",
)
artists = db.get_watchlist_artists(profile_id=1)
assert len(artists) == 1
assert artists[0].artist_name == "MusicBrainz Artist"
assert artists[0].musicbrainz_artist_id == "mb-artist-1"
assert artists[0].spotify_artist_id is None
def test_watchlist_musicbrainz_match_can_be_added_to_existing_artist(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
assert db.add_artist_to_watchlist("sp-artist-1", "Linked Artist", profile_id=1, source="spotify")
assert db.add_artist_to_watchlist("mb-artist-1", "Linked Artist", profile_id=1, source="musicbrainz")
artists = db.get_watchlist_artists(profile_id=1)
assert len(artists) == 1
assert artists[0].spotify_artist_id == "sp-artist-1"
assert artists[0].musicbrainz_artist_id == "mb-artist-1"
def test_watchlist_musicbrainz_match_supports_presence_and_removal(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
db.add_artist_to_watchlist("sp-artist-1", "Removable Artist", profile_id=1, source="spotify")
artist = db.get_watchlist_artists(profile_id=1)[0]
assert db.update_watchlist_musicbrainz_id(artist.id, "mb-artist-1")
assert db.is_artist_in_watchlist("mb-artist-1", profile_id=1)
assert db.remove_artist_from_watchlist("mb-artist-1", profile_id=1)
assert db.get_watchlist_artists(profile_id=1) == []
def test_watchlist_musicbrainz_match_backfills_from_library_by_name(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
db.add_artist_to_watchlist("sp-artist-1", "Library Matched Artist", profile_id=1, source="spotify")
with db._get_connection() as conn:
conn.execute(
"""
INSERT INTO artists (id, name, musicbrainz_id)
VALUES (?, ?, ?)
""",
("library-artist-1", "Library Matched Artist", "mb-library-1"),
)
conn.commit()
assert db.backfill_watchlist_musicbrainz_ids_from_library(profile_id=1) == 1
artist = db.get_watchlist_artists(profile_id=1)[0]
assert artist.musicbrainz_artist_id == "mb-library-1"
def test_watchlist_musicbrainz_match_backfills_from_library_by_linked_id(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
db.add_artist_to_watchlist("sp-artist-1", "Different Watchlist Name", profile_id=1, source="spotify")
with db._get_connection() as conn:
conn.execute(
"""
INSERT INTO artists (id, name, spotify_artist_id, musicbrainz_id)
VALUES (?, ?, ?, ?)
""",
("library-artist-1", "Canonical Library Name", "sp-artist-1", "mb-library-1"),
)
conn.commit()
assert db.backfill_watchlist_musicbrainz_ids_from_library(profile_id=1) == 1
artist = db.get_watchlist_artists(profile_id=1)[0]
assert artist.musicbrainz_artist_id == "mb-library-1"

View file

@ -63,9 +63,17 @@ def diagnose_itunes_discover():
""") """)
with_both = cursor.fetchone()['count'] with_both = cursor.fetchone()['count']
with_musicbrainz = 0
try:
cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_musicbrainz_id IS NOT NULL")
with_musicbrainz = cursor.fetchone()['count']
except Exception as exc:
logger.debug("similar_artist_musicbrainz_id column is unavailable: %s", exc)
logger.info(f" Total similar artists: {total}") logger.info(f" Total similar artists: {total}")
logger.info(f" With iTunes ID: {with_itunes} ({100 * with_itunes / total:.1f}%)" if total > 0 else " With iTunes ID: 0") logger.info(f" With iTunes ID: {with_itunes} ({100 * with_itunes / total:.1f}%)" if total > 0 else " With iTunes ID: 0")
logger.info(f" With Spotify ID: {with_spotify} ({100 * with_spotify / total:.1f}%)" if total > 0 else " With Spotify ID: 0") logger.info(f" With Spotify ID: {with_spotify} ({100 * with_spotify / total:.1f}%)" if total > 0 else " With Spotify ID: 0")
logger.info(f" With MusicBrainz ID: {with_musicbrainz} ({100 * with_musicbrainz / total:.1f}%)" if total > 0 else " With MusicBrainz ID: 0")
logger.info(f" With BOTH IDs: {with_both} ({100 * with_both / total:.1f}%)" if total > 0 else " With BOTH IDs: 0") logger.info(f" With BOTH IDs: {with_both} ({100 * with_both / total:.1f}%)" if total > 0 else " With BOTH IDs: 0")
if with_itunes == 0 and total > 0: if with_itunes == 0 and total > 0:

View file

@ -18847,8 +18847,12 @@ def get_spotify_album_tracks(album_id):
if not album_data: if not album_data:
return jsonify({"error": "Album not found"}), 404 return jsonify({"error": "Album not found"}), 404
# Extract tracks from album data (Spotify format) # Extract tracks — handle Spotify {items, total} or flat-list formats
tracks = album_data.get('tracks', {}).get('items', []) tracks_container = album_data.get('tracks', {})
if isinstance(tracks_container, list):
tracks = tracks_container
else:
tracks = tracks_container.get('items', [])
# If no tracks in album data (iTunes format), fetch them separately # If no tracks in album data (iTunes format), fetch them separately
if not tracks: if not tracks:
@ -24507,6 +24511,7 @@ def get_watchlist_artists():
"""Get all artists in the watchlist with cached images""" """Get all artists in the watchlist with cached images"""
try: try:
database = get_database() database = get_database()
database.backfill_watchlist_musicbrainz_ids_from_library(profile_id=get_current_profile_id())
watchlist_artists = database.get_watchlist_artists(profile_id=get_current_profile_id()) watchlist_artists = database.get_watchlist_artists(profile_id=get_current_profile_id())
# Convert to JSON serializable format (images are cached from watchlist scans) # Convert to JSON serializable format (images are cached from watchlist scans)
@ -24524,6 +24529,7 @@ def get_watchlist_artists():
"itunes_artist_id": artist.itunes_artist_id, # For iTunes-only artists "itunes_artist_id": artist.itunes_artist_id, # For iTunes-only artists
"deezer_artist_id": getattr(artist, 'deezer_artist_id', None), "deezer_artist_id": getattr(artist, 'deezer_artist_id', None),
"discogs_artist_id": getattr(artist, 'discogs_artist_id', None), "discogs_artist_id": getattr(artist, 'discogs_artist_id', None),
"musicbrainz_artist_id": getattr(artist, 'musicbrainz_artist_id', None),
"amazon_artist_id": getattr(artist, 'amazon_artist_id', None), "amazon_artist_id": getattr(artist, 'amazon_artist_id', None),
"include_albums": artist.include_albums, "include_albums": artist.include_albums,
"include_eps": artist.include_eps, "include_eps": artist.include_eps,
@ -24561,7 +24567,7 @@ def add_to_watchlist():
conn = database._get_connection() conn = database._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT spotify_artist_id, itunes_artist_id, deezer_id, discogs_id SELECT spotify_artist_id, itunes_artist_id, deezer_id, discogs_id, musicbrainz_id
FROM artists WHERE id = ? LIMIT 1 FROM artists WHERE id = ? LIMIT 1
""", (artist_id,)) """, (artist_id,))
row = cursor.fetchone() row = cursor.fetchone()
@ -24572,6 +24578,9 @@ def add_to_watchlist():
if fallback == 'discogs' and row['discogs_id']: if fallback == 'discogs' and row['discogs_id']:
artist_id = row['discogs_id'] artist_id = row['discogs_id']
source = 'discogs' source = 'discogs'
elif fallback == 'musicbrainz' and row['musicbrainz_id']:
artist_id = row['musicbrainz_id']
source = 'musicbrainz'
elif fallback == 'deezer' and row['deezer_id']: elif fallback == 'deezer' and row['deezer_id']:
artist_id = row['deezer_id'] artist_id = row['deezer_id']
source = 'deezer' source = 'deezer'
@ -24587,12 +24596,17 @@ def add_to_watchlist():
elif row['discogs_id']: elif row['discogs_id']:
artist_id = row['discogs_id'] artist_id = row['discogs_id']
source = 'discogs' source = 'discogs'
elif row['musicbrainz_id']:
artist_id = row['musicbrainz_id']
source = 'musicbrainz'
except Exception as e: except Exception as e:
logger.debug("watchlist artist source lookup failed: %s", e) logger.debug("watchlist artist source lookup failed: %s", e)
if not source: if not source:
fallback_source = _get_metadata_fallback_source() fallback_source = _get_metadata_fallback_source()
source = fallback_source if is_numeric_id else 'spotify' source = fallback_source if is_numeric_id else 'spotify'
success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=source) success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=source)
if success:
database.backfill_watchlist_musicbrainz_ids_from_library(profile_id=get_current_profile_id())
if success: if success:
@ -25010,7 +25024,7 @@ def start_watchlist_scan():
# PROACTIVE ID BACKFILLING (cross-provider support) # PROACTIVE ID BACKFILLING (cross-provider support)
# Before scanning, ensure all artists have IDs for ALL available sources # Before scanning, ensure all artists have IDs for ALL available sources
providers_to_backfill = ['itunes', 'deezer'] providers_to_backfill = ['itunes', 'deezer', 'musicbrainz']
if spotify_client and spotify_client.is_spotify_authenticated(): if spotify_client and spotify_client.is_spotify_authenticated():
providers_to_backfill.append('spotify') providers_to_backfill.append('spotify')
try: try:
@ -25316,6 +25330,7 @@ def watchlist_artist_config(artist_id):
database = get_database() database = get_database()
if request.method == 'GET': if request.method == 'GET':
database.backfill_watchlist_musicbrainz_ids_from_library(profile_id=get_current_profile_id())
# Get current config from database # Get current config from database
conn = sqlite3.connect(str(database.database_path)) conn = sqlite3.connect(str(database.database_path))
cursor = conn.cursor() cursor = conn.cursor()
@ -25324,10 +25339,12 @@ def watchlist_artist_config(artist_id):
include_live, include_remixes, include_acoustic, include_compilations, include_live, include_remixes, include_acoustic, include_compilations,
artist_name, image_url, spotify_artist_id, itunes_artist_id, artist_name, image_url, spotify_artist_id, itunes_artist_id,
last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id, last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id,
lookback_days, discogs_artist_id, preferred_metadata_source, amazon_artist_id lookback_days, discogs_artist_id, preferred_metadata_source,
amazon_artist_id, musicbrainz_artist_id
FROM watchlist_artists FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR amazon_artist_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id, artist_id)) OR discogs_artist_id = ? OR amazon_artist_id = ? OR musicbrainz_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id, artist_id, artist_id))
result = cursor.fetchone() result = cursor.fetchone()
conn.close() conn.close()
@ -25341,6 +25358,7 @@ def watchlist_artist_config(artist_id):
deezer_id = result[14] # deezer_artist_id from query deezer_id = result[14] # deezer_artist_id from query
discogs_id = result[16] # discogs_artist_id from query discogs_id = result[16] # discogs_artist_id from query
amazon_id = result[18] if len(result) > 18 else None # amazon_artist_id from query amazon_id = result[18] if len(result) > 18 else None # amazon_artist_id from query
musicbrainz_id = result[19] if len(result) > 19 else None # musicbrainz_artist_id from query
# Get artist info from Spotify (only for Spotify artists) # Get artist info from Spotify (only for Spotify artists)
artist_info = None artist_info = None
@ -25383,9 +25401,16 @@ def watchlist_artist_config(artist_id):
cur2.execute(""" cur2.execute("""
SELECT banner_url, summary, style, mood, label, genres SELECT banner_url, summary, style, mood, label, genres
FROM artists FROM artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_id = ? OR discogs_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_id = ?
OR discogs_id = ? OR musicbrainz_id = ?
LIMIT 1 LIMIT 1
""", (artist_id, artist_id, artist_id, artist_id)) """, (
spotify_id or artist_id,
itunes_id or artist_id,
deezer_id or artist_id,
discogs_id or artist_id,
musicbrainz_id or artist_id,
))
lib_row = cur2.fetchone() lib_row = cur2.fetchone()
if lib_row: if lib_row:
artist_info['banner_url'] = lib_row[0] artist_info['banner_url'] = lib_row[0]
@ -25406,9 +25431,17 @@ def watchlist_artist_config(artist_id):
FROM recent_releases rr FROM recent_releases rr
JOIN watchlist_artists wa ON rr.watchlist_artist_id = wa.id JOIN watchlist_artists wa ON rr.watchlist_artist_id = wa.id
WHERE wa.spotify_artist_id = ? OR wa.itunes_artist_id = ? OR wa.deezer_artist_id = ? WHERE wa.spotify_artist_id = ? OR wa.itunes_artist_id = ? OR wa.deezer_artist_id = ?
OR wa.discogs_artist_id = ? OR wa.amazon_artist_id = ? OR wa.musicbrainz_artist_id = ?
ORDER BY rr.release_date DESC ORDER BY rr.release_date DESC
LIMIT 6 LIMIT 6
""", (artist_id, artist_id, artist_id)) """, (
spotify_id or artist_id,
itunes_id or artist_id,
deezer_id or artist_id,
discogs_id or artist_id,
amazon_id or artist_id,
musicbrainz_id or artist_id,
))
releases = [ releases = [
{ {
'album_name': r[0], 'album_name': r[0],
@ -25449,6 +25482,7 @@ def watchlist_artist_config(artist_id):
"deezer_artist_id": deezer_id, "deezer_artist_id": deezer_id,
"discogs_artist_id": discogs_id, "discogs_artist_id": discogs_id,
"amazon_artist_id": amazon_id, "amazon_artist_id": amazon_id,
"musicbrainz_artist_id": musicbrainz_id,
"watchlist_name": result[7], # Original stored watchlist artist name "watchlist_name": result[7], # Original stored watchlist artist name
"global_metadata_source": get_primary_source(), "global_metadata_source": get_primary_source(),
}) })
@ -25472,7 +25506,7 @@ def watchlist_artist_config(artist_id):
lookback_days = int(lookback_days) if lookback_days != '' else None lookback_days = int(lookback_days) if lookback_days != '' else None
preferred_metadata_source = data.get('preferred_metadata_source', None) preferred_metadata_source = data.get('preferred_metadata_source', None)
# Validate — only accept known sources, empty string means clear override # Validate — only accept known sources, empty string means clear override
if preferred_metadata_source == '' or preferred_metadata_source not in ('spotify', 'deezer', 'itunes', 'discogs'): if preferred_metadata_source == '' or preferred_metadata_source not in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'):
preferred_metadata_source = None preferred_metadata_source = None
# Validate at least one release type is selected # Validate at least one release type is selected
@ -25486,8 +25520,9 @@ def watchlist_artist_config(artist_id):
# Check if lookback_days changed — if so, clear last_scan_timestamp to force rescan # Check if lookback_days changed — if so, clear last_scan_timestamp to force rescan
cursor.execute(""" cursor.execute("""
SELECT lookback_days FROM watchlist_artists SELECT lookback_days FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id)) OR discogs_artist_id = ? OR musicbrainz_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id, artist_id))
old_row = cursor.fetchone() old_row = cursor.fetchone()
old_lookback = old_row[0] if old_row else None old_lookback = old_row[0] if old_row else None
lookback_changed = old_lookback != lookback_days lookback_changed = old_lookback != lookback_days
@ -25499,11 +25534,12 @@ def watchlist_artist_config(artist_id):
include_instrumentals = ?, lookback_days = ?, preferred_metadata_source = ?, include_instrumentals = ?, lookback_days = ?, preferred_metadata_source = ?,
last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END, last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
OR discogs_artist_id = ? OR musicbrainz_artist_id = ?
""", (int(include_albums), int(include_eps), int(include_singles), """, (int(include_albums), int(include_eps), int(include_singles),
int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations), int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations),
int(include_instrumentals), lookback_days, preferred_metadata_source, lookback_changed, int(include_instrumentals), lookback_days, preferred_metadata_source, lookback_changed,
artist_id, artist_id, artist_id, artist_id)) artist_id, artist_id, artist_id, artist_id, artist_id))
conn.commit() conn.commit()
if cursor.rowcount == 0: if cursor.rowcount == 0:
@ -25549,7 +25585,7 @@ def watchlist_artist_link_provider(artist_id):
new_provider_id = data.get('provider_id', '').strip() new_provider_id = data.get('provider_id', '').strip()
provider = data.get('provider', '').strip() provider = data.get('provider', '').strip()
valid_providers = ('spotify', 'itunes', 'deezer', 'discogs', 'amazon') valid_providers = ('spotify', 'itunes', 'deezer', 'discogs', 'amazon', 'musicbrainz')
if provider not in valid_providers: if provider not in valid_providers:
return jsonify({"success": False, "error": f"Invalid provider. Must be one of: {', '.join(valid_providers)}"}), 400 return jsonify({"success": False, "error": f"Invalid provider. Must be one of: {', '.join(valid_providers)}"}), 400
@ -25563,8 +25599,9 @@ def watchlist_artist_link_provider(artist_id):
cursor.execute(""" cursor.execute("""
SELECT id, artist_name, spotify_artist_id, itunes_artist_id SELECT id, artist_name, spotify_artist_id, itunes_artist_id
FROM watchlist_artists FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR amazon_artist_id = ? WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id, artist_id)) OR discogs_artist_id = ? OR amazon_artist_id = ? OR musicbrainz_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id, artist_id, artist_id))
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
@ -25575,7 +25612,14 @@ def watchlist_artist_link_provider(artist_id):
artist_name = row[1] artist_name = row[1]
# Check for duplicate — another watchlist artist already has this provider ID # Check for duplicate — another watchlist artist already has this provider ID
col_map = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id', 'amazon': 'amazon_artist_id'} col_map = {
'spotify': 'spotify_artist_id',
'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id',
'discogs': 'discogs_artist_id',
'amazon': 'amazon_artist_id',
'musicbrainz': 'musicbrainz_artist_id',
}
col = col_map[provider] col = col_map[provider]
if not is_clear: if not is_clear:
@ -25901,6 +25945,8 @@ def get_discover_similar_artists():
artist_id = artist.similar_artist_spotify_id artist_id = artist.similar_artist_spotify_id
elif active_source == 'deezer': elif active_source == 'deezer':
artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
elif active_source == 'musicbrainz':
artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist.similar_artist_itunes_id
else: else:
artist_id = artist.similar_artist_itunes_id artist_id = artist.similar_artist_itunes_id
@ -25908,6 +25954,7 @@ def get_discover_similar_artists():
"artist_id": artist_id, "artist_id": artist_id,
"spotify_artist_id": artist.similar_artist_spotify_id, "spotify_artist_id": artist.similar_artist_spotify_id,
"itunes_artist_id": artist.similar_artist_itunes_id, "itunes_artist_id": artist.similar_artist_itunes_id,
"musicbrainz_artist_id": getattr(artist, 'similar_artist_musicbrainz_id', None),
"artist_name": artist.similar_artist_name, "artist_name": artist.similar_artist_name,
"occurrence_count": artist.occurrence_count, "occurrence_count": artist.occurrence_count,
"similarity_rank": artist.similarity_rank, "similarity_rank": artist.similarity_rank,
@ -25964,6 +26011,8 @@ def enrich_similar_artists():
ext_id = artist.similar_artist_spotify_id ext_id = artist.similar_artist_spotify_id
elif source == 'deezer': elif source == 'deezer':
ext_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id ext_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
elif source == 'musicbrainz':
ext_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist.similar_artist_itunes_id
else: else:
ext_id = artist.similar_artist_itunes_id ext_id = artist.similar_artist_itunes_id
if ext_id and ext_id not in cache_map: if ext_id and ext_id not in cache_map:

View file

@ -3658,10 +3658,11 @@
<option value="itunes">iTunes / Apple Music</option> <option value="itunes">iTunes / Apple Music</option>
<option value="deezer">Deezer</option> <option value="deezer">Deezer</option>
<option value="discogs">Discogs</option> <option value="discogs">Discogs</option>
<option value="musicbrainz">MusicBrainz</option>
</select> </select>
</div> </div>
<div class="callback-info"> <div class="callback-info">
<div class="callback-help">Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token.</div> <div class="callback-help">Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token. MusicBrainz is always available but rate-limited to 1 req/sec.</div>
</div> </div>
</div> </div>

View file

@ -974,8 +974,9 @@ async function initializeWatchlistPage() {
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>'); if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>'); if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>'); if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
if (artist.musicbrainz_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-musicbrainz">MusicBrainz</span>');
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>'); if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.amazon_artist_id; const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.musicbrainz_artist_id || artist.amazon_artist_id;
return ` return `
<div class="watchlist-artist-card" <div class="watchlist-artist-card"
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}" data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}"
@ -1767,8 +1768,9 @@ async function showWatchlistModal() {
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>'); if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>'); if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>'); if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
if (artist.musicbrainz_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-musicbrainz">MusicBrainz</span>');
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>'); if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.amazon_artist_id; const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.musicbrainz_artist_id || artist.amazon_artist_id;
return ` return `
<div class="watchlist-artist-card" <div class="watchlist-artist-card"
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}" data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}"
@ -1924,7 +1926,7 @@ function closeWatchlistModal() {
* Populate the linked provider section in the watchlist config modal. * Populate the linked provider section in the watchlist config modal.
* Shows which Spotify/iTunes/Deezer artist is linked and allows changing it. * Shows which Spotify/iTunes/Deezer artist is linked and allows changing it.
*/ */
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId, amazonId) { function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId, amazonId, musicbrainzId) {
const section = document.getElementById('watchlist-linked-provider-section'); const section = document.getElementById('watchlist-linked-provider-section');
const content = document.getElementById('watchlist-linked-provider-content'); const content = document.getElementById('watchlist-linked-provider-content');
if (!section || !content) return; if (!section || !content) return;
@ -1936,6 +1938,7 @@ function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesI
{ key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' }, { key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' },
{ key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' }, { key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' },
{ key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' }, { key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' },
{ key: 'musicbrainz', label: 'MusicBrainz', icon: 'MB', id: musicbrainzId || '', color: '#ba478f' },
{ key: 'amazon', label: 'Amazon Music', icon: '🟠', id: amazonId || '', color: '#FF9900' }, { key: 'amazon', label: 'Amazon Music', icon: '🟠', id: amazonId || '', color: '#FF9900' },
]; ];
@ -1980,7 +1983,7 @@ function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesI
function _openSourceSearch(sourceKey, artistId, artistName) { function _openSourceSearch(sourceKey, artistId, artistName) {
const panel = document.getElementById('wl-linked-search-panel'); const panel = document.getElementById('wl-linked-search-panel');
if (!panel) return; if (!panel) return;
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', amazon: 'Amazon Music' }; const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', amazon: 'Amazon Music', musicbrainz: 'MusicBrainz' };
document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`; document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`;
const input = document.getElementById('wl-linked-search-input'); const input = document.getElementById('wl-linked-search-input');
input.value = artistName; input.value = artistName;
@ -2108,10 +2111,10 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
return; return;
} }
const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, amazon_artist_id, watchlist_name } = data; const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, amazon_artist_id, musicbrainz_artist_id, watchlist_name } = data;
// Populate linked provider section (use DB watchlist_name for mismatch comparison) // Populate linked provider section (use DB watchlist_name for mismatch comparison)
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id, amazon_artist_id); _populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id, amazon_artist_id, musicbrainz_artist_id);
// Check if global override is active // Check if global override is active
let globalOverrideActive = false; let globalOverrideActive = false;
@ -2178,10 +2181,11 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
{ key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' }, { key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' },
{ key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' }, { key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' },
{ key: 'discogs', label: 'Discogs', id: discogs_artist_id, color: '#333' }, { key: 'discogs', label: 'Discogs', id: discogs_artist_id, color: '#333' },
{ key: 'musicbrainz', label: 'MusicBrainz', id: musicbrainz_artist_id, color: '#BA478F' },
]; ];
const globalSource = data.global_metadata_source || 'deezer'; const globalSource = data.global_metadata_source || 'deezer';
const currentOverride = config.preferred_metadata_source; const currentOverride = config.preferred_metadata_source;
const globalLabel = { spotify: 'Spotify', deezer: 'Deezer', itunes: 'Apple Music', discogs: 'Discogs' }[globalSource] || globalSource; const globalLabel = { spotify: 'Spotify', deezer: 'Deezer', itunes: 'Apple Music', discogs: 'Discogs', musicbrainz: 'MusicBrainz' }[globalSource] || globalSource;
let html = `<button class="config-msrc-btn ${!currentOverride ? 'active' : ''}" data-source="" title="Use global default (${globalLabel})"> let html = `<button class="config-msrc-btn ${!currentOverride ? 'active' : ''}" data-source="" title="Use global default (${globalLabel})">
<span class="config-msrc-icon">🌐</span><span class="config-msrc-label">Default (${globalLabel})</span> <span class="config-msrc-icon">🌐</span><span class="config-msrc-label">Default (${globalLabel})</span>
@ -2283,7 +2287,7 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
return; return;
} }
const { config, artist, recent_releases, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id } = data; const { config, artist, recent_releases, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, musicbrainz_artist_id } = data;
// Remove existing overlay if any // Remove existing overlay if any
const existing = document.querySelector('.watchlist-artist-detail-overlay'); const existing = document.querySelector('.watchlist-artist-detail-overlay');
@ -2416,11 +2420,13 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
discogId = discogs_artist_id; source = 'discogs'; discogId = discogs_artist_id; source = 'discogs';
} else if (activeSrc.includes('deezer') && deezer_artist_id) { } else if (activeSrc.includes('deezer') && deezer_artist_id) {
discogId = deezer_artist_id; source = 'deezer'; discogId = deezer_artist_id; source = 'deezer';
} else if (activeSrc.includes('musicbrainz') && musicbrainz_artist_id) {
discogId = musicbrainz_artist_id; source = 'musicbrainz';
} else if (itunes_artist_id) { } else if (itunes_artist_id) {
discogId = itunes_artist_id; source = 'itunes'; discogId = itunes_artist_id; source = 'itunes';
} else { } else {
discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || itunes_artist_id; discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || musicbrainz_artist_id || itunes_artist_id;
source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes';
} }
if (discogId) { if (discogId) {
closeWatchlistArtistDetailView(); closeWatchlistArtistDetailView();

View file

@ -6782,11 +6782,12 @@ async function openYourArtistInfoModal_direct(node) {
let bestId = '', bestSource = ''; let bestId = '', bestSource = '';
// Check what the active source is // Check what the active source is
const activeSource = window._yaActiveSource || 'spotify'; const activeSource = window._yaActiveSource || 'spotify';
const sourceOrder = activeSource === 'spotify' ? ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id'] const sourceOrder = activeSource === 'spotify' ? ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id', 'musicbrainz_id']
: activeSource === 'itunes' ? ['itunes_id', 'spotify_id', 'deezer_id', 'discogs_id'] : activeSource === 'itunes' ? ['itunes_id', 'spotify_id', 'deezer_id', 'discogs_id', 'musicbrainz_id']
: activeSource === 'deezer' ? ['deezer_id', 'spotify_id', 'itunes_id', 'discogs_id'] : activeSource === 'deezer' ? ['deezer_id', 'spotify_id', 'itunes_id', 'discogs_id', 'musicbrainz_id']
: ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id']; : activeSource === 'musicbrainz' ? ['musicbrainz_id', 'spotify_id', 'itunes_id', 'deezer_id', 'discogs_id']
const sourceMap = { spotify_id: 'spotify', itunes_id: 'itunes', deezer_id: 'deezer', discogs_id: 'discogs' }; : ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id', 'musicbrainz_id'];
const sourceMap = { spotify_id: 'spotify', itunes_id: 'itunes', deezer_id: 'deezer', discogs_id: 'discogs', musicbrainz_id: 'musicbrainz' };
for (const key of sourceOrder) { for (const key of sourceOrder) {
if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; } if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; }
} }

View file

@ -71,8 +71,7 @@ function _isMetadataSourceSelectable(source) {
function _metadataSourceFallback(source) { function _metadataSourceFallback(source) {
if (source === 'spotify') return 'deezer'; if (source === 'spotify') return 'deezer';
if (source === 'discogs') return 'itunes'; return 'deezer';
return 'itunes';
} }
function focusServiceSettingsSection(service, message) { function focusServiceSettingsSection(service, message) {
@ -100,7 +99,7 @@ function sanitizeMetadataSourceSelection({ quiet = true } = {}) {
const select = document.getElementById('metadata-fallback-source'); const select = document.getElementById('metadata-fallback-source');
if (!select) return false; if (!select) return false;
const selectedSource = select.value || 'itunes'; const selectedSource = select.value || 'deezer';
if (_isMetadataSourceSelectable(selectedSource)) { if (_isMetadataSourceSelectable(selectedSource)) {
select.dataset.lastValidSource = selectedSource; select.dataset.lastValidSource = selectedSource;
return false; return false;
@ -893,7 +892,7 @@ async function loadSettingsData() {
document.getElementById('discogs-token').value = settings.discogs?.token || ''; document.getElementById('discogs-token').value = settings.discogs?.token || '';
// Populate Metadata source setting // Populate Metadata source setting
document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'itunes'; document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'deezer';
// Populate Hydrabase settings // Populate Hydrabase settings
const hbConfig = settings.hydrabase || {}; const hbConfig = settings.hydrabase || {};
@ -2563,16 +2562,16 @@ async function saveSettings(quiet = false) {
const metadataSourceSelect = document.getElementById('metadata-fallback-source'); const metadataSourceSelect = document.getElementById('metadata-fallback-source');
const discogsTokenInput = document.getElementById('discogs-token'); const discogsTokenInput = document.getElementById('discogs-token');
const discogsTokenPresent = !!discogsTokenInput?.value?.trim(); const discogsTokenPresent = !!discogsTokenInput?.value?.trim();
let metadataSource = metadataSourceSelect?.value || 'itunes'; let metadataSource = metadataSourceSelect?.value || 'deezer';
const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true; const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true;
if (metadataSource === 'spotify' && !spotifySessionActive) { if (metadataSource === 'spotify' && !spotifySessionActive) {
metadataSource = 'deezer'; metadataSource = _metadataSourceFallback('spotify');
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource; if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
if (!quiet) { if (!quiet) {
showToast('Spotify is disconnected, so Deezer is used as the primary metadata source.', 'warning'); showToast('Spotify is disconnected, so the primary metadata source was switched.', 'warning');
} }
} else if (metadataSource === 'discogs' && !discogsTokenPresent) { } else if (metadataSource === 'discogs' && !discogsTokenPresent) {
metadataSource = 'itunes'; metadataSource = _metadataSourceFallback('discogs');
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource; if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
if (!quiet) { if (!quiet) {
showToast('Discogs requires a personal access token before it can be selected as the primary metadata source.', 'warning'); showToast('Discogs requires a personal access token before it can be selected as the primary metadata source.', 'warning');

View file

@ -3244,7 +3244,9 @@ function syncPrimaryMetadataSourceAvailability(statusData) {
function getMetadataSourceLabel(source) { function getMetadataSourceLabel(source) {
if (source === 'deezer') return 'Deezer'; if (source === 'deezer') return 'Deezer';
if (source === 'discogs') return 'Discogs'; if (source === 'discogs') return 'Discogs';
if (source === 'hydrabase') return 'Hydrabase';
if (source === 'itunes') return 'iTunes'; if (source === 'itunes') return 'iTunes';
if (source === 'musicbrainz') return 'MusicBrainz';
if (source === 'spotify') return 'Spotify'; if (source === 'spotify') return 'Spotify';
return 'Unmapped'; return 'Unmapped';
} }

View file

@ -16445,6 +16445,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #D4A574; color: #D4A574;
} }
.watchlist-source-musicbrainz {
background: rgba(186, 71, 143, 0.15);
color: #BA478F;
}
.watchlist-source-amazon { .watchlist-source-amazon {
background: rgba(255, 153, 0, 0.15); background: rgba(255, 153, 0, 0.15);
color: #FF9900; color: #FF9900;