Merge pull request #641 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-05-18 21:30:23 -07:00 committed by GitHub
commit 4f452541a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2061 additions and 556 deletions

View file

@ -9,9 +9,9 @@ on:
workflow_dispatch:
inputs:
version_tag:
description: 'Version tag (e.g. 2.5.4)'
description: 'Version tag (e.g. 2.5.6)'
required: true
default: '2.5.4'
default: '2.5.6'
jobs:
build-and-push:

View file

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

View file

@ -19,6 +19,7 @@ class AmazonWorker:
def __init__(self, database: MusicDatabase):
self.db = database
self.client = AmazonClient()
self._amazon_schema_checked = False
self.running = False
self.paused = False
@ -40,6 +41,38 @@ class AmazonWorker:
logger.info("Amazon background worker initialized")
def _ensure_amazon_schema(self, cursor) -> None:
"""Ensure upgraded installs have the Amazon enrichment columns.
MusicDatabase normally runs this migration during startup, but the
worker should still be defensive because it is the code path that
repeatedly queries these columns in the background.
"""
if self._amazon_schema_checked:
return
table_columns = {
'artists': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
'albums': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
'tracks': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
}
for table, columns in table_columns.items():
cursor.execute(f"PRAGMA table_info({table})")
existing = {row[1] for row in cursor.fetchall()}
for column in columns:
if column not in existing:
column_type = 'TIMESTAMP' if column == 'amazon_last_attempted' else 'TEXT'
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_id ON artists (amazon_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_status ON artists (amazon_match_status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_id ON albums (amazon_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_status ON albums (amazon_match_status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_id ON tracks (amazon_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_status ON tracks (amazon_match_status)")
cursor.connection.commit()
self._amazon_schema_checked = True
def start(self):
if self.running:
logger.warning("Worker already running")
@ -131,6 +164,7 @@ class AmazonWorker:
try:
conn = self.db._get_connection()
cursor = conn.cursor()
self._ensure_amazon_schema(cursor)
# Priority 1: Unattempted artists
cursor.execute("""
@ -517,6 +551,7 @@ class AmazonWorker:
try:
conn = self.db._get_connection()
cursor = conn.cursor()
self._ensure_amazon_schema(cursor)
cursor.execute("""
SELECT
(SELECT COUNT(*) FROM artists WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
@ -538,6 +573,7 @@ class AmazonWorker:
try:
conn = self.db._get_connection()
cursor = conn.cursor()
self._ensure_amazon_schema(cursor)
progress = {}
for table in ('artists', 'albums', 'tracks'):

View file

@ -68,6 +68,8 @@ def build_source_only_artist_detail(
if source == "spotify" and spotify_client is not None:
sp_artist = spotify_client.get_artist(artist_id, allow_fallback=False)
if sp_artist:
if not artist_name and sp_artist.get("name"):
resolved_name = sp_artist["name"]
source_genres = sp_artist.get("genres") or []
source_followers = (sp_artist.get("followers") or {}).get("total")
if not image_url and sp_artist.get("images"):
@ -75,19 +77,27 @@ def build_source_only_artist_detail(
elif source == "deezer" and deezer_client is not None:
dz_artist = deezer_client.get_artist_info(artist_id)
if dz_artist:
if not artist_name and dz_artist.get("name"):
resolved_name = dz_artist["name"]
source_genres = dz_artist.get("genres") or []
source_followers = (dz_artist.get("followers") or {}).get("total")
elif source == "itunes" and itunes_client is not None:
it_artist = itunes_client.get_artist(artist_id)
if it_artist:
if not artist_name and it_artist.get("name"):
resolved_name = it_artist["name"]
source_genres = it_artist.get("genres") or []
elif source == "discogs" and discogs_client is not None:
dc_artist = discogs_client.get_artist(artist_id)
if dc_artist:
if not artist_name and dc_artist.get("name"):
resolved_name = dc_artist["name"]
source_genres = dc_artist.get("genres") or []
elif source == "amazon" and amazon_client is not None:
az_artist = amazon_client.get_artist(resolved_name or artist_id)
if az_artist:
if not artist_name and az_artist.get("name"):
resolved_name = az_artist["name"]
source_genres = az_artist.get("genres") or []
if not image_url and az_artist.get("images"):
image_url = az_artist["images"][0].get("url")

View file

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

View file

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

View file

@ -379,6 +379,16 @@ def run_service_test(service, test_config):
return False, "Hydrabase not connected. Configure URL + API key and click Connect."
except Exception as e:
return False, f"Hydrabase connection error: {str(e)}"
elif service == "musicbrainz":
try:
from core.metadata.registry import get_musicbrainz_client
mb = get_musicbrainz_client()
results = mb.search_artists("radiohead", limit=1)
if results:
return True, "MusicBrainz reachable"
return False, "MusicBrainz returned no results — may be rate-limited or unreachable."
except Exception as e:
return False, f"MusicBrainz connection error: {str(e)}"
elif service == "soundcloud":
# Anonymous SoundCloud has no auth, so "test" really means
# "is yt-dlp installed and can it reach SoundCloud right now."

View file

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

View file

@ -382,8 +382,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
# Manual library matches are authoritative unless the user explicitly
# requested a force re-download from the normal download modal.
_stid = track_data.get('spotify_track_id') or track_data.get('id', '')
if not ignore_manual_matches and _stid and _mlm.get_match(db, batch_profile_id, batch_source, _stid):
_stid = track_data.get('spotify_track_id') or track_data.get('source_track_id') or track_data.get('id', '')
if not ignore_manual_matches and _stid and _mlm.get_match_for_track(
db, batch_profile_id, track_data, default_source=batch_source
):
logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download")
try:
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)

View file

@ -312,6 +312,7 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
'ui_state': task.get('ui_state', 'normal'), # normal|cancelling|cancelled
'playlist_id': task.get('playlist_id'), # For V2 system identification
'error_message': task.get('error_message'), # Surface failure reasons to UI
'quarantine_entry_id': task.get('quarantine_entry_id'),
'has_candidates': bool(task.get('cached_candidates')), # Whether search found results (for clickable review)
}
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}

View file

@ -35,6 +35,7 @@ from core.imports.context import (
from core.imports.file_integrity import check_audio_integrity, resolve_duration_tolerance
from core.imports.filename import extract_track_number_from_filename
from core.imports.guards import check_flac_bit_depth, move_to_quarantine
from core.imports.quarantine import entry_id_from_quarantined_filename
from core.imports.side_effects import (
emit_track_downloaded,
record_download_provenance,
@ -79,6 +80,26 @@ __all__ = [
]
def _should_skip_quarantine_check(context: dict, check_name: str) -> bool:
bypass = context.get('_skip_quarantine_check')
if bypass == 'all':
return True
if isinstance(bypass, (list, tuple, set)):
return 'all' in bypass or check_name in bypass
return bypass == check_name
def _mark_task_quarantined(context: dict, quarantine_path: str | None) -> None:
if not quarantine_path:
return
task_id = context.get('task_id')
if not task_id:
return
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['quarantine_entry_id'] = entry_id_from_quarantined_filename(quarantine_path)
def build_import_pipeline_runtime(
*,
automation_engine: Any | None = None,
@ -163,11 +184,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
_duration_tolerance_override = resolve_duration_tolerance(
config_manager.get('post_processing.duration_tolerance_seconds', 0)
)
# Per-check quarantine bypass — set by `approve_quarantine_entry`
# when the user explicitly approves a previously-quarantined
# file. Skips ONLY the named check; other gates still run.
_bypass_check = context.get('_skip_quarantine_check')
if _bypass_check == 'integrity':
# User-approved quarantine restores can bypass quarantine gates
# for this one post-processing pass.
if _should_skip_quarantine_check(context, 'integrity'):
logger.info(f"[Integrity] Skipped (user approval) for {_basename}")
integrity = None
else:
@ -193,6 +212,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
automation_engine,
trigger='integrity',
)
_mark_task_quarantined(context, quarantine_path)
logger.error(f"File quarantined due to integrity failure: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting broken file: {file_path}")
@ -227,7 +247,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
f"drift={integrity.checks.get('length_drift_s', 'n/a')})"
)
_skip_acoustid = context.get('_skip_quarantine_check') == 'acoustid'
_skip_acoustid = _should_skip_quarantine_check(context, 'acoustid')
if _skip_acoustid:
logger.info(f"[AcoustID] Skipped (user approval) for {_basename}")
try:
@ -273,6 +293,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
automation_engine,
trigger='acoustid',
)
_mark_task_quarantined(context, quarantine_path)
logger.error(f"File quarantined due to verification failure: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting wrong file: {file_path}")
@ -444,8 +465,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
rejection_reason = None if context.get('_skip_quarantine_check') == 'bit_depth' else check_flac_bit_depth(file_path, context)
if context.get('_skip_quarantine_check') == 'bit_depth':
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth')
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context)
if _skip_bit_depth:
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
@ -456,6 +478,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
automation_engine,
trigger='bit_depth',
)
_mark_task_quarantined(context, quarantine_path)
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
@ -575,8 +598,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
rejection_reason = None if context.get('_skip_quarantine_check') == 'bit_depth' else check_flac_bit_depth(file_path, context)
if context.get('_skip_quarantine_check') == 'bit_depth':
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth')
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context)
if _skip_bit_depth:
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
@ -587,6 +611,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
automation_engine,
trigger='bit_depth',
)
_mark_task_quarantined(context, quarantine_path)
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")

View file

@ -88,6 +88,11 @@ def _entry_id_from_filename(quarantined_filename: str) -> str:
return Path(base).stem
def entry_id_from_quarantined_filename(quarantined_filename: str) -> str:
"""Derive a quarantine entry id from a quarantined filename or path."""
return _entry_id_from_filename(os.path.basename(quarantined_filename))
def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
"""Enumerate quarantined files paired with their sidecars.

View file

@ -42,6 +42,93 @@ def get_match(
return getter(profile_id, source, source_track_id, server_source)
def _first_artist_name(track: dict[str, Any]) -> str:
artists = track.get("artists") or []
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
return (first.get("name") or "").strip()
return str(first).strip()
return (track.get("artist") or track.get("artist_name") or "").strip()
def _track_source_candidates(track: dict[str, Any], default_source: str = "") -> list[str]:
candidates = [
track.get("provider"),
track.get("source"),
default_source,
"spotify",
]
out = []
for source in candidates:
source = (source or "").strip()
if source and source not in out:
out.append(source)
return out
def _track_id_candidates(track: dict[str, Any]) -> list[str]:
candidates = [
track.get("source_track_id"),
track.get("spotify_track_id"),
track.get("track_id"),
track.get("id"),
track.get("musicbrainz_recording_id"),
track.get("deezer_id") or track.get("deezer_track_id"),
track.get("itunes_track_id"),
track.get("tidal_id") or track.get("tidal_track_id"),
track.get("qobuz_id") or track.get("qobuz_track_id"),
track.get("amazon_id") or track.get("amazon_track_id"),
]
out = []
for value in candidates:
value = str(value).strip() if value is not None else ""
if value and value not in out:
out.append(value)
return out
def get_match_for_track(
db,
profile_id: int,
track: dict[str, Any],
*,
default_source: str = "",
server_source: str = "",
) -> Optional[dict]:
"""Return a manual match for a wishlist/sync track.
Exact source+ID matches are preferred, but source labels can legitimately
change between UI surfaces (for example ``mirrored`` in sync history versus
``wishlist`` in the wishlist batch). Fall back to track ID and finally
title/artist so saved manual matches are honored consistently.
"""
if not isinstance(track, dict):
return None
sources = _track_source_candidates(track, default_source)
track_ids = _track_id_candidates(track)
for track_id in track_ids:
for source in sources:
match = get_match(db, profile_id, source, track_id, server_source)
if match:
return match
id_getter = getattr(db, "find_manual_library_match_by_source_track_id", None)
if id_getter is not None:
for track_id in track_ids:
match = id_getter(profile_id, track_id, server_source)
if match:
return match
title = (track.get("name") or track.get("title") or track.get("track_name") or "").strip()
artist = _first_artist_name(track)
metadata_getter = getattr(db, "find_manual_library_match_by_metadata", None)
if metadata_getter is not None and title and artist:
return metadata_getter(profile_id, title, artist, server_source)
return None
def delete_match(db, match_id: int, profile_id: int) -> bool:
"""Delete match by PK id, scoped to profile."""
return db.delete_manual_library_match(match_id, profile_id)

View file

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

View file

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

View file

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

View file

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

View file

@ -678,7 +678,131 @@ class MusicBrainzSearchClient:
return sorted(releases, key=_key)[0]
def get_album(self, album_mbid: str) -> Optional[Dict[str, Any]]:
def is_authenticated(self) -> bool:
return True
def reload_config(self) -> None:
pass
def get_track_features(self, track_id: str) -> None:
return None
def get_user_info(self) -> None:
return None
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Return Spotify-compatible track detail dict by recording MBID."""
try:
rec = self._client.get_recording(track_id, includes=['releases', 'artist-credits', 'release-groups'])
if not rec:
return None
releases = rec.get('releases', []) or []
releases.sort(key=self._release_preference_key)
first_rel = releases[0] if releases else {}
rg = first_rel.get('release-group', {}) or {}
release_id = first_rel.get('id', '')
rg_id = rg.get('id', '')
image_url = self._cached_art(release_id, rg_id)
artists = _extract_artist_credit(rec.get('artist-credit', []))
return {
'id': rec.get('id', ''),
'name': rec.get('title', ''),
'artists': [{'name': a, 'id': ''} for a in artists],
'album': {
'id': rg_id or release_id,
'name': first_rel.get('title', ''),
'images': [{'url': image_url, 'height': 250, 'width': 250}] if image_url else [],
'release_date': first_rel.get('date') or rg.get('first-release-date') or '',
},
'duration_ms': rec.get('length') or 0,
'track_number': 1,
'disc_number': 1,
'preview_url': None,
'popularity': 0,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/recording/{track_id}'},
}
except Exception as e:
logger.error(f'get_track_details({track_id}) error: {e}')
return None
def get_album_tracks(self, album_mbid: str) -> Optional[Dict[str, Any]]:
"""Return {items: [...], total: N} track listing for a release/release-group MBID."""
album = self.get_album(album_mbid, include_tracks=True)
if album is None:
return None
flat = album.get('tracks', [])
if isinstance(flat, dict):
return flat
return {'items': flat, 'total': len(flat)}
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
"""Return Spotify-compatible artist detail dict."""
try:
artist = self._client.get_artist(artist_id, includes=['tags', 'url-rels'])
if not artist:
return None
genres = [t['name'] for t in (artist.get('tags') or []) if isinstance(t, dict) and t.get('name')]
return {
'id': artist.get('id', artist_id),
'name': artist.get('name', ''),
'genres': genres,
'followers': {'total': 0},
'popularity': 0,
'images': [],
'external_urls': {'musicbrainz': f'https://musicbrainz.org/artist/{artist_id}'},
}
except Exception as e:
logger.error(f'get_artist({artist_id}) error: {e}')
return None
def get_artist_top_tracks(self, artist_id: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Return top recordings for an artist, deduplicated by title and sorted by year."""
try:
recs = self._client.search_recordings_by_artist_mbid(artist_id, limit=100)
for r in recs:
rels = r.get('releases') or []
if rels:
rels.sort(key=self._release_preference_key)
r['releases'] = rels
studio = [r for r in recs if self._has_studio_release(r)]
recs = studio or recs
seen: set = set()
deduped = []
for r in recs:
key = (r.get('title') or '').lower().strip()
if not key or key in seen:
continue
seen.add(key)
deduped.append(r)
results = []
for r in deduped[:limit]:
releases = r.get('releases', [])
first_rel = releases[0] if releases else {}
rg = first_rel.get('release-group', {}) or {}
release_id = first_rel.get('id', '')
rg_id = rg.get('id', '')
artists = _extract_artist_credit(r.get('artist-credit', []))
image_url = self._cached_art(release_id, rg_id)
results.append({
'id': r.get('id', ''),
'name': r.get('title', ''),
'artists': [{'name': a, 'id': ''} for a in artists],
'album': {
'id': rg_id or release_id,
'name': first_rel.get('title', ''),
'images': [{'url': image_url}] if image_url else [],
},
'duration_ms': r.get('length') or 0,
'popularity': 0,
'preview_url': None,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/recording/{r.get("id", "")}'},
})
return results
except Exception as e:
logger.error(f'get_artist_top_tracks({artist_id}) error: {e}')
return []
def get_album(self, album_mbid: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
"""Get full album details with track listing for download modal.
The MBID passed in could be either:
@ -713,10 +837,15 @@ class MusicBrainzSearchClient:
album['external_urls'] = {
'musicbrainz': f'https://musicbrainz.org/release-group/{album_mbid}'
}
if not include_tracks:
album.pop('tracks', None)
return album
# Path B: release MBID (text-search fallback path)
return self._render_release_as_album(album_mbid)
album = self._render_release_as_album(album_mbid)
if album and not include_tracks:
album.pop('tracks', None)
return album
except Exception as e:
logger.error(f"MusicBrainz album detail failed for {album_mbid}: {e}")
return None
@ -728,7 +857,7 @@ class MusicBrainzSearchClient:
metadata (type, artist credits) when resolving from a release-group
whose releases may be lightly populated."""
release = self._client.get_release(
release_mbid, includes=['recordings', 'artist-credits', 'release-groups']
release_mbid, includes=['recordings', 'artist-credits', 'release-groups', 'cover-art-archive']
)
if not release:
return None
@ -747,7 +876,18 @@ class MusicBrainzSearchClient:
album_type = _map_release_type(primary_type, secondary_types)
rg_mbid = rg.get('id', '')
image_url = self._cached_art(release_mbid, rg_mbid)
# Use cover-art-archive metadata to pick the right CAA scope.
# release-group scope is preferred (covers all editions), but only
# if the release itself actually has front art — otherwise that URL
# will 404. `cover-art-archive.front` is authoritative with no
# extra network call (returned as part of the release fetch above).
caa = release.get('cover-art-archive') or {}
if caa.get('front'):
image_url = _cover_art_url(release_mbid, scope='release')
elif rg_mbid:
image_url = _cover_art_url(rg_mbid, scope='release-group')
else:
image_url = None
tracks = []
total_tracks = 0
@ -789,7 +929,7 @@ class MusicBrainzSearchClient:
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
}
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single') -> List:
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single', limit: int = 200) -> List:
"""Get artist's releases for discography view."""
try:
artist = self._client.get_artist(artist_mbid, includes=['release-groups'])
@ -814,7 +954,7 @@ class MusicBrainzSearchClient:
image_url=image_url,
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'},
))
return albums
return albums[:limit]
except Exception as e:
logger.warning(f"MusicBrainz artist albums failed: {e}")
return []

View file

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

View file

@ -707,7 +707,8 @@ class SeasonalDiscoveryService:
artist_name,
album_cover_url,
release_date,
popularity
popularity,
source
FROM seasonal_albums
WHERE season_key = ? AND source = ?
ORDER BY popularity DESC, album_name ASC

View file

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

View file

@ -520,12 +520,8 @@ class WatchlistScanner:
return list(get_source_priority(get_primary_source()))
def _discovery_source_priority(self) -> List[str]:
"""Return discovery sources in configured priority order.
Discovery pool writes only support Spotify, iTunes, and Deezer IDs, so
we filter the broader metadata priority list down to those sources.
"""
return [source for source in self._watchlist_source_priority() if source in {'spotify', 'itunes', 'deezer'}]
"""Return discovery sources in configured priority order."""
return [source for source in self._watchlist_source_priority() if source in {'spotify', 'itunes', 'deezer', 'musicbrainz'}]
@staticmethod
def _artist_id_attribute_for_source(source: str) -> Optional[str]:
@ -535,6 +531,7 @@ class WatchlistScanner:
'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id',
'discogs': 'discogs_artist_id',
'musicbrainz': 'musicbrainz_artist_id',
}.get(source)
@staticmethod
@ -544,6 +541,7 @@ class WatchlistScanner:
'spotify': 'similar_artist_spotify_id',
'itunes': 'similar_artist_itunes_id',
'deezer': 'similar_artist_deezer_id',
'musicbrainz': 'similar_artist_musicbrainz_id',
}.get(source)
@staticmethod
@ -574,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."""
@ -904,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%')
@ -959,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:
@ -992,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()
@ -1164,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:
@ -1221,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)
)
@ -1595,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:
@ -1614,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 = {
@ -1621,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:
@ -1780,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.
@ -2216,6 +2233,8 @@ class WatchlistScanner:
album_id = album.id
album_release_date = album.release_date
album_images = album.images if hasattr(album, 'images') else []
if not album_images and hasattr(album, 'image_url') and album.image_url:
album_images = [{'url': album.image_url}]
album_type = album.album_type if hasattr(album, 'album_type') else 'album'
total_tracks = album.total_tracks if hasattr(album, 'total_tracks') else 0
album_artists = album.artists if hasattr(album, 'artists') else []
@ -2372,6 +2391,7 @@ class WatchlistScanner:
'spotify': 'spotify_id',
'itunes': 'itunes_id',
'deezer': 'deezer_id',
'musicbrainz': 'musicbrainz_id',
}
searched_source_ids = {}
available_sources = []
@ -2403,6 +2423,7 @@ class WatchlistScanner:
'spotify_id': None,
'itunes_id': None,
'deezer_id': None,
'musicbrainz_id': None,
'image_url': None,
'genres': [],
'popularity': 0,
@ -2470,6 +2491,8 @@ class WatchlistScanner:
return self.database.update_similar_artist_deezer_id(similar_artist_id, source_id)
if source == 'itunes':
return self.database.update_similar_artist_itunes_id(similar_artist_id, source_id)
if source == 'musicbrainz':
return self.database.update_similar_artist_musicbrainz_id(similar_artist_id, source_id)
return False
def _backfill_similar_artists_fallback_ids(self, source_artist_id: str, profile_id: int = 1) -> int:
@ -2480,7 +2503,7 @@ class WatchlistScanner:
writable similar-artist ID columns. This keeps old cached rows usable
when the active metadata provider changes.
"""
backfill_sources = [source for source in self._discovery_source_priority() if source in {'itunes', 'deezer'}]
backfill_sources = [source for source in self._discovery_source_priority() if source in {'itunes', 'deezer', 'musicbrainz'}]
if not backfill_sources:
logger.debug("No fallback metadata providers available for similar-artist backfill")
return 0
@ -2582,14 +2605,18 @@ class WatchlistScanner:
image_url=similar_artist.get('image_url'),
genres=similar_artist.get('genres'),
popularity=similar_artist.get('popularity', 0),
similar_artist_deezer_id=similar_artist.get('deezer_id')
similar_artist_deezer_id=similar_artist.get('deezer_id'),
similar_artist_musicbrainz_id=similar_artist.get('musicbrainz_id'),
)
if success:
stored_count += 1
fallback_id = similar_artist.get('deezer_id') or similar_artist.get('itunes_id')
fallback_label = 'Deezer' if similar_artist.get('deezer_id') else 'iTunes'
logger.debug(f" #{rank}: {similar_artist['name']} (Spotify: {similar_artist.get('spotify_id')}, {fallback_label}: {fallback_id})")
ids = ', '.join(
f"{k}: {similar_artist.get(v)}"
for k, v in [('Spotify', 'spotify_id'), ('iTunes', 'itunes_id'), ('Deezer', 'deezer_id'), ('MB', 'musicbrainz_id')]
if similar_artist.get(v)
)
logger.debug(f" #{rank}: {similar_artist['name']} ({ids})")
except Exception as e:
logger.warning(f"Error storing similar artist {similar_artist.get('name', 'Unknown')}: {e}")
@ -2685,6 +2712,8 @@ class WatchlistScanner:
cache_callback = lambda found_id, artist_id=similar_artist.id: self.database.update_similar_artist_itunes_id(artist_id, found_id)
elif source == 'deezer':
cache_callback = lambda found_id, artist_id=similar_artist.id: self.database.update_similar_artist_deezer_id(artist_id, found_id)
elif source == 'musicbrainz':
cache_callback = lambda found_id, artist_id=similar_artist.id: self.database.update_similar_artist_musicbrainz_id(artist_id, found_id)
artist_id = self._resolve_artist_id_for_source(
source,
@ -2820,7 +2849,7 @@ class WatchlistScanner:
track_data['deezer_track_id'] = track.get('id')
track_data['deezer_album_id'] = album_data.get('id')
track_data['deezer_artist_id'] = selected_artist_id
else:
elif selected_source == 'itunes':
track_data['itunes_track_id'] = track.get('id')
track_data['itunes_album_id'] = album_data.get('id')
track_data['itunes_artist_id'] = selected_artist_id
@ -2954,7 +2983,7 @@ class WatchlistScanner:
track_data['deezer_track_id'] = track.get('id')
track_data['deezer_album_id'] = album_data.get('id')
track_data['deezer_artist_id'] = artist_id_for_genres or ''
else:
elif db_source == 'itunes':
track_data['itunes_track_id'] = track.get('id')
track_data['itunes_album_id'] = album_data.get('id')
track_data['itunes_artist_id'] = artist_id_for_genres or ''
@ -3176,7 +3205,7 @@ class WatchlistScanner:
track_data['deezer_track_id'] = track['id']
track_data['deezer_album_id'] = album_data['id']
track_data['deezer_artist_id'] = selected_artist_id
else:
elif selected_source == 'itunes':
track_data['itunes_track_id'] = track['id']
track_data['itunes_album_id'] = album_data['id']
track_data['itunes_artist_id'] = selected_artist_id
@ -3351,6 +3380,8 @@ class WatchlistScanner:
selected_watchlist_id = artist.itunes_artist_id or artist_id
elif source == 'deezer':
selected_watchlist_id = getattr(artist, 'deezer_artist_id', None) or artist_id
elif source == 'musicbrainz':
selected_watchlist_id = artist_id
break
if not selected_source or not selected_artist_id or not selected_albums:
@ -3384,6 +3415,8 @@ class WatchlistScanner:
cache_callback = lambda found_id, similar_id=artist.id: self.database.update_similar_artist_itunes_id(similar_id, found_id)
elif source == 'deezer':
cache_callback = lambda found_id, similar_id=artist.id: self.database.update_similar_artist_deezer_id(similar_id, found_id)
elif source == 'musicbrainz':
cache_callback = lambda found_id, similar_id=artist.id: self.database.update_similar_artist_musicbrainz_id(similar_id, found_id)
artist_id = self._resolve_artist_id_for_source(
source,
@ -3415,6 +3448,8 @@ class WatchlistScanner:
selected_similar_id = artist.similar_artist_itunes_id or artist_id
elif source == 'deezer':
selected_similar_id = getattr(artist, 'similar_artist_deezer_id', None) or artist_id
elif source == 'musicbrainz':
selected_similar_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist_id
break
if not selected_source or not selected_artist_id or not selected_albums:

View file

@ -256,8 +256,7 @@ def remove_tracks_already_in_library(
continue
# Manual match check — skip fuzzy search if user already linked this track.
_track_source = track.get('provider') or 'spotify'
if _mlm.get_match(music_database, profile_id, _track_source, spotify_track_id):
if _mlm.get_match_for_track(music_database, profile_id, track, default_source='wishlist'):
try:
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
if removed:

View file

@ -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]
@ -4219,6 +4247,64 @@ class MusicDatabase:
logger.error(f"get_manual_library_match error: {e}")
return None
def find_manual_library_match_by_source_track_id(self, profile_id: int,
source_track_id: str,
server_source: str = '') -> Optional[Dict[str, Any]]:
"""Return a manual match for this source track ID across source labels.
The UI may save a match from sync history as ``mirrored`` while the
wishlist/download flow later sees the same track under ``wishlist`` or
the provider name. The source remains useful metadata, but the stored
track ID is the stable identity we need to honor.
"""
if not source_track_id:
return None
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM manual_library_track_matches
WHERE profile_id = ?
AND source_track_id = ?
AND (server_source = ? OR server_source = '')
ORDER BY
CASE WHEN server_source = ? THEN 0 ELSE 1 END,
updated_at DESC
LIMIT 1
""", (profile_id, source_track_id, server_source or '', server_source or ''))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"find_manual_library_match_by_source_track_id error: {e}")
return None
def find_manual_library_match_by_metadata(self, profile_id: int,
source_title: str,
source_artist: str,
server_source: str = '') -> Optional[Dict[str, Any]]:
"""Return a manual match by title/artist when provider IDs differ."""
if not source_title or not source_artist:
return None
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM manual_library_track_matches
WHERE profile_id = ?
AND source_title = ? COLLATE NOCASE
AND source_artist = ? COLLATE NOCASE
AND (server_source = ? OR server_source = '')
ORDER BY
CASE WHEN server_source = ? THEN 0 ELSE 1 END,
updated_at DESC
LIMIT 1
""", (profile_id, source_title, source_artist, server_source or '', server_source or ''))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"find_manual_library_match_by_metadata error: {e}")
return None
def delete_manual_library_match(self, match_id: int, profile_id: int) -> bool:
"""Delete match by PK id, scoped to profile_id."""
try:
@ -7315,12 +7401,12 @@ class MusicDatabase:
logger.error("Cannot add track to wishlist: missing track ID")
return False
track_source = spotify_track_data.get('provider') or spotify_track_data.get('source') or 'spotify'
if self.get_manual_library_match(profile_id, track_source, track_id):
from core.library import manual_library_match as _mlm
if _mlm.get_match_for_track(self, profile_id, spotify_track_data):
logger.info(
"Skipping wishlist add for manually matched track: '%s' (%s:%s)",
spotify_track_data.get('name', 'Unknown Track'),
track_source,
spotify_track_data.get('provider') or spotify_track_data.get('source') or 'unknown',
track_id,
)
return True
@ -7677,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
@ -7690,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"""
@ -7726,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
@ -7742,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()
@ -7750,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()
@ -7773,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()
@ -7782,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
@ -7812,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']
@ -7841,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
@ -7864,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,
@ -8063,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
@ -8150,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:
@ -8202,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,
@ -8228,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()
@ -8261,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:
@ -8270,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()
@ -8297,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:
@ -8341,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"""
@ -8362,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()
@ -8371,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"""
@ -8424,9 +8662,16 @@ class MusicDatabase:
logger.error(f"Error checking similar artists freshness: {e}")
return False # Default to re-fetching on error
def get_top_similar_artists(self, limit: int = 50, profile_id: int = 1, require_source: str = None) -> List[SimilarArtist]:
def get_top_similar_artists(
self,
limit: int = 50,
profile_id: int = 1,
require_source: str = None,
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:
cursor = conn.cursor()
@ -8439,6 +8684,30 @@ 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, musicbrainz_id
FROM artists
WHERE server_source = ?
""", (exclude_library_server,))
library_rows = cursor.fetchall()
library_artist_keys = {
'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
if r['name']
},
}
sql_limit = max(limit * 5, limit + 100)
cursor.execute(f"""
SELECT
@ -8447,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,
@ -8469,11 +8739,26 @@ class MusicDatabase:
occurrence_count DESC,
similarity_rank ASC
LIMIT ?
""", (profile_id, profile_id, limit))
""", (profile_id, profile_id, sql_limit))
rows = cursor.fetchall()
results = []
for row in rows:
if library_artist_keys:
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
genres_raw = row['genres'] if 'genres' in row.keys() else None
try:
genres_list = json.loads(genres_raw) if genres_raw else None
@ -8485,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'],
@ -8493,6 +8779,8 @@ class MusicDatabase:
genres=genres_list,
popularity=row['popularity'] if 'popularity' in row.keys() else 0,
))
if len(results) >= limit:
break
return results
except Exception as e:
@ -10152,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}, ?)")

View file

@ -1,56 +1,57 @@
## Summary
Adds a new **Manual Library Match** tool that lets users map a wishlist/sync source track to an existing library track. Once saved, SoulSync can treat that source track as already owned/found instead of repeatedly trying to download it.
Adds source-aware artist detail deep links so artist pages can be opened directly as `/artist-detail/:source/:id`, including metadata-source artists from Spotify, Deezer, iTunes, Discogs, Amazon, Hydrabase, and existing library artists.
This also fixes Discover/download modal artist links that were falling back to `/artist-detail/library/<artist name>` or using the wrong album/card ID as an artist ID.
## What Changed
- Added a centralized Manual Library Match tool on the Tools page and a shortcut from the Sync page.
- Added side-by-side source-track and library-track search UI, plus an existing matches table with removal support.
- Added `manual_library_track_matches` persistence with profile/source/source-track scoping.
- Added backend search/list/save/delete endpoints for manual matches.
- Integrated manual matches into download analysis so matched source tracks are marked found and skipped.
- Preserved the distinction between wishlist internal force mode and explicit user Force Download All:
- Wishlist/internal `force_download_all` still honors manual matches.
- User-facing Force Download All ignores manual matches and downloads anyway.
- Updated wishlist cleanup so manual matches are removed from the wishlist through:
- manual wishlist cleanup,
- automatic cleanup after DB update,
- wishlist download analysis.
- Prevented manually matched source tracks from being re-added to the wishlist in the common database add path.
- Polished the Tools page card so Manual Library Match visually matches the Discovery Pool tool card.
- Added canonical artist detail routes in the SPA:
- `/artist-detail/library/<library_artist_id>`
- `/artist-detail/spotify/<spotify_artist_id>`
- `/artist-detail/deezer/<deezer_artist_id>`
- `/artist-detail/itunes/<itunes_artist_id>`
- plus other supported metadata sources.
- Preserved legacy `/artist-detail/<id>` behavior as a library fallback.
- Updated shell routing and deep-link activation so refresh/direct navigation works for nested artist-detail URLs.
- Updated artist detail navigation to carry `artistSource` through the SPA instead of relying only on artist name/id.
- Improved source-only artist detail loading so provider-fetched artist names are used when the URL only contains the source ID.
- Prevented source-only artist pages from running library-only ownership/enhancement checks.
- Treats unknown ownership on source-only discographies as missing/clickable instead of leaving cards stuck on "still checking ownership."
- Uses release artwork as a generic artist-detail hero fallback when an artist portrait is missing or fails to load.
- Preserved source/artist IDs from Discover album modals, seasonal albums, cached discovery albums, recent releases, and download modal hero links.
- Prevented modal artist links from falling back to fake library routes when a real source artist ID is unavailable.
- Returned seasonal album `source` from cached seasonal album rows so seasonal modal links retain their provider context.
## Behavior
- Manual match saved:
- `source + source_track_id + profile_id -> library_track_id`
- Wishlist cleanup:
- removes the item if a manual match exists.
- Wishlist add:
- skips inserting the item if a manual match already exists, returning a harmless success/no-op.
- Wishlist download:
- checks manual match first, marks found, skips download, and attempts wishlist removal.
- Normal download with Force Download All off:
- checks manual match before normal library matching.
- Normal download with Force Download All on:
- skips manual matches and downloads selected tracks intentionally.
- Clicking a Spotify artist result can now land on:
- `/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg`
- Clicking a Deezer artist result can now land on:
- `/artist-detail/deezer/525046`
- Existing library artist links continue to resolve through the library path.
- If a source artist resolves to an existing library artist, the page upgrades to the library-backed artist and keeps library-only tools/checks available.
- If a source artist is not in the library, the page shows source discography as missing/clickable and skips library-only endpoints.
- If a modal lacks a trustworthy source artist ID, it shows a warning instead of navigating to an invalid library artist URL.
## Tests
Verified by user:
Frontend route tests:
```bash
./.venv/bin/python -m pytest tests/test_manual_library_match.py tests/downloads/test_downloads_master.py
cd webui
npm.cmd test -- --run src/platform/shell/route-manifest.test.ts src/platform/shell/bridge.test.ts
```
Result:
```text
43 passed in 10.29s
2 test files passed
10 tests passed
```
Additional local verification:
Recommended backend verification:
```text
py_compile passed for touched Python files
node --check passed for touched JS
```bash
./.venv/bin/python -m pytest tests/test_spa_deep_linking.py tests/metadata/test_artist_source_detail.py
```

View file

@ -0,0 +1,114 @@
from database.music_database import MusicDatabase
def _names(artists):
return {artist.similar_artist_name for artist in artists}
def test_top_similar_artists_can_exclude_active_server_library_artists(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Owned By Spotify ID",
similar_artist_spotify_id="sp-owned",
profile_id=1,
)
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Owned By Deezer ID",
similar_artist_deezer_id="dz-owned",
profile_id=1,
)
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Owned By MusicBrainz ID",
similar_artist_musicbrainz_id="mb-owned",
profile_id=1,
)
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Owned By Name",
similar_artist_spotify_id="sp-owned-name",
profile_id=1,
)
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Different Server Artist",
similar_artist_spotify_id="sp-other-server",
profile_id=1,
)
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Fresh Artist",
similar_artist_spotify_id="sp-fresh",
profile_id=1,
)
with db._get_connection() as conn:
conn.executemany(
"""
INSERT INTO artists (name, server_source, spotify_artist_id, deezer_id, musicbrainz_id)
VALUES (?, ?, ?, ?, ?)
""",
[
("Library Alias", "navidrome", "sp-owned", None, None),
("Library Deezer Alias", "navidrome", None, "dz-owned", None),
("Library MusicBrainz Alias", "navidrome", None, None, "mb-owned"),
("owned by name", "navidrome", None, None, None),
("Different Server Artist", "plex", "sp-other-server", None, None),
],
)
conn.commit()
artists = db.get_top_similar_artists(
limit=20,
profile_id=1,
exclude_library_server="navidrome",
)
assert _names(artists) == {"Different Server Artist", "Fresh Artist"}
def test_top_similar_artists_can_require_musicbrainz_source(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="MB Artist",
similar_artist_musicbrainz_id="mb-artist",
profile_id=1,
)
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Spotify Only",
similar_artist_spotify_id="sp-artist",
profile_id=1,
)
artists = db.get_top_similar_artists(limit=20, profile_id=1, require_source="musicbrainz")
assert _names(artists) == {"MB Artist"}
assert artists[0].similar_artist_musicbrainz_id == "mb-artist"
def test_top_similar_artists_keeps_existing_behavior_without_library_filter(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
db.add_or_update_similar_artist(
source_artist_id="seed-1",
similar_artist_name="Owned Artist",
similar_artist_spotify_id="sp-owned",
profile_id=1,
)
with db._get_connection() as conn:
conn.execute(
"""
INSERT INTO artists (name, server_source, spotify_artist_id)
VALUES (?, ?, ?)
""",
("Owned Artist", "navidrome", "sp-owned"),
)
conn.commit()
artists = db.get_top_similar_artists(limit=20, profile_id=1)
assert _names(artists) == {"Owned Artist"}

View file

@ -43,6 +43,7 @@ class _FakeDB:
self.album_confidence = album_confidence
self.sync_history_calls = []
self.track_results_calls = []
self.manual_matches = []
def check_track_exists(self, title, artist, confidence_threshold=0.7, server_source=None, album=None):
key = (title.lower().strip(), artist.lower().strip())
@ -71,6 +72,23 @@ class _FakeDB:
def update_sync_history_track_results(self, batch_id, results_json):
self.track_results_calls.append((batch_id, results_json))
def get_manual_library_match(self, profile_id, source, source_track_id, server_source=''):
for match in self.manual_matches:
if (
match["profile_id"] == profile_id
and match["source"] == source
and match["source_track_id"] == source_track_id
and match.get("server_source", "") == server_source
):
return match
return None
def find_manual_library_match_by_source_track_id(self, profile_id, source_track_id, server_source=''):
for match in self.manual_matches:
if match["profile_id"] == profile_id and match["source_track_id"] == source_track_id:
return match
return None
class _DBTrack:
def __init__(self, title):
@ -301,7 +319,7 @@ def test_manual_match_overrides_internal_force_download(monkeypatch):
db = _FakeDB()
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
monkeypatch.setattr(
'core.library.manual_library_match.get_match',
'core.library.manual_library_match.get_match_for_track',
lambda *_args, **_kwargs: {'id': 1, 'library_track_id': 42},
)
@ -324,6 +342,38 @@ def test_manual_match_overrides_internal_force_download(monkeypatch):
assert removed == ['T1']
def test_manual_match_saved_under_mirrored_source_overrides_wishlist_batch(monkeypatch):
"""Wishlist batches honor matches saved from mirrored sync history."""
db = _FakeDB()
db.manual_matches.append({
"id": 1,
"profile_id": 1,
"source": "mirrored",
"source_track_id": "track-abc",
"server_source": "",
"library_track_id": 42,
})
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
removed = []
_seed_batch(
'B2m',
force_download_all=True,
ignore_manual_matches=False,
profile_id=1,
batch_source='wishlist',
)
deps = _build_deps(wishlist_remove=lambda td: removed.append(td.get('name')))
tracks = [{'id': 'track-abc', 'name': 'Coffee Break', 'artists': [{'name': 'Zeds Dead'}], 'provider': 'wishlist'}]
mw.run_full_missing_tracks_process('B2m', 'wishlist', tracks, deps)
assert download_batches['B2m']['queue'] == []
assert download_batches['B2m']['analysis_results'][0]['found'] is True
assert download_batches['B2m']['analysis_results'][0]['match_reason'] == 'manual_library_match'
assert removed == ['Coffee Break']
def test_explicit_force_download_ignores_manual_match(monkeypatch):
"""User-facing Force Download All can intentionally bypass manual matches."""
db = _FakeDB()
@ -331,7 +381,7 @@ def test_explicit_force_download_ignores_manual_match(monkeypatch):
calls = []
monkeypatch.setattr(
'core.library.manual_library_match.get_match',
'core.library.manual_library_match.get_match_for_track',
lambda *_args, **_kwargs: calls.append(True) or {'id': 1, 'library_track_id': 42},
)

View file

@ -140,6 +140,7 @@ def test_task_status_includes_v2_state_fields():
'cancel_requested': True, 'cancel_timestamp': 12345,
'ui_state': 'cancelling', 'playlist_id': 'pl1',
'error_message': 'oh no', 'cached_candidates': [{'x': 1}],
'quarantine_entry_id': '20260514_120000_song',
}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, {}, deps)
@ -149,6 +150,7 @@ def test_task_status_includes_v2_state_fields():
assert t['ui_state'] == 'cancelling'
assert t['playlist_id'] == 'pl1'
assert t['error_message'] == 'oh no'
assert t['quarantine_entry_id'] == '20260514_120000_song'
assert t['has_candidates'] is True

View file

@ -4,10 +4,12 @@ import os
from core.imports.quarantine import (
approve_quarantine_entry,
delete_quarantine_entry,
entry_id_from_quarantined_filename,
list_quarantine_entries,
recover_to_staging,
serialize_quarantine_context,
)
from core.imports.pipeline import _should_skip_quarantine_check
# ──────────────────────────────────────────────────────────────────────
@ -146,6 +148,18 @@ def test_list_swallows_corrupt_sidecar_gracefully(tmp_path):
assert entries[0]["reason"] == "Unknown reason"
def test_entry_id_helper_handles_paths_and_quarantine_suffix():
path = "/music/ss_quarantine/20260514_120000_song.flac.quarantined"
assert entry_id_from_quarantined_filename(path) == "20260514_120000_song"
def test_quarantine_bypass_all_skips_every_gate():
context = {"_skip_quarantine_check": "all"}
assert _should_skip_quarantine_check(context, "integrity") is True
assert _should_skip_quarantine_check(context, "acoustid") is True
assert _should_skip_quarantine_check(context, "bit_depth") is True
# ──────────────────────────────────────────────────────────────────────
# delete_quarantine_entry
# ──────────────────────────────────────────────────────────────────────

View file

@ -147,6 +147,7 @@ class TestPerSourceEnrichment:
def test_spotify_extracts_genres_followers_and_image_fallback(self, _stub_metadata):
spotify = SimpleNamespace(
get_artist=lambda aid, allow_fallback=False: {
"name": "Artist",
"genres": ["alt rock", "emo"],
"followers": {"total": 12345},
"images": [{"url": "https://sp/img.jpg"}],
@ -160,6 +161,26 @@ class TestPerSourceEnrichment:
# image_url falls back to Spotify's image when metadata returned None
assert payload["artist"]["image_url"] == "https://sp/img.jpg"
def test_empty_name_uses_source_artist_name_when_available(self, _stub_metadata):
spotify = SimpleNamespace(
get_artist=lambda aid, allow_fallback=False: {
"name": "Kendrick Lamar",
"genres": [],
"followers": {},
"images": [],
}
)
payload, _ = build_source_only_artist_detail(
"2YZyLoL8N0Wb9xBt1NhZWg", "", "spotify", spotify_client=spotify,
)
assert payload["artist"]["name"] == "Kendrick Lamar"
assert _stub_metadata["last_discog_call"] == (
"2YZyLoL8N0Wb9xBt1NhZWg",
"Kendrick Lamar",
)
def test_deezer_extracts_genres_and_followers(self, _stub_metadata):
deezer = SimpleNamespace(
get_artist_info=lambda aid: {

View file

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

View file

@ -541,7 +541,7 @@ def test_get_album_resolves_release_group_mbid_to_release():
'rg-damn', includes=['releases', 'artist-credits']
)
client._client.get_release.assert_called_once_with(
'rel-official', includes=['recordings', 'artist-credits', 'release-groups']
'rel-official', includes=['recordings', 'artist-credits', 'release-groups', 'cover-art-archive']
)
assert album is not None
assert album['id'] == 'rg-damn' # Canonical ID stays the release-group MBID.

View file

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

View file

@ -79,6 +79,58 @@ def test_get_returns_none_when_absent(db):
assert db.get_manual_library_match(1, "spotify", "nonexistent") is None
def test_get_match_for_track_falls_back_across_source_labels(db):
db.save_manual_library_match(
1,
"mirrored",
"track-abc",
42,
source_title="Coffee Break",
source_artist="Zeds Dead",
)
row = mlm.get_match_for_track(
db,
1,
{
"id": "track-abc",
"name": "Coffee Break",
"artists": [{"name": "Zeds Dead"}],
"provider": "wishlist",
},
default_source="wishlist",
)
assert row is not None
assert row["library_track_id"] == 42
def test_get_match_for_track_falls_back_to_source_title_artist(db):
db.save_manual_library_match(
1,
"mirrored",
"old-id",
42,
source_title="Coffee Break",
source_artist="Zeds Dead",
)
row = mlm.get_match_for_track(
db,
1,
{
"id": "new-id",
"name": "Coffee Break",
"artists": [{"name": "Zeds Dead"}],
"provider": "musicbrainz",
},
default_source="wishlist",
)
assert row is not None
assert row["library_track_id"] == 42
def test_add_to_wishlist_skips_manual_matched_track(db):
db.save_manual_library_match(1, "spotify", "track-abc", 42)
@ -98,6 +150,32 @@ def test_add_to_wishlist_skips_manual_matched_track(db):
assert db.get_wishlist_tracks(profile_id=1) == []
def test_add_to_wishlist_skips_manual_match_saved_from_mirrored_source(db):
db.save_manual_library_match(
1,
"mirrored",
"track-abc",
42,
source_title="Coffee Break",
source_artist="Zeds Dead",
)
ok = db.add_to_wishlist(
track_data={
"id": "track-abc",
"name": "Coffee Break",
"artists": [{"name": "Zeds Dead"}],
"album": {"name": "Coffee Break"},
"provider": "wishlist",
},
failure_reason="Download failed",
profile_id=1,
)
assert ok is True
assert db.get_wishlist_tracks(profile_id=1) == []
def test_get_match_returns_none_when_db_lacks_manual_match_method():
class _MinimalDB:
pass
@ -167,7 +245,7 @@ def test_wishlist_skips_manual_matched_track():
mock_music_db = MagicMock()
mock_music_db.check_track_exists = MagicMock()
with patch("core.library.manual_library_match.get_match", return_value={"id": 1, "library_track_id": 42}):
with patch("core.library.manual_library_match.get_match_for_track", return_value={"id": 1, "library_track_id": 42}):
from core.wishlist.processing import remove_tracks_already_in_library
removed = remove_tracks_already_in_library(
mock_wishlist_svc,
@ -199,7 +277,7 @@ def test_wishlist_falls_through_when_no_match():
mock_music_db = MagicMock()
mock_music_db.check_track_exists.return_value = (None, 0.0)
with patch("core.library.manual_library_match.get_match", return_value=None):
with patch("core.library.manual_library_match.get_match_for_track", return_value=None):
from core.wishlist.processing import remove_tracks_already_in_library
removed = remove_tracks_already_in_library(
mock_wishlist_svc,
@ -273,7 +351,7 @@ def test_master_analysis_marks_found():
mock_deps.start_next_batch_of_downloads = MagicMock()
mock_deps.reset_wishlist_auto_processing = MagicMock()
with patch("core.library.manual_library_match.get_match", return_value={"id": 1, "library_track_id": 42}), \
with patch("core.library.manual_library_match.get_match_for_track", return_value={"id": 1, "library_track_id": 42}), \
patch("database.music_database.MusicDatabase", return_value=mock_db):
run_full_missing_tracks_process(batch_id, "playlist-1", [track_data], mock_deps)
@ -345,7 +423,7 @@ def test_master_analysis_manual_match_wins_over_internal_force_download():
mock_deps.start_next_batch_of_downloads = MagicMock()
mock_deps.reset_wishlist_auto_processing = MagicMock()
with patch("core.library.manual_library_match.get_match", return_value={"id": 1, "library_track_id": 42}), \
with patch("core.library.manual_library_match.get_match_for_track", return_value={"id": 1, "library_track_id": 42}), \
patch("database.music_database.MusicDatabase", return_value=mock_db):
run_full_missing_tracks_process(batch_id, "playlist-1", [track_data], mock_deps)

View file

@ -95,6 +95,18 @@ class TestSpaRoutes:
assert resp.status_code == 200
assert resp.data == b'INDEX_HTML'
def test_artist_detail_with_id_serves_index(self, client):
# /artist-detail/:id deep-links must serve index so the client can restore the artist.
resp = client.get('/artist-detail/42')
assert resp.status_code == 200
assert resp.data == b'INDEX_HTML'
def test_artist_detail_with_source_and_id_serves_index(self, client):
# /artist-detail/:source/:id is the canonical source-aware artist deep-link.
resp = client.get('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')
assert resp.status_code == 200
assert resp.data == b'INDEX_HTML'
# ---------------------------------------------------------------------------
# Group B — Reserved prefixes are not shadowed

View file

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

View file

@ -0,0 +1,63 @@
import sqlite3
from core.amazon_worker import AmazonWorker
class _LegacyDatabase:
def __init__(self, path):
self.path = str(path)
with sqlite3.connect(self.path) as conn:
conn.executescript(
"""
CREATE TABLE artists (
id INTEGER PRIMARY KEY,
name TEXT
);
CREATE TABLE albums (
id INTEGER PRIMARY KEY,
title TEXT,
artist_id INTEGER
);
CREATE TABLE tracks (
id INTEGER PRIMARY KEY,
title TEXT,
artist_id INTEGER
);
INSERT INTO artists (id, name) VALUES (1, 'Artist A');
INSERT INTO albums (id, title, artist_id) VALUES (10, 'Album A', 1);
INSERT INTO tracks (id, title, artist_id) VALUES (100, 'Track A', 1);
"""
)
def _get_connection(self):
return sqlite3.connect(self.path)
def _columns(db_path, table):
with sqlite3.connect(str(db_path)) as conn:
return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
def test_amazon_worker_self_heals_legacy_schema_before_selecting_next_item(tmp_path):
db_path = tmp_path / "legacy.db"
db = _LegacyDatabase(db_path)
worker = AmazonWorker(db)
item = worker._get_next_item()
assert item == {"type": "artist", "id": 1, "name": "Artist A"}
for table in ("artists", "albums", "tracks"):
assert {"amazon_id", "amazon_match_status", "amazon_last_attempted"} <= _columns(db_path, table)
def test_amazon_worker_stats_self_heal_legacy_schema(tmp_path):
db_path = tmp_path / "legacy.db"
db = _LegacyDatabase(db_path)
worker = AmazonWorker(db)
assert worker._count_pending_items() == 3
assert worker._get_progress_breakdown() == {
"artists": {"matched": 0, "total": 1, "percent": 0},
"albums": {"matched": 0, "total": 1, "percent": 0},
"tracks": {"matched": 0, "total": 1, "percent": 0},
}

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

@ -240,7 +240,7 @@ def test_automatic_wishlist_cleanup_after_db_update_removes_manual_matches(monke
)
music_db = _CleanupMusicDatabase()
monkeypatch.setattr(
"core.library.manual_library_match.get_match",
"core.library.manual_library_match.get_match_for_track",
lambda *_args, **_kwargs: {"id": 1, "library_track_id": 42},
)

View file

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

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.5.5"
_SOULSYNC_BASE_VERSION = "2.5.6"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -1859,6 +1859,7 @@ SERVICE_CONFIG_REGISTRY = {
'spotify': {'required': ['client_id', 'client_secret']},
'itunes': {'always': True}, # default storefront works anon
'deezer': {'always': True}, # anon search works, premium ARL is optional
'musicbrainz': {'always': True}, # public API, no credentials required
'amazon': {'always': True}, # T2Tunes proxy, no credentials required
'discogs': {'required': ['token']},
'tidal': {'custom': lambda _svc: _tidal_has_auth_token()},
@ -6836,8 +6837,7 @@ def delete_quarantine_item(entry_id):
@app.route('/api/quarantine/<entry_id>/approve', methods=['POST'])
def approve_quarantine_item(entry_id):
"""One-click approve: restore the file and re-run post-process with the
matching per-check bypass flag set so the original quarantine trigger
is skipped. Other checks still run."""
quarantine gates skipped for this explicit user-approved pass."""
try:
from core.imports.quarantine import approve_quarantine_entry
# Restore inside the soulseek download dir so existing path-resolution
@ -6854,8 +6854,10 @@ def approve_quarantine_item(entry_id):
"error": "Cannot one-click approve — entry has thin sidecar (no embedded context). Use 'Recover to Staging' instead.",
}), 400
restored_path, context, trigger = result
# Mark the bypass so the pipeline skips the trigger that fired.
context['_skip_quarantine_check'] = trigger
# User approval means "import this file"; skip all quarantine gates
# for this one restored pass so multi-reason failures do not loop.
context['_skip_quarantine_check'] = 'all'
context['_approved_quarantine_trigger'] = trigger
# Re-dispatch through the same pipeline. Run async so the HTTP
# request returns quickly — UI polls /list to see the entry vanish.
context_key = f"approve_{entry_id}_{int(time.time())}"
@ -6863,8 +6865,8 @@ def approve_quarantine_item(entry_id):
target=lambda: _post_process_matched_download(context_key, context, restored_path),
daemon=True,
).start()
logger.info(f"[Quarantine] Approved {entry_id} (bypass={trigger}) → re-running pipeline")
return jsonify({"success": True, "trigger_bypassed": trigger})
logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all) → re-running pipeline")
return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger})
except Exception as e:
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@ -18846,8 +18848,12 @@ def get_spotify_album_tracks(album_id):
if not album_data:
return jsonify({"error": "Album not found"}), 404
# Extract tracks from album data (Spotify format)
tracks = album_data.get('tracks', {}).get('items', [])
# Extract tracks — handle Spotify {items, total} or flat-list formats
tracks_container = album_data.get('tracks', {})
if isinstance(tracks_container, list):
tracks = tracks_container
else:
tracks = tracks_container.get('items', [])
# If no tracks in album data (iTunes format), fetch them separately
if not tracks:
@ -24506,6 +24512,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)
@ -24523,6 +24530,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,
@ -24560,7 +24568,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()
@ -24571,6 +24579,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'
@ -24586,12 +24597,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:
@ -25009,7 +25025,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:
@ -25315,6 +25331,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()
@ -25323,10 +25340,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()
@ -25340,6 +25359,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
@ -25382,9 +25402,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]
@ -25405,9 +25432,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],
@ -25448,6 +25483,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(),
})
@ -25471,7 +25507,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
@ -25485,8 +25521,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
@ -25498,11 +25535,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:
@ -25548,7 +25586,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
@ -25562,8 +25600,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:
@ -25574,7 +25613,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:
@ -25879,8 +25925,15 @@ def get_discover_similar_artists():
try:
database = get_database()
active_source = _get_active_discovery_source()
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
similar_artists = database.get_top_similar_artists(limit=200, profile_id=get_current_profile_id(), require_source=active_source)
similar_artists = database.get_top_similar_artists(
limit=200,
profile_id=get_current_profile_id(),
require_source=active_source,
exclude_library_server=active_server,
)
if not similar_artists:
return jsonify({"success": True, "artists": [], "source": active_source, "count": 0})
@ -25893,6 +25946,8 @@ def get_discover_similar_artists():
artist_id = artist.similar_artist_spotify_id
elif active_source == 'deezer':
artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
elif active_source == 'musicbrainz':
artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist.similar_artist_itunes_id
else:
artist_id = artist.similar_artist_itunes_id
@ -25900,6 +25955,7 @@ def get_discover_similar_artists():
"artist_id": artist_id,
"spotify_artist_id": artist.similar_artist_spotify_id,
"itunes_artist_id": artist.similar_artist_itunes_id,
"musicbrainz_artist_id": getattr(artist, 'similar_artist_musicbrainz_id', None),
"artist_name": artist.similar_artist_name,
"occurrence_count": artist.occurrence_count,
"similarity_rank": artist.similarity_rank,
@ -25914,7 +25970,10 @@ def get_discover_similar_artists():
artist_data["popularity"] = artist.popularity
result_artists.append(artist_data)
logger.info(f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for {active_source}")
logger.info(
f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for "
f"{active_source} after excluding {active_server} library artists"
)
return jsonify({
"success": True,
@ -25953,6 +26012,8 @@ def enrich_similar_artists():
ext_id = artist.similar_artist_spotify_id
elif source == 'deezer':
ext_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
elif source == 'musicbrainz':
ext_id = getattr(artist, 'similar_artist_musicbrainz_id', None) or artist.similar_artist_itunes_id
else:
ext_id = artist.similar_artist_itunes_id
if ext_id and ext_id not in cache_map:

View file

@ -3658,10 +3658,11 @@
<option value="itunes">iTunes / Apple Music</option>
<option value="deezer">Deezer</option>
<option value="discogs">Discogs</option>
<option value="musicbrainz">MusicBrainz</option>
</select>
</div>
<div class="callback-info">
<div class="callback-help">Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token.</div>
<div class="callback-help">Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token. MusicBrainz is always available but rate-limited to 1 req/sec.</div>
</div>
</div>
@ -4210,6 +4211,9 @@
<!-- Navidrome Settings -->
<div class="server-config-container hidden" id="navidrome-container">
<div class="setting-help-text">
Navidrome users should enable <strong>Real Path</strong> in the Navidrome player/settings so SoulSync can resolve imported and synced files correctly.
</div>
<div class="form-group">
<label>Navidrome Server URL:</label>
<input type="url" id="navidrome-url" placeholder="http://localhost:4533">
@ -4242,7 +4246,7 @@
<img src="/static/trans2.png" alt="SoulSync" class="soulsync-standalone-logo">
<div class="soulsync-standalone-text">
<h4>Standalone Mode</h4>
<p>SoulSync manages your library directly without an external media server. Downloads and imports are added to the library automatically.</p>
<p>SoulSync manages your library directly without an external media server. Import your existing music first: move files into the import folder, then use a separate music/output folder for the processed library SoulSync creates.</p>
</div>
</div>
<div class="form-actions">

View file

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellProfileContext } from './bridge';
import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, waitForShellContext } from './bridge';
import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, bindWindowWebRouter, waitForShellContext } from './bridge';
describe('waitForShellContext', () => {
beforeEach(() => {
@ -55,3 +55,37 @@ describe('waitForShellContext', () => {
});
});
});
describe('bindWindowWebRouter', () => {
it('navigates artist detail pages with source-aware URLs', async () => {
const navigate = vi.fn().mockResolvedValue(undefined);
bindWindowWebRouter({ navigate } as never);
await window.SoulSyncWebRouter?.navigateToPage('artist-detail', {
artistId: '2YZyLoL8N0Wb9xBt1NhZWg',
artistSource: 'spotify',
});
expect(navigate).toHaveBeenCalledWith({
href: '/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg',
replace: false,
});
});
it('falls back artist detail URLs to library source when none is supplied', async () => {
const navigate = vi.fn().mockResolvedValue(undefined);
bindWindowWebRouter({ navigate } as never);
await window.SoulSyncWebRouter?.navigateToPage('artist-detail', {
artistId: '42',
replace: true,
});
expect(navigate).toHaveBeenCalledWith({
href: '/artist-detail/library/42',
replace: true,
});
});
});

View file

@ -89,10 +89,13 @@ export function bindWindowWebRouter(router: AnyRouter) {
const route = getShellRouteByPageId(pageId);
if (!route) return false;
await router.navigate({
href: route.path,
replace: options?.replace === true,
});
let href: `/${string}` = route.path;
if (pageId === 'artist-detail' && options?.artistId) {
const source = options.artistSource ? String(options.artistSource) : 'library';
href = `/artist-detail/${encodeURIComponent(source)}/${encodeURIComponent(String(options.artistId))}` as `/${string}`;
}
await router.navigate({ href, replace: options?.replace === true });
return true;
},
};

View file

@ -23,6 +23,8 @@ declare global {
pageId: ShellPageId,
options?: {
replace?: boolean;
artistId?: string | number;
artistSource?: string | null;
},
) => Promise<boolean>;
};

View file

@ -17,6 +17,7 @@ describe('shellRouteManifest', () => {
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveShellPageFromPath('/artist-detail')).toBe('artist-detail');
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe('artist-detail');
expect(resolveShellPageFromPath('/artists')).toBeNull();
});
@ -49,6 +50,7 @@ describe('shellRouteManifest', () => {
expect(resolveLegacyShellPageFromPath('/search')).toBe('search');
expect(resolveLegacyShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveLegacyShellPageFromPath('/tools')).toBe('tools');
expect(resolveLegacyShellPageFromPath('/artist-detail/deezer/12345')).toBe('artist-detail');
expect(resolveLegacyShellPageFromPath('/issues')).toBeNull();
expect(resolveLegacyShellPageFromPath('/does-not-exist')).toBeNull();
});

View file

@ -71,10 +71,18 @@ export function getShellRouteByPath(pathname: string): ShellRouteDefinition | un
}
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
const normalized = normalizeShellPath(pathname);
if (normalized === '/artist-detail' || normalized.startsWith('/artist-detail/')) {
return 'artist-detail';
}
return getShellRouteByPath(pathname)?.pageId ?? null;
}
export function resolveLegacyShellPageFromPath(pathname: string): ShellPageId | null {
const normalized = normalizeShellPath(pathname);
if (normalized === '/artist-detail' || normalized.startsWith('/artist-detail/')) {
return 'artist-detail';
}
const route = getShellRouteByPath(pathname);
return route?.kind === 'legacy' ? route.pageId : null;
}

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.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.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>');
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 `
<div class="watchlist-artist-card"
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.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.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>');
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 `
<div class="watchlist-artist-card"
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.
* 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 content = document.getElementById('watchlist-linked-provider-content');
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: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' },
{ 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' },
];
@ -1980,7 +1983,7 @@ function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesI
function _openSourceSearch(sourceKey, artistId, artistName) {
const panel = document.getElementById('wl-linked-search-panel');
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}`;
const input = document.getElementById('wl-linked-search-input');
input.value = artistName;
@ -2108,10 +2111,10 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
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)
_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
let globalOverrideActive = false;
@ -2178,10 +2181,11 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
{ key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' },
{ key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' },
{ 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 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})">
<span class="config-msrc-icon">🌐</span><span class="config-msrc-label">Default (${globalLabel})</span>
@ -2283,7 +2287,7 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
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
const existing = document.querySelector('.watchlist-artist-detail-overlay');
@ -2416,11 +2420,13 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
discogId = discogs_artist_id; source = 'discogs';
} else if (activeSrc.includes('deezer') && deezer_artist_id) {
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) {
discogId = itunes_artist_id; source = 'itunes';
} else {
discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || itunes_artist_id;
source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes';
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' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes';
}
if (discogId) {
closeWatchlistArtistDetailView();

View file

@ -465,7 +465,13 @@ function initializeWebSocket() {
// Phase 5 event listeners (sync/discovery progress + scans)
socket.on('sync:progress', (data) => updateSyncProgressFromData(data));
socket.on('discovery:progress', (data) => updateDiscoveryProgressFromData(data));
socket.on('scan:watchlist', (data) => updateWatchlistScanFromData(data));
socket.on('scan:watchlist', (data) => {
updateWatchlistScanFromData(data);
const watchlistBtn = document.querySelector('.nav-button[data-page="watchlist"]');
if (watchlistBtn) {
watchlistBtn.classList.toggle('nav-watchlist-scanning', data.status === 'scanning');
}
});
socket.on('scan:media', (data) => updateMediaScanFromData(data));
socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data));
// Phase 6: Automation progress

View file

@ -387,6 +387,7 @@ async function watchAllHeroArtists(btn) {
// Cache for recommended artists data so reopening is instant
let _recommendedArtistsCache = null;
let _recommendedArtistsSource = null;
async function openRecommendedArtistsModal() {
let modal = document.getElementById('recommended-artists-modal');
@ -404,7 +405,7 @@ async function openRecommendedArtistsModal() {
// If cached, render instantly and refresh watchlist statuses
if (_recommendedArtistsCache) {
modal.style.display = 'flex';
renderRecommendedArtistsModal(modal, _recommendedArtistsCache);
renderRecommendedArtistsModal(modal, _recommendedArtistsCache, _recommendedArtistsSource);
checkRecommendedWatchlistStatuses(_recommendedArtistsCache);
return;
}
@ -444,13 +445,14 @@ async function openRecommendedArtistsModal() {
return;
}
// Render cards immediately with fallback images
_recommendedArtistsCache = data.artists;
renderRecommendedArtistsModal(modal, data.artists);
// Phase 2: Enrich with images/genres progressively in batches of 50
// Skip artists that already have cached metadata from the initial response
const source = data.source || 'spotify';
// Render cards immediately with fallback images
_recommendedArtistsCache = data.artists;
_recommendedArtistsSource = source;
renderRecommendedArtistsModal(modal, data.artists, source);
const idKey = source === 'spotify' ? 'spotify_artist_id' : source === 'deezer' ? 'deezer_artist_id' : 'itunes_artist_id';
const allIds = data.artists
.filter(a => !a.image_url) // Only enrich artists without cached images
@ -521,7 +523,7 @@ async function openRecommendedArtistsModal() {
}
}
function renderRecommendedArtistsModal(modal, artists) {
function renderRecommendedArtistsModal(modal, artists, source = null) {
modal.innerHTML = `
<div class="modal-container playlist-modal recommended-modal">
<div class="playlist-modal-header">
@ -555,10 +557,12 @@ function renderRecommendedArtistsModal(modal, artists) {
const similarText = artist.occurrence_count > 1
? `Similar to ${artist.occurrence_count} in your watchlist`
: 'Similar to an artist in your watchlist';
const artistSource = artist.source || source || _recommendedArtistsSource || '';
return `
<div class="recommended-artist-card"
data-artist-name="${escapeHtml(artist.artist_name).toLowerCase()}"
data-artist-id="${artist.artist_id}">
data-artist-id="${artist.artist_id}"
data-artist-source="${escapeHtml(artistSource)}">
<button class="recommended-card-watchlist-btn"
data-artist-id="${artist.artist_id}"
data-artist-name="${escapeHtml(artist.artist_name)}">
@ -601,9 +605,10 @@ function renderRecommendedArtistsModal(modal, artists) {
const card = e.target.closest('.recommended-artist-card');
if (card) {
const artistId = card.getAttribute('data-artist-id');
const artistSource = card.getAttribute('data-artist-source') || _recommendedArtistsSource || null;
const nameEl = card.querySelector('.recommended-card-name');
const artistName = nameEl ? nameEl.textContent : '';
viewRecommendedArtistDiscography(artistId, artistName);
viewRecommendedArtistDiscography(artistId, artistName, artistSource);
}
});
}
@ -734,9 +739,9 @@ async function checkRecommendedWatchlistStatuses(artists) {
}
}
async function viewRecommendedArtistDiscography(artistId, artistName) {
async function viewRecommendedArtistDiscography(artistId, artistName, source = null) {
closeRecommendedArtistsModal();
navigateToArtistDetail(artistId, artistName);
navigateToArtistDetail(artistId, artistName, source || null);
}
async function checkAllHeroWatchlistStatus() {
@ -3940,6 +3945,35 @@ function hideSeasonalSections() {
}
}
function _buildDiscoverArtistContext(source, artistName, sourceData = {}, albumData = {}) {
const normalizedSource = (source || '').toString().toLowerCase();
const albumArtist = Array.isArray(albumData.artists) ? albumData.artists[0] : null;
const activeSource = (source || sourceData.active_source || sourceData.source || '').toString().toLowerCase();
const context = {
...sourceData,
id: sourceData.active_source_id || sourceData.artist_id || albumArtist?.id || '',
name: artistName || sourceData.artist_name || sourceData.name || albumArtist?.name || '',
source: normalizedSource || activeSource || '',
spotify_artist_id: sourceData.spotify_artist_id || sourceData.artist_spotify_id || ((normalizedSource || activeSource) === 'spotify' ? albumArtist?.id : '') || '',
itunes_artist_id: sourceData.itunes_artist_id || sourceData.artist_itunes_id || ((normalizedSource || activeSource) === 'itunes' ? albumArtist?.id : '') || '',
deezer_artist_id: sourceData.deezer_artist_id || sourceData.artist_deezer_id || sourceData.deezer_id || ((normalizedSource || activeSource) === 'deezer' ? albumArtist?.id : '') || '',
discogs_artist_id: sourceData.discogs_artist_id || sourceData.artist_discogs_id || sourceData.discogs_id || '',
amazon_artist_id: sourceData.amazon_artist_id || sourceData.artist_amazon_id || sourceData.amazon_id || '',
soul_id: sourceData.soul_id || sourceData.hydrabase_artist_id || '',
};
const sourceIdBySource = {
spotify: context.spotify_artist_id,
itunes: context.itunes_artist_id,
deezer: context.deezer_artist_id,
discogs: context.discogs_artist_id,
amazon: context.amazon_artist_id,
hydrabase: context.soul_id,
};
context.id = sourceIdBySource[normalizedSource || activeSource] || context.id;
return context;
}
async function openDownloadModalForSeasonalAlbum(albumIndex) {
const album = discoverSeasonalAlbums[albumIndex];
if (!album) {
@ -3999,14 +4033,12 @@ async function openDownloadModalForSeasonalAlbum(albumIndex) {
const virtualPlaylistId = `seasonal_album_${albumId}`;
// Pass proper artist/album context for album download (1 worker + source reuse)
const artistContext = {
name: album.artist_name,
source: source
};
const artistContext = _buildDiscoverArtistContext(source, album.artist_name, album, albumData);
const albumContext = {
id: albumData.id,
name: albumData.name,
source: source,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
@ -4354,6 +4386,31 @@ async function unblockDiscoveryArtist(id, name) {
let _yourArtistsCtrl = null;
function _pickArtistDetailSource(artist) {
if (!artist) return { id: '', source: '' };
const sourceFields = {
spotify: 'spotify_artist_id',
deezer: 'deezer_artist_id',
itunes: 'itunes_artist_id',
discogs: 'discogs_artist_id',
amazon: 'amazon_artist_id',
musicbrainz: 'musicbrainz_artist_id',
hydrabase: 'soul_id',
};
const active = String(artist.active_source || artist.source || '').toLowerCase();
const activeField = sourceFields[active];
if (activeField && artist[activeField]) {
return { id: String(artist[activeField]), source: active };
}
for (const [source, field] of Object.entries(sourceFields)) {
if (artist[field]) return { id: String(artist[field]), source };
}
if (artist.active_source_id && activeField) {
return { id: String(artist.active_source_id), source: active };
}
return { id: '', source: '' };
}
async function loadYourArtists() {
if (!_yourArtistsCtrl) {
_yourArtistsCtrl = createDiscoverSectionController({
@ -4454,11 +4511,12 @@ function _renderYourArtistCard(artist) {
).join('');
const watchlistClass = artist.on_watchlist ? 'active' : '';
const hasId = artist.active_source_id && artist.active_source_id !== '';
const detailSource = _pickArtistDetailSource(artist);
const hasId = detailSource.id && detailSource.id !== '';
// Navigate to Artists page (name click) — source artist id, needs inline view
const navAction = hasId
? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')`
? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(detailSource.id)}', '${escapeForInlineJs(artist.artist_name)}', '${escapeForInlineJs(detailSource.source)}' || null)`
: '';
// Open info modal (card body click) — pass pool ID so we can look up all data
@ -6724,11 +6782,12 @@ async function openYourArtistInfoModal_direct(node) {
let bestId = '', bestSource = '';
// Check what the active source is
const activeSource = window._yaActiveSource || 'spotify';
const sourceOrder = activeSource === 'spotify' ? ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id']
: activeSource === 'itunes' ? ['itunes_id', 'spotify_id', 'deezer_id', 'discogs_id']
: activeSource === 'deezer' ? ['deezer_id', 'spotify_id', 'itunes_id', 'discogs_id']
: ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id'];
const sourceMap = { spotify_id: 'spotify', itunes_id: 'itunes', deezer_id: 'deezer', discogs_id: 'discogs' };
const sourceOrder = activeSource === 'spotify' ? ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id', 'musicbrainz_id']
: activeSource === 'itunes' ? ['itunes_id', 'spotify_id', 'deezer_id', 'discogs_id', 'musicbrainz_id']
: activeSource === 'deezer' ? ['deezer_id', 'spotify_id', 'itunes_id', 'discogs_id', 'musicbrainz_id']
: activeSource === 'musicbrainz' ? ['musicbrainz_id', 'spotify_id', 'itunes_id', 'deezer_id', 'discogs_id']
: ['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) {
if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; }
}
@ -6955,7 +7014,7 @@ async function openCacheDiscoverAlbum(sectionKey, index) {
duration_ms: track.duration_ms || 0, track_number: track.track_number || 0,
};
});
const artistContext = { id: albumData.artists?.[0]?.id || '', name: artistName, source: resolvedSource };
const artistContext = _buildDiscoverArtistContext(resolvedSource, artistName, item, albumData);
const albumContext = { id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', images: albumData.images || [] };
await openDownloadMissingModalForYouTube(`discover_cache_${resolvedId}`, albumData.name, spotifyTracks, artistContext, albumContext);
hideLoadingOverlay();
@ -7015,11 +7074,7 @@ async function openCacheDiscoverAlbum(sectionKey, index) {
};
});
const artistContext = {
id: albumData.artists?.[0]?.id || '',
name: item.artist_name || albumData.artists?.[0]?.name || '',
source: source,
};
const artistContext = _buildDiscoverArtistContext(source, item.artist_name || albumData.artists?.[0]?.name || '', item, albumData);
const albumContext = {
id: albumData.id,
name: albumData.name,
@ -7939,11 +7994,7 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
const virtualPlaylistId = `discover_album_${albumId}`;
// CRITICAL FIX: Pass proper artist/album context for modal display
const artistContext = {
id: source === 'spotify' ? album.artist_spotify_id : source === 'deezer' ? album.artist_deezer_id : album.artist_itunes_id,
name: album.artist_name,
source: source
};
const artistContext = _buildDiscoverArtistContext(source, album.artist_name, album, albumData);
const albumContext = {
id: albumData.id,

View file

@ -501,7 +501,10 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
const heroContext = isDiscoverAlbum && album && artist ? {
type: 'album',
artist: {
...artist,
name: artist.name,
id: artist.id || artist.artist_id || null,
source: artist.source || album.source || null,
image_url: artist.image_url || null
},
album: {
@ -633,8 +636,43 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, playlistId) {
if (!artistName) return;
// Close the download modal
const process = playlistId ? activeDownloadProcesses[playlistId] : null;
const artistContext = process?.artist || {};
const inferredSource = artistContext.spotify_artist_id ? 'spotify'
: artistContext.itunes_artist_id ? 'itunes'
: (artistContext.deezer_artist_id || artistContext.deezer_id) ? 'deezer'
: (artistContext.discogs_artist_id || artistContext.discogs_id) ? 'discogs'
: (artistContext.amazon_artist_id || artistContext.amazon_id) ? 'amazon'
: (artistContext.soul_id || artistContext.hydrabase_artist_id) ? 'hydrabase'
: null;
const resolvedSource = source || process?.artist?.source || process?.album?.source || process?.source || inferredSource;
const sourceKey = (resolvedSource || '').toString().toLowerCase();
const sourceIdFields = {
spotify: ['spotify_artist_id', 'id', 'artist_id'],
itunes: ['itunes_artist_id', 'artist_id', 'id'],
deezer: ['deezer_artist_id', 'deezer_id', 'artist_id', 'id'],
discogs: ['discogs_artist_id', 'discogs_id', 'artist_id', 'id'],
amazon: ['amazon_artist_id', 'amazon_id', 'artist_id', 'id'],
hydrabase: ['soul_id', 'hydrabase_artist_id', 'artist_id', 'id'],
musicbrainz: ['musicbrainz_id', 'artist_id', 'id'],
};
let resolvedArtistId = artistId;
for (const field of (sourceIdFields[sourceKey] || ['artist_id', 'id'])) {
const candidate = artistContext?.[field];
if (candidate) {
resolvedArtistId = candidate;
break;
}
}
if (resolvedArtistId && String(resolvedArtistId).toLowerCase() === String(artistName).toLowerCase()) {
resolvedArtistId = null;
}
if (playlistId) closeDownloadMissingModal(playlistId);
navigateToArtistDetail(artistId || artistName, artistName, source || null);
if (!resolvedArtistId || !resolvedSource) {
showToast(`Artist details are not available for ${artistName}`, 'warning');
return;
}
navigateToArtistDetail(resolvedArtistId, artistName, resolvedSource);
}
async function closeDownloadMissingModal(playlistId) {
@ -1508,46 +1546,54 @@ async function selectWishlistCategory(category) {
// Sort tracks by track number
albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber);
const tracksListHTML = albumData.tracks.map(track => `
const tracksListHTML = albumData.tracks.map(track => {
const safeTrackId = escapeHtml(track.spotifyTrackId || '');
const safeTrackName = escapeHtml(track.name || 'Unknown Track');
return `
<div class="wishlist-album-track wishlist-track-item">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-select-cb"
data-track-id="${track.spotifyTrackId}">
data-track-id="${safeTrackId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<span class="wishlist-album-track-name">${track.name}</span>
<button class="wishlist-delete-btn wishlist-delete-btn-small" data-track-id="${track.spotifyTrackId}" title="Remove from wishlist">
<span class="wishlist-album-track-name">${safeTrackName}</span>
<button class="wishlist-delete-btn wishlist-delete-btn-small" data-track-id="${safeTrackId}" title="Remove from wishlist">
🗑
</button>
</div>
`).join('');
`;
}).join('');
// Handle missing album images with a placeholder
const safeAlbumId = escapeHtml(albumId);
const safeAlbumName = escapeHtml(albumData.albumName || 'Unknown Album');
const safeArtistName = escapeHtml(albumData.artistName || 'Unknown Artist');
const safeAlbumImage = escapeHtml(albumData.albumImage || '').replace(/'/g, '&#39;');
const albumImageStyle = albumData.albumImage
? `background-image: url('${albumData.albumImage}')`
? `background-image: url('${safeAlbumImage}')`
: `background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(50, 50, 50, 0.9) 100%); display: flex; align-items: center; justify-content: center; font-size: 40px;`;
const albumImageContent = albumData.albumImage ? '' : '<span style="opacity: 0.3;">💿</span>';
albumsHTML += `
<div class="wishlist-album-card">
<div class="wishlist-album-header" data-album-id="${albumId}">
<div class="wishlist-album-header" data-album-id="${safeAlbumId}">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-album-select-all-cb"
data-album-id="${albumId}">
data-album-id="${safeAlbumId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<div class="wishlist-album-image" style="${albumImageStyle}">${albumImageContent}</div>
<div class="wishlist-album-info">
<div class="wishlist-album-name">${albumData.albumName}</div>
<div class="wishlist-album-artist">${albumData.artistName}</div>
<div class="wishlist-album-name">${safeAlbumName}</div>
<div class="wishlist-album-artist">${safeArtistName}</div>
<div class="wishlist-album-track-count">${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}</div>
</div>
<button class="wishlist-delete-btn wishlist-delete-album-btn" data-album-id="${albumId}" title="Remove all tracks from album">
<button class="wishlist-delete-btn wishlist-delete-album-btn" data-album-id="${safeAlbumId}" title="Remove all tracks from album">
🗑
</button>
<div class="wishlist-album-expand-icon" id="expand-icon-${albumId}"></div>
</div>
<div class="wishlist-album-tracks" id="tracks-${albumId}" style="display: none;">
<div class="wishlist-album-tracks" id="tracks-${safeAlbumId}" style="display: none;">
${tracksListHTML}
</div>
</div>
@ -1599,20 +1645,25 @@ async function selectWishlistCategory(category) {
const albumImage = spotifyData?.album?.images?.[0]?.url || '';
const spotifyTrackId = track.spotify_track_id || track.id || '';
const safeTrackId = escapeHtml(spotifyTrackId);
const safeTrackName = escapeHtml(trackName);
const safeArtistName = escapeHtml(artistName);
const safeAlbumName = escapeHtml(albumName);
const safeAlbumImage = escapeHtml(albumImage).replace(/'/g, '&#39;');
tracksHTML += `
<div class="playlist-track-item-with-image wishlist-track-item">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-select-cb"
data-track-id="${spotifyTrackId}">
data-track-id="${safeTrackId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<div class="playlist-track-image" style="background-image: url('${albumImage}')"></div>
<div class="playlist-track-image" style="background-image: url('${safeAlbumImage}')"></div>
<div class="playlist-track-info">
<div class="playlist-track-name">${trackName}</div>
<div class="playlist-track-artist">${artistName} ${albumName}</div>
<div class="playlist-track-name">${safeTrackName}</div>
<div class="playlist-track-artist">${safeArtistName} ${safeAlbumName}</div>
</div>
<button class="wishlist-delete-btn" data-track-id="${spotifyTrackId}" title="Remove from wishlist">
<button class="wishlist-delete-btn" data-track-id="${safeTrackId}" title="Remove from wishlist">
🗑
</button>
</div>
@ -3248,6 +3299,41 @@ async function downloadCandidate(taskId, candidate, trackName) {
}
}
async function approveQuarantineFromDownloadRow(button) {
const entryId = button?.dataset?.entryId || '';
if (!entryId) {
showToast('Open Quarantine to approve this file.', 'warning');
return;
}
const confirmed = await showConfirmDialog({
title: 'Approve Quarantined File',
message: 'Import this quarantined file and skip quarantine checks for this approved pass?',
confirmText: 'Approve & Import',
cancelText: 'Cancel',
});
if (!confirmed) return;
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Approving...';
try {
const response = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast('Approved quarantined file. Re-running post-processing.', 'success');
} else {
showToast(`Approve failed: ${data.error || 'Unknown error'}`, 'error');
button.disabled = false;
button.textContent = originalText;
}
} catch (error) {
showToast(`Approve failed: ${error.message}`, 'error');
button.disabled = false;
button.textContent = originalText;
}
}
function closeCandidatesModal() {
const overlay = document.getElementById('candidates-modal-overlay');
if (overlay) {
@ -3372,6 +3458,7 @@ function processModalStatusUpdate(playlistId, data) {
const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`);
let statusText = '';
let isQuarantinedTask = false;
// V2 SYSTEM: Handle UI state override for cancelling tasks
if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') {
statusText = '🔄 Cancelling...';
@ -3388,6 +3475,7 @@ function processModalStatusUpdate(playlistId, data) {
// failures — the file is recoverable, not lost.
const _em = (task.error_message || '').toLowerCase();
if (_em.includes('integrity check failed') || _em.includes('bit depth filter') || _em.includes('verification failed') || _em.includes('quarantin')) {
isQuarantinedTask = true;
statusText = '🛡️ Quarantined';
} else {
statusText = '❌ Failed';
@ -3435,6 +3523,13 @@ function processModalStatusUpdate(playlistId, data) {
const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload';
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="${onclickHandler}('${playlistId}', ${task.track_index})">×</button>`;
}
} else if (actionsEl && task.status === 'failed' && isQuarantinedTask && task.quarantine_entry_id) {
const entryId = escapeHtml(task.quarantine_entry_id);
actionsEl.innerHTML = `<button class="approve-quarantine-inline-btn" data-entry-id="${entryId}" title="Approve quarantined file">Approve</button>`;
const approveBtn = actionsEl.querySelector('.approve-quarantine-inline-btn');
if (approveBtn) {
approveBtn.addEventListener('click', () => approveQuarantineFromDownloadRow(approveBtn));
}
} else if (actionsEl && ['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) {
actionsEl.innerHTML = '-'; // No actions available for terminal or processing states
}
@ -3533,12 +3628,9 @@ function processModalStatusUpdate(playlistId, data) {
setTimeout(() => loadServerPlaylists(), 2000);
}
// Auto-close wishlist modal when completed (for auto-processing)
// Keep visible wishlist results open so failed tracks can be reviewed.
if (playlistId === 'wishlist') {
console.log('🔄 [Auto-Wishlist] Auto-closing completed wishlist modal to enable next cycle');
setTimeout(() => {
closeDownloadMissingModal(playlistId);
}, 3000); // 3-second delay to show completion message
console.log('[Wishlist] Leaving completed wishlist modal open for failed-track review');
}
// Check if any other processes still need polling

View file

@ -3413,6 +3413,10 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.5.6': [
{ date: 'May 18, 2026 — 2.5.6 release' },
{ title: 'MusicBrainz as Primary Metadata Source', desc: 'MusicBrainz is now a full primary metadata source on equal footing with Deezer, iTunes, Spotify, and Discogs. switch to it in Settings → Metadata Source — always available, no account or API key needed, rate-limited to 1 req/sec. covers all primary source flows: search, album/track/artist lookup, watchlist scans, discover hero, similar artist backfill, artist map.', page: 'settings' },
],
'2.5.5': [
{ date: 'May 17, 2026 — 2.5.5 release' },
{ title: 'Manual Library Match', desc: 'stop SoulSync from re-downloading tracks it already has. new centralized tool (Tools page → Manual Library Match, or Sync page → Library Match button) lets you search your wishlist / sync history on the left and your library on the right, then link them. once matched, that source track is permanently skipped in wishlist cleanup and the download analysis loop — even when force download is on. manage all your matches in one place with remove support.', page: 'tools' },
@ -3445,6 +3449,20 @@ const WHATS_NEW = {
// Section shape: { title, description, features: [bullet strings],
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "MusicBrainz Is Now a First-Class Metadata Source",
description: "MusicBrainz was already available as an optional search tab, but it wasn't selectable as your primary metadata source. now it is — switch to it in Settings → Metadata Source and the whole app routes through it.",
features: [
"• always available — no account, no API key, no token needed",
"• rate-limited to 1 req/sec at the client layer, consistent with MusicBrainz's terms",
"• covers the full primary-source interface: search, album/track/artist lookup, top tracks, artist albums, discography",
"• watchlist scanner now backfills MusicBrainz artist IDs alongside Spotify / iTunes / Deezer in the similar artists table",
"• discover hero, artist map, and personalized playlists all source from MusicBrainz IDs when it's the active primary",
"• cover art served via Cover Art Archive — no extra API calls, browser fetches the URL directly",
"• fallback source logic in Settings and the registry now reads from source priority order instead of hardcoding 'deezer'",
],
usage_note: "Settings → Metadata → Primary Source → MusicBrainz",
},
{
title: "Live Recordings Stop False-Quarantining",
description: "github issue #607: live recordings were quarantining as 'Version mismatch: expected ... (live) but file is ... (original)' because MusicBrainz often stores live recordings with bare titles — venue annotations live on the release entity, not the recording entity itself. AcoustID's fingerprint correctly identified the live recording, but the title-text comparison flagged it as wrong.",
@ -3714,278 +3732,19 @@ const VERSION_MODAL_SECTIONS = [
],
},
{
title: "Earlier in v2.4 — Reorganize, Search, Sync polish",
description: "highlights from the 2.4.0 cycle that landed before this patch.",
title: "Earlier in v2.4",
description: "highlights from the 2.4 cycle.",
features: [
"• reorganize is now a queue with a live status panel — spam-click all you want, items run one at a time and you can keep browsing",
"• search page got a row of source icons above the bar — typing only searches the active source instead of fanning out to all of them",
"• per-query source cache + cache dots — switching back to a source you already searched is instant",
"• fix: \"maduk — leave a light on\" on tidal was downloading tom walker\'s song of the same name with maduk\'s metadata embedded — tightened the candidate artist gate and acoustid verification",
"• tidal: rejects silent quality downgrades (320kbps when you asked for hires)",
"• spotify: bumped post-ban cooldown from 5 to 30 minutes — first call after a ban was getting re-banned within seconds",
],
},
{
title: "Reorganize Queue Polish",
description: "cleaned up some race conditions in the queue. behavior is solid now.",
features: [
"• worker pick + status flip is atomic now — cancel can\'t land between them and let a cancelled item still run",
"• swapped lock + wakeup-event for a single threading.Condition — newly-queued items don\'t sleep up to 60s anymore",
"• bulk enqueue dedupes within a single batch (was only deduping against pre-existing items)",
"• reorganize-preview Apply button no longer gets stuck disabled on errors",
"• db helpers let exceptions bubble instead of swallowing them as \"album not found\"",
],
},
{
title: "Reorganize Queue with Live Status Panel",
description: "reorganize is now a queue with a live status panel. spam-click all you want — items run one at a time and you can keep browsing.",
features: [
"• per-album reorganize and reorganize all both enqueue into a single backend queue",
"• buttons stay clickable — clicking the same album twice silently dedupes",
"• status panel shows active progress, queued count, and recent finishes",
"• expand the panel for the full queue + per-item cancel buttons (running items can\'t be cancelled mid-flight)",
"• cross-artist items get tagged so you know what\'s queued from where",
"• continue-on-failure: one bad album never stalls the queue",
"• reorganize all is now one backend call instead of N js-driven calls — way faster",
],
},
{
title: "Fix Wrong-Artist Tracks Silently Downloading",
description: "searching for a track could silently download a completely different artist\'s song with the same name. fixed at two layers.",
features: [
"• example: \"maduk — leave a light on\" on tidal was downloading tom walker\'s song of the same name with maduk\'s metadata embedded",
"• tightened the candidate artist gate (was letting through 0.4 similarity, now blocks at 0.5)",
"• acoustid verification now FAILs (quarantines) clear artist mismatches instead of accepting them",
"• ambiguous matches (covers, collabs) still get the benefit of the doubt — only obvious mismatches get blocked",
],
},
{
title: "Tidal Search Falls Back on Long Queries",
description: "tidal\'s search chokes on long remix-credit queries. now retries with shorter variants when the original returns 0 results.",
features: [
"• example: \"maduk transformations remixed fire away fred v remix\" returned 0 — falls back to shorter queries until tidal finds the track",
"• up to 4 shortened variants tried, capped at 5 total requests",
"• qualifier-safe: live/remix/acoustic searches only accept fallback results that keep the qualifier",
"• returns empty if no variant preserves the qualifiers — same as before",
],
},
{
title: "Manual Discovery Fixes Persist Across Restart",
description: "manual discovery fixes are now saved under your active metadata source instead of always \"spotify\" — so deezer / itunes / discogs / hydrabase users\' fixes survive restart.",
features: [
"• affects tidal, deezer, spotify public, youtube, and discovery pool manual fixes",
"• matches how the auto-discovery worker already saved",
"• spotify-primary users unaffected (hardcoded value matched their source)",
],
},
{
title: "Watchlist Content Filters Fixed",
description: "global override and live-version detection now behave the way the ui implies.",
features: [
"• scheduled auto-watchlist honors watchlist → global override (was bypassing it)",
"• live detection tightened — no more false positives on titles like \"what we live for\"",
"• same fix applies to the library maintenance live/commentary cleaner",
"• still catches (live), - live, live at/from/in/on, unplugged, in concert",
],
},
{
title: "Discography Backfill",
description: "new maintenance job that scans each artist\'s full discography and finds what you\'re missing.",
features: [
"• scans each library artist against your metadata source",
"• creates findings for missing tracks — review and add to wishlist",
"• respects all content filters (live, remix, acoustic, etc.) and release type filters",
"• optional auto-add-to-wishlist setting for hands-off operation",
"• opt-in, runs weekly, processes up to 50 artists per run",
],
},
{
title: "Repair 'Run Now' Honored While Paused",
description: "force-running a repair job no longer stalls forever when the master worker is paused.",
features: [
"• jobs queued via run now complete even if the master worker is paused",
"• fixes silent stalls where the job logged \"scanning 50 artists\" then did nothing",
"• master-pause still blocks scheduled runs — only affects user-triggered runs",
],
},
{
title: "Multi-Artist Tagging",
description: "more control over how multiple artists are written to audio file tags.",
features: [
"• configurable separator: comma, semicolon, or slash",
"• multi-value ARTISTS tag for navidrome / jellyfin multi-artist linking",
"• \"move featured artists to title\" mode — primary in ARTIST tag, others as (feat. ...) in title",
"• opt-in, defaults match current behavior",
],
},
{
title: "Enriched Downloads Page",
description: "download cards now show rich metadata instead of just filenames.",
features: [
"• album artwork thumbnail on each card",
"• artist name, album name, source badge",
"• quality badge appears after post-processing",
"• falls back gracefully for transfers without metadata context",
],
},
{
title: "Template Variable Delimiters",
description: "use ${var} syntax to append literal text to template variables.",
features: [
"• ${albumtype}s produces \"Albums\", \"Singles\", \"EPs\"",
"• both $var and ${var} syntaxes work everywhere",
"• validation updated to accept delimited variables",
],
},
{
title: "Reorganize All Albums",
description: "bulk reorganize all albums for an artist from the enhanced library view.",
features: [
"• new reorganize all button in the artist header",
"• processes sequentially with progress toasts",
"• continues on error — one failed album doesn\'t block the rest",
"• uses the same template + endpoint as per-album reorganize",
],
},
{
title: "SoulSync Standalone Library",
description: "use soulsync without plex, jellyfin, or navidrome — manage your library directly.",
features: [
"• new standalone server option in settings → connections",
"• downloads and imports write to the library db immediately",
"• pre-populated enrichment ids — workers skip re-discovery",
"• deep scan finds untracked files and removes stale db records",
"• sync page hidden automatically in standalone mode",
"• full library / artist detail / discography all work standalone",
],
usage_note: "settings → connections → standalone. no media server needed.",
},
{
title: "Auto-Import",
description: "background folder watcher that automatically identifies and imports music into your library.",
features: [
"• recursive scan — any folder depth (artist/album/tracks, loose files, whatever)",
"• tag-based identification preferred, acoustid fingerprinting as fallback",
"• stats bar, filter pills, scan now, approve all, clear history",
"• expandable per-track match details with confidence scores",
"• race condition fix prevents duplicate processing on multi-track albums",
],
usage_note: "import page → auto tab. set your import folder in settings.",
},
{
title: "Wishlist Nebula",
description: "wishlist redesigned as an interactive artist orb visualization.",
features: [
"• each artist is a glowing orb — albums and singles orbit around it",
"• click orbs to expand and download directly from the nebula",
"• live progress with spinning ring animation while processing",
"• stats strip up top: total artists, albums, singles, tracks",
],
usage_note: "click wishlist in the sidebar.",
},
{
title: "Automation Group Management",
description: "organize and manage automation groups properly.",
features: [
"• rename, delete, and bulk-toggle groups from the group header",
"• drag-and-drop automations between groups",
"• delete confirmation shows group name and automation count",
],
usage_note: "use the action buttons on group headers in the automations page.",
},
{
title: "Bidirectional Artist Sync & Server Playlists",
description: "artist sync now goes both ways, and server playlists show full coverage.",
features: [
"• artist sync pulls new content from your media server AND removes stale library entries",
"• deep scan mode fetches full metadata for newly-discovered tracks",
"• server playlist view shows all playlists with synced vs unsynced visual separation",
],
},
{
title: "Provider-Agnostic Discovery",
description: "discovery features work with any configured metadata source instead of requiring spotify.",
features: [
"• similar artist matching, discovery pool, and incremental updates use source priority",
"• falls back through spotify, itunes, deezer in configured order",
"• musicmap url encoding fixed for artists with special characters",
"• freshness check simplified to age-based",
],
},
{
title: "Dashboard & Navigation",
description: "dashboard improvements and sidebar navigation enhancements.",
features: [
"• library status card on dashboard — server state, track counts, scan buttons",
"• tools page in sidebar — maintenance tools moved out of the dashboard modal",
"• watchlist and wishlist promoted to full sidebar pages with live count badges",
"• acoustid scanner scans full library with retag / redownload / delete fix options",
],
},
{
title: "MusicBrainz & Metadata Fixes",
description: "critical tag embedding fix and picard-style album consistency.",
features: [
"• source id tags (spotify, musicbrainz, deezer, audiodb) were silently skipped on every download — now embed correctly",
"• picard-style release preference scoring prevents navidrome album splits",
"• source tags wiped when metadata enhancement is skipped or fails",
"• spotify api no longer called when deezer/itunes is your primary source",
],
},
{
title: "Downloads & Soulseek Improvements",
description: "better download management, search accuracy, and queue control.",
features: [
"• downloads batch panel — color-coded cards with progress, cancel, expand, 7-day history",
"• soulseek queries include album name now — fewer wrong-artist downloads",
"• reject results from various artists / unknown artist folders",
"• clearing wishlist cancels the active wishlist download batch",
"• album delete with \"delete files too\" option on enhanced library",
"• fix download modal freezing mid-download (m3u auto-save was exhausting server threads)",
"• fix unknown artist when adding playlist tracks to wishlist",
],
},
{
title: "Recent Fixes",
description: "smaller bug fixes from recent releases and community reports.",
features: [
"• fix watchlist scan false failures — empty discography no longer reported as error",
"• fix deezer_artist_id column error on enhanced library sync",
"• fix wishlist button intermittently not navigating",
"• fix worker orb tooltips rendering behind dashboard content",
"• fix oauth callback port hardcoding — custom ports respected now",
"• fix allow duplicates and replace-lower-quality settings not saving",
"• fix wishlist dropping cross-album tracks when duplicates enabled",
"• fix spotify enrichment worker infinite loop on pre-matched artists",
"• reject qobuz 30-second sample/preview downloads",
"• auto wing-it fallback for failed discovery",
"• fix album track lookup hardcoded to spotify — uses configured primary now",
"• fix m3u showing all tracks as missing after post-processing",
"• fix acoustid retag not writing corrected tags to file",
"• fix downloads badge dropping to 300 after opening downloads page",
"• unmatch discovery tracks (red ✕ button)",
"• customizable music video naming with $artist, $title, $year",
"• fix soulseek log spam when not configured as download source",
],
},
{
title: "Earlier in v2.3",
description: "major features from earlier in this release cycle.",
features: [
"• centralized downloads page with live-updating list and filter pills",
"• first-run setup wizard — 7-step guided configuration",
"• music videos — search and download from youtube",
"• inbound music request api for external tools (discord bots, home assistant)",
"• lidarr download source (in development) for usenet / torrent",
"• graceful shutdown — all workers respond to shutdown signals immediately",
"• unknown artist prevention with 3-tier metadata fallback",
"• deezer multi-artist tagging via contributors field",
"• artist map — watchlist constellation, genre map, artist explorer",
"• discogs integration — enrichment worker, fallback source, search tab",
"• wing it mode, global search bar, redesigned notifications",
"• server playlist manager, sync history dashboard, playlist explorer",
"• enhanced library manager with inline tag editing and write-to-file",
"• automation signals, multi-source search tabs, rich artist profiles",
"• reorganize queue with live status panel — bulk reorganize all, atomic status flips, race conditions fixed",
"• discography backfill maintenance job — finds what's missing across your library",
"• standalone library mode — use SoulSync without Plex, Jellyfin, or Navidrome",
"• auto-import background folder watcher — tag-based + acoustid fingerprint identification",
"• wishlist nebula — interactive artist orb visualization with inline download",
"• bidirectional artist sync + server playlist view",
"• provider-agnostic discovery — similar artists, discovery pool, and incremental updates use source priority",
"• multi-artist tagging: configurable separator, multi-value ARTISTS tag, move-to-title mode",
"• search page source icons — per-source cache with cache dots, instant source switching",
"• tidal: rejects silent quality downgrades; spotify: post-ban cooldown bumped to 30 minutes",
],
},
];
@ -4021,7 +3780,7 @@ function _getLatestWhatsNewVersion() {
const versions = Object.keys(WHATS_NEW)
.filter(v => _compareVersions(v, buildVer) <= 0)
.sort((a, b) => _compareVersions(b, a));
return versions[0] || '2.5.4';
return versions[0] || '2.5.6';
}
function openWhatsNew() {

View file

@ -2150,14 +2150,53 @@ function _getPageFromPath() {
const path = window.location.pathname.replace(/^\/+|\/+$/g, '');
if (!path) return 'dashboard';
const basePage = path.split('/')[0];
const segs = path.split('/');
const basePage = segs[0];
if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard';
// Context-dependent pages fall back to a sensible parent
if (basePage === 'artist-detail') return 'library';
if (basePage === 'artist-detail') {
// /artist-detail/:id deep-link — keep on artist-detail; bare /artist-detail falls back to library
return (segs.length >= 2 && segs[1]) ? 'artist-detail' : 'library';
}
if (basePage === 'playlist-explorer') return 'library';
return basePage;
}
function _normalizeArtistDetailSource(source) {
const value = (source || '').toString().trim().toLowerCase();
return value || 'library';
}
function buildArtistDetailPath(artistId, source = null) {
if (!artistId) return '/artist-detail';
const normalizedSource = _normalizeArtistDetailSource(source);
return '/artist-detail/' + encodeURIComponent(normalizedSource) + '/' + encodeURIComponent(String(artistId));
}
/** Extract artist source + ID from /artist-detail/:source/:id or legacy /artist-detail/:id URLs. */
function _getDeepLinkArtistDetail(pathname = window.location.pathname) {
const path = (pathname || '').replace(/^\/+|\/+$/g, '');
const segs = path.split('/');
if (segs[0] !== 'artist-detail' || !segs[1]) return null;
if (segs[2]) {
return {
source: _normalizeArtistDetailSource(decodeURIComponent(segs[1])),
artistId: decodeURIComponent(segs.slice(2).join('/')),
};
}
return {
source: 'library',
artistId: decodeURIComponent(segs[1]),
};
}
/** Legacy convenience wrapper for callers that only need the artist ID. */
function _getDeepLinkArtistId() {
return _getDeepLinkArtistDetail()?.artistId || null;
}
// ===============================
// MOBILE NAVIGATION
// ===============================
@ -2279,7 +2318,11 @@ function navigateToPage(pageId, options = {}) {
showReactHost(pageId);
setActivePageChrome(pageId);
}
return router.navigateToPage(pageId, { replace: options.replace === true });
return router.navigateToPage(pageId, {
replace: options.replace === true,
artistId: options.artistId,
artistSource: options.artistSource,
});
}
// Fallback path for initial bootstrap or environments without TanStack routing.
@ -2294,7 +2337,9 @@ function navigateToPage(pageId, options = {}) {
}
if (!options.skipPushState) {
const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId;
const urlPath = pageId === 'dashboard' ? '/'
: (pageId === 'artist-detail' && options.artistId) ? buildArtistDetailPath(options.artistId, options.artistSource)
: '/' + pageId;
if (window.location.pathname !== urlPath) {
if (options.replace === true) {
history.replaceState({ page: pageId }, '', urlPath);

View file

@ -803,6 +803,24 @@ const _ARTIST_DETAIL_BACK_LABELS = {
};
function navigateToArtistDetail(artistId, artistName, sourceOverride = null, options = {}) {
const normalizedSource = sourceOverride || null;
// Skip reload if already on this exact artist/source (prevents double-fetch
// when the router fires activateLegacyPath after navigating to an
// /artist-detail/:source/:id URL).
if (artistId &&
String(artistId) === String(artistDetailPageState.currentArtistId) &&
String(normalizedSource || '') === String(artistDetailPageState.currentArtistSource || '')) {
if (currentPage !== 'artist-detail') {
navigateToPage('artist-detail', {
artistId,
artistSource: normalizedSource,
skipRouteChange: true
});
_updateArtistDetailBackButtonLabel();
}
return;
}
console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId}${sourceOverride ? `, source: ${sourceOverride}` : ''})`);
// Capture the current location on the origin stack BEFORE navigateToPage
@ -856,7 +874,7 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt
// Store current artist info and reset enhanced view state
artistDetailPageState.currentArtistId = artistId;
artistDetailPageState.currentArtistName = artistName;
artistDetailPageState.currentArtistSource = sourceOverride || null;
artistDetailPageState.currentArtistSource = normalizedSource;
artistDetailPageState.enhancedData = null;
artistDetailPageState.expandedAlbums = new Set();
artistDetailPageState.selectedTracks = new Set();
@ -885,7 +903,11 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt
if (bulkBar) bulkBar.classList.remove('visible');
// Navigate to artist detail page
navigateToPage('artist-detail');
navigateToPage('artist-detail', {
artistId,
artistSource: normalizedSource,
skipRouteChange: options.skipRouteChange === true
});
// Update back-button label to reflect where the next pop will land.
_updateArtistDetailBackButtonLabel();
@ -1020,6 +1042,17 @@ async function loadArtistDetailData(artistId, artistName) {
throw new Error(data.error || 'Failed to load artist data');
}
const isSourceOnlyArtist = !data.artist?.server_source;
if (isSourceOnlyArtist && data.discography) {
for (const bucket of ['albums', 'eps', 'singles']) {
for (const release of (data.discography[bucket] || [])) {
if (release.owned === null || typeof release.owned === 'undefined') {
release.owned = false;
}
}
}
}
console.log(`✅ Loaded artist detail data:`, data);
// Hide loading and show all content
@ -1055,7 +1088,7 @@ async function loadArtistDetailData(artistId, artistName) {
renderArtistEnrichmentCoverage(data.enrichment_coverage);
// Start streaming ownership checks if we have Spotify discography with checking state
if (data.discography && data.discography.albums) {
if (!isSourceOnlyArtist && data.discography && data.discography.albums) {
const hasChecking = [...(data.discography.albums || []), ...(data.discography.eps || []), ...(data.discography.singles || [])]
.some(r => r.owned === null);
if (hasChecking) {
@ -1069,7 +1102,9 @@ async function loadArtistDetailData(artistId, artistName) {
// Use currentArtistId (not the closure arg) because the library-upgrade
// branch above may have rewritten it from the source ID to the library PK,
// and /api/library/artist/<id>/quality-analysis only works on library PKs.
checkArtistEnhanceEligibility(artistDetailPageState.currentArtistId);
if (!isSourceOnlyArtist) {
checkArtistEnhanceEligibility(artistDetailPageState.currentArtistId);
}
} catch (error) {
console.error(`❌ Error loading artist detail data:`, error);
@ -1314,15 +1349,34 @@ function updateArtistHeaderStats(albumCount, trackCount) {
console.log("📊 Using new hero section instead of old header stats");
}
function _isUsableArtistHeroImageUrl(url) {
return typeof url === 'string' && url.trim() !== '' && url !== 'null';
}
function _getArtistHeroReleaseImage(discography) {
for (const bucket of ['albums', 'eps', 'singles']) {
for (const release of (discography?.[bucket] || [])) {
if (_isUsableArtistHeroImageUrl(release?.image_url)) {
return release.image_url;
}
}
}
return '';
}
function updateArtistHeroSection(artist, discography) {
console.log("🖼️ Updating artist hero section");
const artistImageUrl = _isUsableArtistHeroImageUrl(artist.image_url) ? artist.image_url : '';
const releaseImageUrl = _getArtistHeroReleaseImage(discography);
const primaryHeroImageUrl = artistImageUrl || releaseImageUrl;
// Blurred background image (inline-Artists hero treatment) — set whenever
// we have an image_url; falls back to clearing the bg if not.
const heroBg = document.getElementById("artist-detail-hero-bg");
if (heroBg) {
if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") {
heroBg.style.backgroundImage = `url('${artist.image_url}')`;
if (primaryHeroImageUrl) {
heroBg.style.backgroundImage = `url('${primaryHeroImageUrl}')`;
} else {
heroBg.style.backgroundImage = '';
}
@ -1339,9 +1393,11 @@ function updateArtistHeroSection(artist, discography) {
console.log(` - Image element:`, imageElement);
console.log(` - Fallback element:`, fallbackElement);
if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") {
console.log(`✅ Setting image src to: ${artist.image_url}`);
imageElement.src = artist.image_url;
if (primaryHeroImageUrl) {
console.log(`✅ Setting image src to: ${primaryHeroImageUrl}`);
imageElement.dataset.triedDeezer = '';
imageElement.dataset.triedReleaseFallback = artistImageUrl ? '' : 'true';
imageElement.src = primaryHeroImageUrl;
imageElement.alt = artist.name;
imageElement.style.display = "block";
if (fallbackElement) {
@ -1353,11 +1409,14 @@ function updateArtistHeroSection(artist, discography) {
};
imageElement.onerror = () => {
console.error(`❌ Failed to load artist image: ${artist.image_url}`);
// Try Deezer fallback before emoji
console.error(`❌ Failed to load artist image: ${imageElement.src}`);
// Try Deezer fallback, then release art, before the generic icon.
if (artist.deezer_id && !imageElement.dataset.triedDeezer) {
imageElement.dataset.triedDeezer = 'true';
imageElement.src = `https://api.deezer.com/artist/${artist.deezer_id}/image?size=big`;
} else if (releaseImageUrl && imageElement.src !== releaseImageUrl && !imageElement.dataset.triedReleaseFallback) {
imageElement.dataset.triedReleaseFallback = 'true';
imageElement.src = releaseImageUrl;
} else {
imageElement.style.display = "none";
if (fallbackElement) {

View file

@ -2248,6 +2248,10 @@ function _updateDlNavBadge(count) {
badge.classList.add('hidden');
}
}
const dlBtn = document.querySelector('.nav-button[data-page="active-downloads"]');
if (dlBtn) {
dlBtn.classList.toggle('nav-downloads-active', count > 0);
}
}
function _adlRender() {

View file

@ -1197,7 +1197,16 @@ async function loadInitialData() {
return;
}
navigateToPage(targetPage, { skipRouteChange: true, forceReload: true });
if (targetPage === 'artist-detail') {
const deepArtist = _getDeepLinkArtistDetail();
if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') {
navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source);
} else {
navigateToPage('library', { skipRouteChange: true, forceReload: true });
}
} else {
navigateToPage(targetPage, { skipRouteChange: true, forceReload: true });
}
} catch (error) {
console.error('Error loading initial data:', error);
}

View file

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

View file

@ -1016,6 +1016,7 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
type: 'artist_album',
artist: artist,
album: album,
source: artist?.source || album?.source || artistsPageState.artistDiscography?.source || null,
trackCount: spotifyTracks.length,
playlistId: virtualPlaylistId
};
@ -3243,7 +3244,9 @@ function syncPrimaryMetadataSourceAvailability(statusData) {
function getMetadataSourceLabel(source) {
if (source === 'deezer') return 'Deezer';
if (source === 'discogs') return 'Discogs';
if (source === 'hydrabase') return 'Hydrabase';
if (source === 'itunes') return 'iTunes';
if (source === 'musicbrainz') return 'MusicBrainz';
if (source === 'spotify') return 'Spotify';
return 'Unmapped';
}

View file

@ -80,6 +80,16 @@ function activateLegacyPath(pathname) {
return;
}
if (targetPage === 'artist-detail') {
const deepArtist = _getDeepLinkArtistDetail(pathname);
if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') {
navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source, { skipOriginPush: true, skipRouteChange: true });
return;
}
navigateToPage('library', { replace: true });
return;
}
notifyPageWillChange(targetPage);
activatePage(targetPage, { forceReload: true });
}
@ -97,6 +107,16 @@ function syncActivePageFromLocation() {
return;
}
if (targetPage === 'artist-detail') {
const deepArtist = _getDeepLinkArtistDetail();
if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') {
navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source, { skipOriginPush: true, skipRouteChange: true });
return;
}
navigateToPage('library', { replace: true });
return;
}
notifyPageWillChange(targetPage);
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {

View file

@ -449,6 +449,49 @@ body {
color: rgba(var(--accent-rgb), 0.7);
}
.nav-button.nav-downloads-active .nav-icon {
animation: watchlist-scan-glow 1.2s ease-in-out infinite;
}
.nav-button.nav-downloads-active .nav-svg {
animation: nav-downloads-svg 1.2s ease-in-out infinite;
}
@keyframes nav-downloads-svg {
0%, 100% { opacity: 0.7; transform: translateY(0); }
50% { opacity: 1; transform: translateY(1px); filter: drop-shadow(0 0 3px rgba(var(--accent-rgb), 0.8)); }
}
.nav-button.nav-watchlist-scanning .nav-icon {
animation: watchlist-scan-glow 1.6s ease-in-out infinite;
}
.nav-button.nav-watchlist-scanning .nav-svg {
animation: watchlist-scan-svg 1.6s ease-in-out infinite;
}
@keyframes watchlist-scan-glow {
0%, 100% {
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 0 6px rgba(var(--accent-rgb), 0.25);
border-color: rgba(var(--accent-rgb), 0.2);
}
50% {
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 14px rgba(var(--accent-rgb), 0.55);
border-color: rgba(var(--accent-rgb), 0.5);
}
}
@keyframes watchlist-scan-svg {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; filter: drop-shadow(0 0 3px rgba(var(--accent-rgb), 0.8)); }
}
.nav-button:hover .nav-icon {
color: rgba(255, 255, 255, 0.95);
background: linear-gradient(135deg,
@ -16445,6 +16488,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #D4A574;
}
.watchlist-source-musicbrainz {
background: rgba(186, 71, 143, 0.15);
color: #BA478F;
}
.watchlist-source-amazon {
background: rgba(255, 153, 0, 0.15);
color: #FF9900;
@ -19904,6 +19952,28 @@ body.helper-mode-active #dashboard-activity-feed:hover {
transform: scale(1.05);
}
.approve-quarantine-inline-btn {
background: var(--accent-color, #1db954);
color: #ffffff;
border: none;
border-radius: 6px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.approve-quarantine-inline-btn:hover:not(:disabled) {
filter: brightness(1.1);
transform: scale(1.04);
}
.approve-quarantine-inline-btn:disabled {
cursor: default;
opacity: 0.65;
}
/* Modal Footer */
.download-missing-modal-footer {
background: linear-gradient(135deg,

View file

@ -1992,6 +1992,7 @@ function generateDownloadModalHeroSection(context) {
// Artist/album context - show artist + album images
const artistImage = artist?.image_url || artist?.images?.[0]?.url;
const albumImage = album?.image_url || album?.images?.[0]?.url;
const artistSource = artist?.source || album?.source || context.source || '';
// Use album image as background if available
if (albumImage) {
@ -2006,7 +2007,7 @@ function generateDownloadModalHeroSection(context) {
</div>
<div class="download-missing-modal-hero-metadata">
<h1 class="download-missing-modal-hero-title">${escapeHtml(album.name || 'Unknown Album')}</h1>
<div class="download-missing-modal-hero-subtitle">by <a href="#" class="hero-artist-link" onclick="event.preventDefault();_navigateToArtistFromModal('${escapeHtml(artist.id || '')}','${escapeForInlineJs(artist.name || '')}','${escapeHtml(artist.image_url || '')}','${escapeHtml(artist.source || '')}','${escapeHtml(context.playlistId || '')}')">${escapeHtml(artist.name || 'Unknown Artist')}</a></div>
<div class="download-missing-modal-hero-subtitle">by <a href="#" class="hero-artist-link" onclick="event.preventDefault();_navigateToArtistFromModal('${escapeHtml(artist.id || '')}','${escapeForInlineJs(artist.name || '')}','${escapeHtml(artist.image_url || '')}','${escapeHtml(artistSource)}','${escapeHtml(context.playlistId || '')}')">${escapeHtml(artist.name || 'Unknown Artist')}</a></div>
<div class="download-missing-modal-hero-details">
<span class="download-missing-modal-hero-detail">${album.album_type || 'Album'}</span>
<span class="download-missing-modal-hero-detail">${trackCount} tracks</span>

View file

@ -3143,7 +3143,7 @@ function renderQuarantineEntry(entry) {
const entryIdAttr = escapeHtml(String(entry.id || ''));
const approveLabel = entry.has_full_context ? 'Approve' : 'Recover';
const approveTitle = entry.has_full_context
? 'Re-run post-processing with only the failing check skipped'
? 'Re-run post-processing with quarantine checks skipped for this approved file'
: 'Legacy entry — move to Staging, finish via Import flow';
const approveCall = entry.has_full_context
? 'approveQuarantineEntryFromButton(this)'
@ -3206,7 +3206,7 @@ function deleteQuarantineEntryFromButton(button) {
async function approveQuarantineEntry(entryId) {
const ok = await showConfirmDialog({
title: 'Approve Quarantined File',
message: 'Re-run post-processing for this file with only the failing check skipped. The file will be tagged, lyrics generated, and moved into your library. Other quality gates (AcoustID + bit-depth) still run.',
message: 'Re-run post-processing for this file with quarantine checks skipped for this approved pass. The file will be tagged, lyrics generated, and moved into your library.',
confirmText: 'Approve & Import',
cancelText: 'Cancel',
});