@ -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
@ -118,6 +119,7 @@ class SimilarArtist:
genres : Optional [ List [ str ] ] = None # Cached genres
popularity : int = 0 # Cached popularity score
similar_artist_deezer_id : Optional [ str ] = None # Deezer artist ID
similar_artist_musicbrainz_id : Optional [ str ] = None # MusicBrainz artist ID
@dataclass
class DiscoveryTrack :
@ -334,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 ,
@ -1174,6 +1177,10 @@ class MusicDatabase:
cursor . execute ( " ALTER TABLE similar_artists ADD COLUMN similar_artist_deezer_id TEXT " )
logger . info ( " Added similar_artist_deezer_id column to similar_artists table " )
if ' similar_artist_musicbrainz_id ' not in similar_artists_columns :
cursor . execute ( " ALTER TABLE similar_artists ADD COLUMN similar_artist_musicbrainz_id TEXT " )
logger . info ( " Added similar_artist_musicbrainz_id column to similar_artists table " )
# Migration: Add iTunes columns to recent_releases for dual-source discovery
cursor . execute ( " PRAGMA table_info(recent_releases) " )
recent_releases_columns = [ column [ 1 ] for column in cursor . fetchall ( ) ]
@ -1288,6 +1295,8 @@ class MusicDatabase:
source_artist_id TEXT NOT NULL ,
similar_artist_spotify_id TEXT ,
similar_artist_itunes_id TEXT ,
similar_artist_deezer_id TEXT ,
similar_artist_musicbrainz_id TEXT ,
similar_artist_name TEXT NOT NULL ,
similarity_rank INTEGER DEFAULT 1 ,
occurrence_count INTEGER DEFAULT 1 ,
@ -1298,8 +1307,10 @@ class MusicDatabase:
migration_cursor . execute ( """
INSERT OR IGNORE INTO similar_artists_new
( source_artist_id , similar_artist_spotify_id , similar_artist_itunes_id ,
similar_artist_deezer_id , similar_artist_musicbrainz_id ,
similar_artist_name , similarity_rank , occurrence_count , last_updated )
SELECT source_artist_id , similar_artist_spotify_id , similar_artist_itunes_id ,
similar_artist_deezer_id , similar_artist_musicbrainz_id ,
similar_artist_name , similarity_rank , occurrence_count , last_updated
FROM similar_artists
""" )
@ -1312,6 +1323,7 @@ class MusicDatabase:
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_similar_artists_source ON similar_artists (source_artist_id) " )
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_similar_artists_spotify ON similar_artists (similar_artist_spotify_id) " )
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_similar_artists_itunes ON similar_artists (similar_artist_itunes_id) " )
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_similar_artists_musicbrainz ON similar_artists (similar_artist_musicbrainz_id) " )
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_similar_artists_occurrence ON similar_artists (occurrence_count) " )
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_similar_artists_name ON similar_artists (similar_artist_name) " )
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_discovery_pool_spotify_track ON discovery_pool (spotify_track_id) " )
@ -1492,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 ' [] ' ,
@ -1508,6 +1521,10 @@ class MusicDatabase:
""" )
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id) " )
cursor . execute ( " CREATE INDEX IF NOT EXISTS idx_lap_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 ( """
@ -1643,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
@ -1732,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 )
@ -1759,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
)
""" )
@ -1771,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 " )
@ -2614,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 )
@ -2626,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 )
@ -2958,6 +2984,7 @@ class MusicDatabase:
similar_artist_spotify_id TEXT ,
similar_artist_itunes_id TEXT ,
similar_artist_deezer_id TEXT ,
similar_artist_musicbrainz_id TEXT ,
similar_artist_name TEXT NOT NULL ,
similarity_rank INTEGER DEFAULT 1 ,
occurrence_count INTEGER DEFAULT 1 ,
@ -2974,7 +3001,8 @@ class MusicDatabase:
new_cols = [ ' id ' , ' source_artist_id ' , ' similar_artist_spotify_id ' ,
' similar_artist_itunes_id ' , ' similar_artist_deezer_id ' ,
' similar_artist_name ' , ' similarity_rank ' , ' occurrence_count ' ,
' similar_artist_musicbrainz_id ' , ' similar_artist_name ' ,
' similarity_rank ' , ' occurrence_count ' ,
' last_updated ' , ' image_url ' , ' genres ' , ' popularity ' ,
' metadata_updated_at ' , ' last_featured ' , ' profile_id ' ]
shared_cols = [ c for c in new_cols if c in old_cols ]
@ -7735,7 +7763,8 @@ class MusicDatabase:
# Check if artist already exists by name (case-insensitive) for this profile
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
@ -7748,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 """
@ -7784,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
@ -7800,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 ( )
@ -7808,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 ( )
@ -7831,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 ( )
@ -7840,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
@ -7870,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 ' ]
@ -7899,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
@ -7922,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 ,
@ -8121,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
@ -8208,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 :
@ -8260,25 +8411,27 @@ class MusicDatabase:
image_url : Optional [ str ] = None ,
genres : Optional [ list ] = None ,
popularity : int = 0 ,
similar_artist_deezer_id : Optional [ str ] = None ) - > bool :
""" Add or update a similar artist recommendation (supports Spotify, iTunes, and Deezer IDs) """
similar_artist_deezer_id : Optional [ str ] = None ,
similar_artist_musicbrainz_id : Optional [ str ] = None ) - > bool :
""" Add or update a similar artist recommendation. """
try :
with self . _get_connection ( ) as conn :
cursor = conn . cursor ( )
genres_json = json . dumps ( genres ) if genres else None
# Use artist name as the unique key (allows storing both IDs for same artist)
cursor . execute ( """
INSERT INTO similar_artists
( source_artist_id , similar_artist_spotify_id , similar_artist_itunes_id , similar_artist_deezer_id , similar_artist_name ,
( source_artist_id , similar_artist_spotify_id , similar_artist_itunes_id ,
similar_artist_deezer_id , similar_artist_musicbrainz_id , similar_artist_name ,
similarity_rank , occurrence_count , last_updated , profile_id ,
image_url , genres , popularity , metadata_updated_at )
VALUES ( ? , ? , ? , ? , ? , ? , 1 , CURRENT_TIMESTAMP , ? , ? , ? , ? , CURRENT_TIMESTAMP )
VALUES ( ? , ? , ? , ? , ? , ? , ? , 1 , CURRENT_TIMESTAMP , ? , ? , ? , ? , CURRENT_TIMESTAMP )
ON CONFLICT ( profile_id , source_artist_id , similar_artist_name )
DO UPDATE SET
similar_artist_spotify_id = COALESCE ( excluded . similar_artist_spotify_id , similar_artist_spotify_id ) ,
similar_artist_itunes_id = COALESCE ( excluded . similar_artist_itunes_id , similar_artist_itunes_id ) ,
similar_artist_deezer_id = COALESCE ( excluded . similar_artist_deezer_id , similar_artist_deezer_id ) ,
similar_artist_musicbrainz_id = COALESCE ( excluded . similar_artist_musicbrainz_id , similar_artist_musicbrainz_id ) ,
similarity_rank = excluded . similarity_rank ,
occurrence_count = occurrence_count + 1 ,
last_updated = CURRENT_TIMESTAMP ,
@ -8286,7 +8439,8 @@ class MusicDatabase:
genres = COALESCE ( excluded . genres , genres ) ,
popularity = CASE WHEN excluded . popularity > 0 THEN excluded . popularity ELSE popularity END ,
metadata_updated_at = CASE WHEN excluded . image_url IS NOT NULL THEN CURRENT_TIMESTAMP ELSE metadata_updated_at END
""" , (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_name,
""" , (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
similar_artist_deezer_id , similar_artist_musicbrainz_id , similar_artist_name ,
similarity_rank , profile_id , image_url , genres_json , popularity ) )
conn . commit ( )
@ -8319,6 +8473,7 @@ class MusicDatabase:
occurrence_count = row [ ' occurrence_count ' ] ,
last_updated = datetime . fromisoformat ( row [ ' last_updated ' ] ) ,
similar_artist_deezer_id = row [ ' similar_artist_deezer_id ' ] if ' similar_artist_deezer_id ' in row . keys ( ) else None ,
similar_artist_musicbrainz_id = row [ ' similar_artist_musicbrainz_id ' ] if ' similar_artist_musicbrainz_id ' in row . keys ( ) else None ,
) for row in rows ]
except Exception as e :
@ -8328,11 +8483,14 @@ class MusicDatabase:
def get_similar_artists_missing_fallback_ids ( self , source_artist_id : str , fallback_source : str = ' itunes ' , profile_id : int = 1 ) - > List [ SimilarArtist ] :
""" Get similar artists missing fallback-provider IDs for backfill. """
try :
if fallback_source not in { ' itunes ' , ' deezer ' }:
if fallback_source not in { ' itunes ' , ' deezer ' , ' musicbrainz ' }:
logger . error ( " Unsupported similar-artist fallback source: %s " , fallback_source )
return [ ]
col = ' similar_artist_deezer_id ' if fallback_source == ' deezer ' else ' similar_artist_itunes_id '
col = {
' deezer ' : ' similar_artist_deezer_id ' ,
' musicbrainz ' : ' similar_artist_musicbrainz_id ' ,
} . get ( fallback_source , ' similar_artist_itunes_id ' )
with self . _get_connection ( ) as conn :
cursor = conn . cursor ( )
@ -8355,6 +8513,7 @@ class MusicDatabase:
occurrence_count = row [ ' occurrence_count ' ] ,
last_updated = datetime . fromisoformat ( row [ ' last_updated ' ] ) ,
similar_artist_deezer_id = row [ ' similar_artist_deezer_id ' ] if ' similar_artist_deezer_id ' in row . keys ( ) else None ,
similar_artist_musicbrainz_id = row [ ' similar_artist_musicbrainz_id ' ] if ' similar_artist_musicbrainz_id ' in row . keys ( ) else None ,
) for row in rows ]
except Exception as e :
@ -8399,6 +8558,25 @@ class MusicDatabase:
logger . error ( f " Error updating similar artist Deezer ID: { e } " )
return False
def update_similar_artist_musicbrainz_id ( self , similar_artist_id : int , musicbrainz_id : str ) - > bool :
""" Update a similar artist ' s MusicBrainz ID (for backfill) """
try :
with self . _get_connection ( ) as conn :
cursor = conn . cursor ( )
cursor . execute ( """
UPDATE similar_artists
SET similar_artist_musicbrainz_id = ?
WHERE id = ?
""" , (musicbrainz_id, similar_artist_id))
conn . commit ( )
return cursor . rowcount > 0
except Exception as e :
logger . error ( f " Error updating similar artist MusicBrainz ID: { e } " )
return False
def update_similar_artist_metadata ( self , similar_artist_id : int , image_url : str = None ,
genres : list = None , popularity : int = None ) - > bool :
""" Cache artist metadata (image, genres, popularity) to avoid repeated API calls """
@ -8420,7 +8598,7 @@ class MusicDatabase:
def update_similar_artist_metadata_by_external_id ( self , external_id : str , source : str = ' spotify ' ,
image_url : str = None , genres : list = None ,
popularity : int = None ) - > bool :
""" Cache artist metadata by Spotify or iTunes ID (updates all rows for that artist) """
""" Cache artist metadata by external source ID (updates all rows for that artist). """
try :
with self . _get_connection ( ) as conn :
cursor = conn . cursor ( )
@ -8429,6 +8607,8 @@ class MusicDatabase:
where_clause = " similar_artist_spotify_id = ? "
elif source == ' deezer ' :
where_clause = " similar_artist_deezer_id = ? "
elif source == ' musicbrainz ' :
where_clause = " similar_artist_musicbrainz_id = ? "
else :
where_clause = " similar_artist_itunes_id = ? "
cursor . execute ( f """
@ -8490,7 +8670,7 @@ class MusicDatabase:
exclude_library_server : str = None ,
) - > List [ SimilarArtist ] :
""" Get top similar artists excluding watchlist artists, with cycling support.
require_source : if set ( ' spotify ' , ' itunes ' , ' deezer ' ) , only returns artists with that source ID .
require_source : if set , only returns artists with that source ID .
exclude_library_server : if set , also excludes artists already present in that media server . """
try :
with self . _get_connection ( ) as conn :
@ -8504,12 +8684,14 @@ class MusicDatabase:
source_filter = " AND sa.similar_artist_itunes_id IS NOT NULL AND sa.similar_artist_itunes_id != ' ' "
elif require_source == ' deezer ' :
source_filter = " AND sa.similar_artist_deezer_id IS NOT NULL AND sa.similar_artist_deezer_id != ' ' "
elif require_source == ' musicbrainz ' :
source_filter = " AND sa.similar_artist_musicbrainz_id IS NOT NULL AND sa.similar_artist_musicbrainz_id != ' ' "
library_artist_keys = None
sql_limit = limit
if exclude_library_server :
cursor . execute ( """
SELECT name , spotify_artist_id , itunes_artist_id , deezer_id
SELECT name , spotify_artist_id , itunes_artist_id , deezer_id , musicbrainz_id
FROM artists
WHERE server_source = ?
""" , (exclude_library_server,))
@ -8518,6 +8700,7 @@ class MusicDatabase:
' spotify ' : { r [ ' spotify_artist_id ' ] for r in library_rows if r [ ' spotify_artist_id ' ] } ,
' itunes ' : { r [ ' itunes_artist_id ' ] for r in library_rows if r [ ' itunes_artist_id ' ] } ,
' deezer ' : { r [ ' deezer_id ' ] for r in library_rows if r [ ' deezer_id ' ] } ,
' musicbrainz ' : { r [ ' musicbrainz_id ' ] for r in library_rows if r [ ' musicbrainz_id ' ] } ,
' names ' : {
self . _normalize_for_comparison ( r [ ' name ' ] )
for r in library_rows
@ -8533,6 +8716,7 @@ class MusicDatabase:
MAX ( sa . similar_artist_spotify_id ) as similar_artist_spotify_id ,
MAX ( sa . similar_artist_itunes_id ) as similar_artist_itunes_id ,
MAX ( sa . similar_artist_deezer_id ) as similar_artist_deezer_id ,
MAX ( sa . similar_artist_musicbrainz_id ) as similar_artist_musicbrainz_id ,
sa . similar_artist_name ,
AVG ( sa . similarity_rank ) as similarity_rank ,
SUM ( sa . occurrence_count ) as occurrence_count ,
@ -8564,11 +8748,13 @@ class MusicDatabase:
spotify_id = row [ ' similar_artist_spotify_id ' ]
itunes_id = row [ ' similar_artist_itunes_id ' ] if ' similar_artist_itunes_id ' in row . keys ( ) else None
deezer_id = row [ ' similar_artist_deezer_id ' ] if ' similar_artist_deezer_id ' in row . keys ( ) else None
musicbrainz_id = row [ ' similar_artist_musicbrainz_id ' ] if ' similar_artist_musicbrainz_id ' in row . keys ( ) else None
normalized_name = self . _normalize_for_comparison ( row [ ' similar_artist_name ' ] )
if (
( spotify_id and spotify_id in library_artist_keys [ ' spotify ' ] )
or ( itunes_id and itunes_id in library_artist_keys [ ' itunes ' ] )
or ( deezer_id and deezer_id in library_artist_keys [ ' deezer ' ] )
or ( musicbrainz_id and musicbrainz_id in library_artist_keys [ ' musicbrainz ' ] )
or ( normalized_name and normalized_name in library_artist_keys [ ' names ' ] )
) :
continue
@ -8584,6 +8770,7 @@ class MusicDatabase:
similar_artist_spotify_id = row [ ' similar_artist_spotify_id ' ] ,
similar_artist_itunes_id = row [ ' similar_artist_itunes_id ' ] if ' similar_artist_itunes_id ' in row . keys ( ) else None ,
similar_artist_deezer_id = row [ ' similar_artist_deezer_id ' ] if ' similar_artist_deezer_id ' in row . keys ( ) else None ,
similar_artist_musicbrainz_id = row [ ' similar_artist_musicbrainz_id ' ] if ' similar_artist_musicbrainz_id ' in row . keys ( ) else None ,
similar_artist_name = row [ ' similar_artist_name ' ] ,
similarity_rank = int ( row [ ' similarity_rank ' ] ) ,
occurrence_count = row [ ' occurrence_count ' ] ,
@ -10253,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 } , ?) " )