Complete MusicBrainz watchlist source parity

Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support.

Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings.

Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
This commit is contained in:
Broque Thomas 2026-05-18 19:19:25 -07:00
parent 5bc5fbb662
commit f3ad65de34
12 changed files with 383 additions and 60 deletions

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

@ -263,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
@ -632,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
@ -665,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:

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

@ -531,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
@ -571,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."""
@ -901,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%')
@ -956,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:
@ -989,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()
@ -1161,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:
@ -1218,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)
) )
@ -1592,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:
@ -1611,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 = {
@ -1618,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:
@ -1777,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.

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
@ -335,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,
@ -1502,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 '[]',
@ -1518,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("""
@ -1653,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
@ -1742,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)
@ -1769,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
) )
""") """)
@ -1781,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")
@ -2624,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)
@ -2636,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)
@ -7747,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
@ -7760,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"""
@ -7796,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
@ -7812,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()
@ -7820,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()
@ -7843,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()
@ -7852,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
@ -7882,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']
@ -7911,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
@ -7934,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,
@ -8133,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
@ -8220,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:
@ -10301,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

@ -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

@ -24511,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)
@ -24528,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,
@ -24565,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()
@ -24576,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'
@ -24591,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:
@ -25014,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:
@ -25320,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()
@ -25328,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()
@ -25345,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
@ -25387,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]
@ -25410,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],
@ -25453,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(),
}) })
@ -25476,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
@ -25490,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
@ -25503,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:
@ -25553,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
@ -25567,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:
@ -25579,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:

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

@ -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;