diff --git a/core/artists/liked_match.py b/core/artists/liked_match.py index 324d8cbc..a8a973ab 100644 --- a/core/artists/liked_match.py +++ b/core/artists/liked_match.py @@ -12,6 +12,7 @@ from core.metadata.registry import ( get_deezer_client, get_discogs_client, get_itunes_client, + get_musicbrainz_client, get_spotify_client, ) @@ -33,6 +34,11 @@ def _get_discogs_client(token=None): return get_discogs_client(token) +def _get_musicbrainz_client(): + """Mirror of web_server._get_musicbrainz_client — delegates to registry.""" + return get_musicbrainz_client() + + class _SpotifyClientProxy: """Resolves the global Spotify client lazily so a Spotify re-auth that rebinds the cached client in core.metadata.registry is visible to the @@ -65,6 +71,7 @@ def _match_liked_artists_to_all_sources(database, profile_id: int): 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id', + 'musicbrainz': 'musicbrainz_artist_id', } 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 except Exception as e: logger.debug("discogs client init failed: %s", e) + try: + search_clients['musicbrainz'] = _get_musicbrainz_client() + except Exception as e: + logger.debug("musicbrainz client init failed: %s", e) # Reuse watchlist scanner's fuzzy matching logic from core.watchlist_scanner import WatchlistScanner @@ -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 resolved_source = None resolved_id = None - for src in ('spotify', 'itunes', 'deezer', 'discogs'): + for src in ('spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz'): col = source_cols[src] if col in harvested_ids: resolved_source = src diff --git a/core/artists/map.py b/core/artists/map.py index 71a81b12..781a8d1e 100644 --- a/core/artists/map.py +++ b/core/artists/map.py @@ -263,7 +263,7 @@ def get_artist_map_data(): break # Backfill genres if missing 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'): n['genres'] = cached[source]['genres'][:5] break @@ -632,7 +632,7 @@ def get_artist_map_explore(): # Find the center artist center_name = artist_name 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 = [] # Search metadata cache for the center artist @@ -665,14 +665,14 @@ def get_artist_map_explore(): # Check watchlist + library if not in cache 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() if wr: artist_found = True center_name = wr['artist_name'] if wr['image_url'] and str(wr['image_url']).startswith('http'): 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]: center_ids[k] = str(wr[col]) else: diff --git a/core/watchlist/source_picker.py b/core/watchlist/source_picker.py index 666a5a91..0c40a21f 100644 --- a/core/watchlist/source_picker.py +++ b/core/watchlist/source_picker.py @@ -28,6 +28,7 @@ SOURCE_ID_COLUMNS = ( ('itunes', 'itunes_artist_id'), ('deezer', 'deezer_id'), ('discogs', 'discogs_id'), + ('musicbrainz', 'musicbrainz_id'), ) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 694da08b..6f9cbfb5 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -531,6 +531,7 @@ class WatchlistScanner: 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id', + 'musicbrainz': 'musicbrainz_artist_id', }.get(source) @staticmethod @@ -571,6 +572,9 @@ class WatchlistScanner: elif source == 'discogs': self.database.update_watchlist_discogs_id(watchlist_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]: """Resolve the artist ID for an exact source, searching by name if needed.""" @@ -901,7 +905,7 @@ class WatchlistScanner: cursor = conn.cursor() cursor.execute(""" 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 WHERE profile_id = ? AND (image_url IS NULL OR image_url = '' OR image_url = 'None' OR image_url NOT LIKE 'http%') @@ -956,7 +960,8 @@ class WatchlistScanner: if img: 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: self.database.update_watchlist_artist_image(aid, img) else: @@ -989,7 +994,7 @@ class WatchlistScanner: """ # Per-artist metadata source override — if set, use that source first with fallback 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)) else: 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. providers_to_backfill = [ 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: @@ -1218,6 +1223,7 @@ class WatchlistScanner: or artist.itunes_artist_id or artist.deezer_artist_id or artist.discogs_artist_id + or getattr(artist, 'musicbrainz_artist_id', None) or str(artist.id) ) @@ -1592,6 +1598,7 @@ class WatchlistScanner: 'itunes': 'itunes_artist_id', 'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id', + 'musicbrainz': 'musicbrainz_artist_id', }.get(provider) if not id_attr: @@ -1611,6 +1618,7 @@ class WatchlistScanner: 'itunes': self._match_to_itunes, 'deezer': self._match_to_deezer, 'discogs': self._match_to_discogs, + 'musicbrainz': self._match_to_musicbrainz, }.get(provider) update_fn = { @@ -1618,6 +1626,7 @@ class WatchlistScanner: 'itunes': self.database.update_watchlist_itunes_id, 'deezer': self.database.update_watchlist_deezer_id, 'discogs': self.database.update_watchlist_discogs_id, + 'musicbrainz': self.database.update_watchlist_musicbrainz_id, }.get(provider) 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}") 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: """ Get the discovery lookback period setting from database. diff --git a/database/music_database.py b/database/music_database.py index af64008d..02cc5246 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -91,6 +91,7 @@ class WatchlistArtist: itunes_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 + musicbrainz_artist_id: Optional[str] = None # Cross-provider support include_albums: bool = True include_eps: bool = True include_singles: bool = True @@ -335,6 +336,7 @@ class MusicDatabase: itunes_artist_id TEXT, deezer_artist_id TEXT, discogs_artist_id TEXT, + musicbrainz_artist_id TEXT, artist_name TEXT NOT NULL, date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_scan_timestamp TIMESTAMP, @@ -1502,6 +1504,7 @@ class MusicDatabase: itunes_artist_id TEXT, deezer_artist_id TEXT, discogs_artist_id TEXT, + musicbrainz_artist_id TEXT, image_url TEXT, genres TEXT, 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_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 cursor.execute(""" @@ -1653,6 +1660,10 @@ class MusicDatabase: 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") + 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: logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}") # Don't raise - this is a migration, database can still function @@ -1742,6 +1753,7 @@ class MusicDatabase: itunes_artist_id TEXT, deezer_artist_id TEXT, discogs_artist_id TEXT, + musicbrainz_artist_id TEXT, profile_id INTEGER DEFAULT 1, UNIQUE(profile_id, spotify_artist_id), UNIQUE(profile_id, itunes_artist_id) @@ -1769,7 +1781,8 @@ class MusicDatabase: lookback_days INTEGER DEFAULT NULL, itunes_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_remixes', 'include_acoustic', 'include_compilations', '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] cols_str = ', '.join(shared_cols) 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, deezer_artist_id TEXT, discogs_artist_id TEXT, + musicbrainz_artist_id TEXT, profile_id INTEGER DEFAULT 1, UNIQUE(profile_id, spotify_artist_id), UNIQUE(profile_id, itunes_artist_id) @@ -2636,7 +2651,8 @@ class MusicDatabase: 'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations', '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] cols_str = ', '.join(shared_cols) @@ -7747,7 +7763,8 @@ class MusicDatabase: # Check if artist already exists by name (case-insensitive) for this profile 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 WHERE LOWER(artist_name) = LOWER(?) AND profile_id = ? LIMIT 1 @@ -7760,7 +7777,13 @@ class MusicDatabase: if existing: # 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) if col and not existing[col]: cursor.execute(f""" @@ -7796,6 +7819,13 @@ class MusicDatabase: VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) """, (artist_id, artist_name, 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: cursor.execute(""" INSERT INTO watchlist_artists @@ -7812,7 +7842,7 @@ class MusicDatabase: return False 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: with self._get_connection() as conn: cursor = conn.cursor() @@ -7820,15 +7850,17 @@ class MusicDatabase: # Get artist name for logging (check all ID columns) cursor.execute(""" 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 = ? - """, (artist_id, artist_id, artist_id, artist_id, 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 = ? + """, (artist_id, artist_id, artist_id, artist_id, artist_id, profile_id)) result = cursor.fetchone() artist_name = result['artist_name'] if result else "Unknown" cursor.execute(""" DELETE FROM watchlist_artists - WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?) AND profile_id = ? - """, (artist_id, artist_id, artist_id, artist_id, 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 = ? + """, (artist_id, artist_id, artist_id, artist_id, artist_id, profile_id)) if cursor.rowcount > 0: conn.commit() @@ -7843,7 +7875,7 @@ class MusicDatabase: return False 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: with self._get_connection() as conn: cursor = conn.cursor() @@ -7852,15 +7884,18 @@ class MusicDatabase: if artist_name: cursor.execute(""" 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 - """, (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: cursor.execute(""" 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 - """, (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() return result is not None @@ -7882,7 +7917,7 @@ class MusicDatabase: # Build SELECT query based on existing columns base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added', '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_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 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 + 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_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 @@ -7934,6 +7970,7 @@ class MusicDatabase: itunes_artist_id=itunes_artist_id, deezer_artist_id=deezer_artist_id, discogs_artist_id=discogs_artist_id, + musicbrainz_artist_id=musicbrainz_artist_id, include_albums=include_albums, include_eps=include_eps, include_singles=include_singles, @@ -8133,8 +8170,9 @@ class MusicDatabase: cursor.execute(""" UPDATE watchlist_artists SET image_url = ?, updated_at = CURRENT_TIMESTAMP - WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? - """, (image_url, artist_id, artist_id, artist_id, artist_id)) + WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? + OR discogs_artist_id = ? OR musicbrainz_artist_id = ? + """, (image_url, artist_id, artist_id, artist_id, artist_id, artist_id)) conn.commit() return cursor.rowcount > 0 @@ -8220,6 +8258,107 @@ class MusicDatabase: logger.error(f"Error updating watchlist Discogs ID: {e}") 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: """Update the iTunes artist ID for a watchlist artist by Spotify ID (for cross-provider caching)""" try: @@ -10301,7 +10440,7 @@ class MusicDatabase: # Store all discovered source IDs (COALESCE preserves existing values) 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) if val: set_parts.append(f"{col} = COALESCE({col}, ?)") diff --git a/tests/test_watchlist_bulk_add.py b/tests/test_watchlist_bulk_add.py index b193dd7c..19f86b42 100644 --- a/tests/test_watchlist_bulk_add.py +++ b/tests/test_watchlist_bulk_add.py @@ -69,6 +69,23 @@ def test_falls_back_to_discogs_as_last_resort() -> None: 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: """Drop only when the artist has no source IDs at all — that's the only legitimate skip reason now.""" diff --git a/tests/watchlist/test_musicbrainz_watchlist_ids.py b/tests/watchlist/test_musicbrainz_watchlist_ids.py new file mode 100644 index 00000000..a4092d94 --- /dev/null +++ b/tests/watchlist/test_musicbrainz_watchlist_ids.py @@ -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" diff --git a/web_server.py b/web_server.py index a3550b66..0e284c86 100644 --- a/web_server.py +++ b/web_server.py @@ -24511,6 +24511,7 @@ def get_watchlist_artists(): """Get all artists in the watchlist with cached images""" try: 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()) # 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 "deezer_artist_id": getattr(artist, 'deezer_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), "include_albums": artist.include_albums, "include_eps": artist.include_eps, @@ -24565,7 +24567,7 @@ def add_to_watchlist(): conn = database._get_connection() cursor = conn.cursor() 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 """, (artist_id,)) row = cursor.fetchone() @@ -24576,6 +24578,9 @@ def add_to_watchlist(): if fallback == 'discogs' and row['discogs_id']: artist_id = row['discogs_id'] source = 'discogs' + elif fallback == 'musicbrainz' and row['musicbrainz_id']: + artist_id = row['musicbrainz_id'] + source = 'musicbrainz' elif fallback == 'deezer' and row['deezer_id']: artist_id = row['deezer_id'] source = 'deezer' @@ -24591,12 +24596,17 @@ def add_to_watchlist(): elif row['discogs_id']: artist_id = row['discogs_id'] source = 'discogs' + elif row['musicbrainz_id']: + artist_id = row['musicbrainz_id'] + source = 'musicbrainz' except Exception as e: logger.debug("watchlist artist source lookup failed: %s", e) if not source: fallback_source = _get_metadata_fallback_source() 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) + if success: + database.backfill_watchlist_musicbrainz_ids_from_library(profile_id=get_current_profile_id()) if success: @@ -25014,7 +25024,7 @@ def start_watchlist_scan(): # PROACTIVE ID BACKFILLING (cross-provider support) # 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(): providers_to_backfill.append('spotify') try: @@ -25320,6 +25330,7 @@ def watchlist_artist_config(artist_id): database = get_database() if request.method == 'GET': + database.backfill_watchlist_musicbrainz_ids_from_library(profile_id=get_current_profile_id()) # Get current config from database conn = sqlite3.connect(str(database.database_path)) cursor = conn.cursor() @@ -25328,10 +25339,12 @@ def watchlist_artist_config(artist_id): include_live, include_remixes, include_acoustic, include_compilations, artist_name, image_url, spotify_artist_id, itunes_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 - WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR amazon_artist_id = ? - """, (artist_id, artist_id, artist_id, artist_id, artist_id)) + WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_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() conn.close() @@ -25345,6 +25358,7 @@ def watchlist_artist_config(artist_id): deezer_id = result[14] # deezer_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 + musicbrainz_id = result[19] if len(result) > 19 else None # musicbrainz_artist_id from query # Get artist info from Spotify (only for Spotify artists) artist_info = None @@ -25387,9 +25401,16 @@ def watchlist_artist_config(artist_id): cur2.execute(""" SELECT banner_url, summary, style, mood, label, genres 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 - """, (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() if lib_row: artist_info['banner_url'] = lib_row[0] @@ -25410,9 +25431,17 @@ def watchlist_artist_config(artist_id): FROM recent_releases rr 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 = ? + OR wa.discogs_artist_id = ? OR wa.amazon_artist_id = ? OR wa.musicbrainz_artist_id = ? ORDER BY rr.release_date DESC 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 = [ { 'album_name': r[0], @@ -25453,6 +25482,7 @@ def watchlist_artist_config(artist_id): "deezer_artist_id": deezer_id, "discogs_artist_id": discogs_id, "amazon_artist_id": amazon_id, + "musicbrainz_artist_id": musicbrainz_id, "watchlist_name": result[7], # Original stored watchlist artist name "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 preferred_metadata_source = data.get('preferred_metadata_source', None) # 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 # 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 cursor.execute(""" SELECT lookback_days FROM watchlist_artists - WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? - """, (artist_id, artist_id, artist_id, artist_id)) + WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_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_lookback = old_row[0] if old_row else None lookback_changed = old_lookback != lookback_days @@ -25503,11 +25534,12 @@ def watchlist_artist_config(artist_id): include_instrumentals = ?, lookback_days = ?, preferred_metadata_source = ?, last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END, 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_live), int(include_remixes), int(include_acoustic), int(include_compilations), 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() if cursor.rowcount == 0: @@ -25553,7 +25585,7 @@ def watchlist_artist_link_provider(artist_id): new_provider_id = data.get('provider_id', '').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: 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(""" SELECT id, artist_name, spotify_artist_id, itunes_artist_id FROM watchlist_artists - WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR amazon_artist_id = ? - """, (artist_id, artist_id, artist_id, artist_id, artist_id)) + WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_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() if not row: @@ -25579,7 +25612,14 @@ def watchlist_artist_link_provider(artist_id): artist_name = row[1] # 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] if not is_clear: diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 58561523..4b0fc398 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -974,8 +974,9 @@ async function initializeWatchlistPage() { if (artist.itunes_artist_id) sourceBadges.push('iTunes'); if (artist.deezer_artist_id) sourceBadges.push('Deezer'); if (artist.discogs_artist_id) sourceBadges.push('Discogs'); + if (artist.musicbrainz_artist_id) sourceBadges.push('MusicBrainz'); if (artist.amazon_artist_id) sourceBadges.push('Amazon'); - 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 `