commit
4f452541a3
62 changed files with 2061 additions and 556 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ SOURCE_ID_COLUMNS = (
|
|||
('itunes', 'itunes_artist_id'),
|
||||
('deezer', 'deezer_id'),
|
||||
('discogs', 'discogs_id'),
|
||||
('musicbrainz', 'musicbrainz_id'),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}, ?)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
114
tests/discovery/test_similar_artists_library_filter.py
Normal file
114
tests/discovery/test_similar_artists_library_filter.py
Normal 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"}
|
||||
|
|
@ -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},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
63
tests/tools/test_amazon_worker_schema.py
Normal file
63
tests/tools/test_amazon_worker_schema.py
Normal 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},
|
||||
}
|
||||
81
tests/watchlist/test_musicbrainz_watchlist_ids.py
Normal file
81
tests/watchlist/test_musicbrainz_watchlist_ids.py
Normal 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"
|
||||
|
|
@ -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},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
117
web_server.py
117
web_server.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
2
webui/src/platform/shell/globals.d.ts
vendored
2
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -23,6 +23,8 @@ declare global {
|
|||
pageId: ShellPageId,
|
||||
options?: {
|
||||
replace?: boolean;
|
||||
artistId?: string | number;
|
||||
artistSource?: string | null;
|
||||
},
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '"')}"
|
||||
|
|
@ -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, '"')}"
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, ''');
|
||||
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, ''');
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue