diff --git a/core/enrichment/unmatched.py b/core/enrichment/unmatched.py index 1bcd5eae..0d63ac79 100644 --- a/core/enrichment/unmatched.py +++ b/core/enrichment/unmatched.py @@ -32,6 +32,12 @@ SERVICE_ENTITY_SUPPORT = { 'tidal': ('artist', 'album', 'track'), 'qobuz': ('artist', 'album', 'track'), 'amazon': ('artist', 'album', 'track'), + # Relationship enrichment (not a metadata source): the Similar Artists worker + # only operates at the artist level, and its _match_status tracks + # whether MusicMap similars were fetched (not a source-id match). So the + # breakdown / unmatched list here means "artists we have / don't have + # similars for" — informative, even though there's no manual-match action. + 'similar_artists': ('artist',), } # entity_type -> table / display-name column / image expression / optional join diff --git a/core/similar_artists_worker.py b/core/similar_artists_worker.py new file mode 100644 index 00000000..1349b73c --- /dev/null +++ b/core/similar_artists_worker.py @@ -0,0 +1,343 @@ +"""Background worker that fills the ``similar_artists`` table for LIBRARY artists. + +The watchlist scanner only populates similar artists for *watchlist* artists, so +the artist map / discover surfaces are rich for watchlisted artists and sparse +for the rest of the library. This worker closes that gap: for every +source-matched library artist it asks MusicMap for ~25 similar artists, matches +each one to the user's metadata source chain (primary + active fallbacks) via the +shared :func:`core.metadata.similar_artists.get_musicmap_similar_artists`, and +stores the matched results keyed by the library artist's **metadata source id** — +the same key the watchlist scanner and the artist map use, so the two cooperate +(idempotent upsert + a retry window keep them from double-fetching). + +It plugs into the existing enrichment-worker pattern (background thread, status / +pause / resume, ``matched / not_found / pending / errors`` stats) so it shows up +as a bubble in the dashboard / Manage Enrichment Workers modal like every other +source worker. + +The pure seams below (:func:`pick_source_artist_id`, +:func:`map_payload_to_store_kwargs`, :func:`process_artist`) carry the logic and +are unit-tested in isolation; the class wires them to the DB + MusicMap. +""" + +from __future__ import annotations + +import threading +from typing import Any, Callable, Dict, Optional + +from utils.logging_config import get_logger +from core.worker_utils import interruptible_sleep + +logger = get_logger("similar_artists_worker") + + +# A matched MusicMap payload is {id, source, name, image_url, genres, popularity}. +# Map its single (id, source) onto the right add_or_update_similar_artist id +# kwarg. The table only has columns for these four providers; a match on any +# other source (e.g. discogs) is still stored, name-only. +_SOURCE_ID_FIELD = { + 'spotify': 'similar_artist_spotify_id', + 'itunes': 'similar_artist_itunes_id', + 'deezer': 'similar_artist_deezer_id', + 'musicbrainz': 'similar_artist_musicbrainz_id', +} + +# Library-artist source-id columns, in the same priority the watchlist scanner +# uses to key its rows — so a library artist and (if also watchlisted) its +# watchlist row resolve to the SAME source_artist_id and don't duplicate work. +_LIBRARY_ID_COLUMNS = ('spotify_artist_id', 'itunes_artist_id', 'deezer_id', 'musicbrainz_id') + + +def map_payload_to_store_kwargs(payload: Dict[str, Any]) -> Dict[str, Optional[str]]: + """Turn a matched MusicMap payload into the id kwarg for the store call.""" + src = str(payload.get('source') or '').lower() + pid = str(payload.get('id') or '') + field = _SOURCE_ID_FIELD.get(src) + return {field: pid} if (field and pid) else {} + + +def pick_source_artist_id(row: Dict[str, Any]) -> Optional[str]: + """The metadata source id to key a library artist's similars by, or None if + the artist isn't matched to any metadata source yet (→ skip it).""" + for key in _LIBRARY_ID_COLUMNS: + v = row.get(key) + if v: + return str(v) + return None + + +def process_artist( + source_artist_id: str, + artist_name: str, + fetch_similars: Callable[[str, int], Dict[str, Any]], + store_similar: Callable[..., bool], + limit: int = 25, + profile_id: int = 1, +) -> tuple: + """Fetch + store similar artists for one library artist. + + ``fetch_similars(name, limit)`` returns the ``get_musicmap_similar_artists`` + payload; ``store_similar(**kwargs)`` is ``add_or_update_similar_artist``. + Returns ``(status, stored_count, detail)`` where status is one of: + - ``'matched'`` — stored ≥1 similar artist + - ``'not_found'`` — MusicMap had no entry / nothing matched a source + - ``'error'`` — MusicMap/source failure (transient; eligible for retry) + ``detail`` is a short human-readable reason (status code + message, or + ``'no matches'`` / ``''``) so the worker can surface WHY a fetch failed + instead of swallowing it — needed to diagnose error rates. + """ + try: + result = fetch_similars(artist_name, limit) or {} + except Exception as exc: + return ('error', 0, f'exception: {exc}') + + if not result.get('success'): + # 404/400 = genuinely no MusicMap entry → 'not_found' (don't keep retrying); + # anything else (timeout, 5xx, no providers) = transient → 'error' (retry). + code = result.get('status_code') + detail = f"{code}: {result.get('error') or 'unknown'}" + return ('not_found' if code in (400, 404) else 'error', 0, detail) + + sims = result.get('similar_artists') or [] + if not sims: + return ('not_found', 0, 'no matches') + + stored = 0 + for rank, s in enumerate(sims, 1): + name = s.get('name') + if not name: + continue + kwargs = map_payload_to_store_kwargs(s) + if not kwargs: + # The match resolved to a source with no id column in similar_artists + # (e.g. discogs). Storing it name-only would be useless — you can't + # navigate/explore/download it. Enforce the standard: every stored + # similar carries a metadata source id, or we skip it. + logger.debug("Skipping similar '%s' (matched %s — no storable source id)", name, s.get('source')) + continue + try: + ok = store_similar( + source_artist_id=source_artist_id, + similar_artist_name=name, + similarity_rank=rank, + profile_id=profile_id, + image_url=s.get('image_url'), + genres=s.get('genres'), + popularity=s.get('popularity', 0) or 0, + **kwargs, + ) + if ok: + stored += 1 + except Exception as exc: + logger.debug("store similar failed for %s: %s", name, exc) + + return ('matched' if stored else 'not_found', stored, '') + + +class SimilarArtistsWorker: + """Background worker that populates similar artists for library artists.""" + + def __init__(self, database): + self.db = database + self.running = False + self.paused = False + self.should_stop = False + self.thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self.current_item: Optional[str] = None + self.stats = {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0} + self.retry_days = 30 + self.limit = 25 + self._err_logged = 0 # how many fetch errors we've logged at WARNING this session + self._last_error = None # most recent fetch-error reason (for diagnosis) + # similar_artists rows are profile-scoped; the library is shared. v1 keys + # under the default profile (matches single-profile setups, which is the + # common case). Multi-profile per-source-chain population is future work. + self.profile_id = 1 + logger.info("Similar Artists background worker initialized") + + # ── lifecycle (mirrors the other enrichment workers) ────────────────── + def start(self): + if self.running: + return + self.running = True + self.should_stop = False + self._stop_event.clear() + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + logger.info("Similar Artists worker started") + + def stop(self): + self.should_stop = True + self.running = False + self._stop_event.set() + + def pause(self): + self.paused = True + + def resume(self): + self.paused = False + + def get_stats(self) -> Dict[str, Any]: + # Report PERSISTENT counts from the DB (not the in-memory session + # counters), so the dashboard orb and the Manage modal always agree and + # survive restarts — same approach as the other enrichment workers. + c = self._db_counts() + self.stats = { + 'matched': c['matched'], 'not_found': c['not_found'], + 'pending': c['pending'], 'errors': c['error'], + } + total = c['total'] + is_running = self.running and (self.thread is not None and self.thread.is_alive()) + is_idle = is_running and not self.paused and c['pending'] == 0 and self.current_item is None + return { + 'enabled': True, + 'running': is_running and not self.paused, + 'paused': self.paused, + 'idle': is_idle, + 'current_item': self.current_item, + 'stats': self.stats.copy(), + # Artist-only progress (no album/track phases) so the orb tooltip can + # show "matched / total (percent%)" like every other worker. + 'progress': { + 'artists': { + 'matched': c['matched'], 'total': total, + 'percent': int(round(c['matched'] / total * 100)) if total else 0, + } + }, + } + + # ── worker loop ──────────────────────────────────────────────────────── + def _run(self): + logger.info("Similar Artists worker thread started") + # Imported lazily so the worker module stays import-light for tests. + from core.metadata.similar_artists import get_musicmap_similar_artists + + while not self.should_stop: + try: + if self.paused: + interruptible_sleep(self._stop_event, 1) + continue + + self.current_item = None + artist = self._get_next_artist() + if not artist: + interruptible_sleep(self._stop_event, 15) + continue + + sid = pick_source_artist_id(artist) + if not sid: + # Query already filters to artists with a source id; guard anyway. + self._mark(artist['id'], 'error') + continue + + self.current_item = artist.get('name') + status, count, detail = process_artist( + sid, artist['name'], + get_musicmap_similar_artists, + self.db.add_or_update_similar_artist, + limit=self.limit, profile_id=self.profile_id, + ) + self._mark(artist['id'], status) + if status == 'matched': + self.stats['matched'] += 1 + logger.debug("Similar artists: %s → stored %d", artist['name'], count) + elif status == 'not_found': + self.stats['not_found'] += 1 + else: + self.stats['errors'] += 1 + # Surface WHY fetches error — the first handful at WARNING (so + # the cause is visible in app.log without spamming a 4000-artist + # run), the rest at DEBUG. Keep the most recent reason for stats. + self._last_error = detail + if self._err_logged < 15: + self._err_logged += 1 + logger.warning("Similar artists fetch error for '%s' — %s", artist['name'], detail) + else: + logger.debug("Similar artists fetch error for '%s' — %s", artist['name'], detail) + + # Pace MusicMap (name search per candidate is heavy + rate-limited). + interruptible_sleep(self._stop_event, 3) + except Exception as exc: + logger.error("Similar Artists worker loop error: %s", exc) + interruptible_sleep(self._stop_event, 5) + + logger.info("Similar Artists worker thread finished") + + # ── DB helpers (thin; the testable logic lives in the pure seams above) ── + def _has_source_id_clause(self) -> str: + return '(' + ' OR '.join(f"{c} IS NOT NULL AND {c} != ''" for c in _LIBRARY_ID_COLUMNS) + ')' + + def _get_next_artist(self) -> Optional[Dict[str, Any]]: + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cols = "id, name, " + ", ".join(_LIBRARY_ID_COLUMNS) + have_id = self._has_source_id_clause() + # 1) Unattempted source-matched artists. + cursor.execute(f""" + SELECT {cols} FROM artists + WHERE id IS NOT NULL AND name IS NOT NULL + AND similar_artists_match_status IS NULL + AND {have_id} + ORDER BY id ASC LIMIT 1 + """) + row = cursor.fetchone() + # 2) Retry transient failures (and re-check 'not_found') after retry_days. + if not row: + cursor.execute(f""" + SELECT {cols} FROM artists + WHERE id IS NOT NULL AND name IS NOT NULL + AND similar_artists_match_status IN ('error', 'not_found') + AND (similar_artists_last_attempted IS NULL + OR similar_artists_last_attempted < datetime('now', ?)) + AND {have_id} + ORDER BY similar_artists_last_attempted ASC LIMIT 1 + """, (f'-{self.retry_days} days',)) + row = cursor.fetchone() + if not row: + return None + keys = ['id', 'name'] + list(_LIBRARY_ID_COLUMNS) + return dict(zip(keys, row, strict=False)) + except Exception as exc: + logger.debug("Similar Artists _get_next_artist failed: %s", exc) + return None + + def _mark(self, artist_id, status: str): + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE artists SET similar_artists_match_status = ?, " + "similar_artists_last_attempted = CURRENT_TIMESTAMP WHERE id = ?", + (status, artist_id), + ) + conn.commit() + except Exception as exc: + logger.debug("Similar Artists _mark failed for %s: %s", artist_id, exc) + + def _count_pending(self) -> int: + return self._db_counts()['pending'] + + def _db_counts(self) -> Dict[str, int]: + """Persistent tallies over the worker's universe (source-matched library + artists): matched / not_found / error / pending(NULL) / total.""" + out = {'matched': 0, 'not_found': 0, 'error': 0, 'pending': 0, 'total': 0} + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(f""" + SELECT + SUM(CASE WHEN similar_artists_match_status = 'matched' THEN 1 ELSE 0 END), + SUM(CASE WHEN similar_artists_match_status = 'not_found' THEN 1 ELSE 0 END), + SUM(CASE WHEN similar_artists_match_status = 'error' THEN 1 ELSE 0 END), + SUM(CASE WHEN similar_artists_match_status IS NULL THEN 1 ELSE 0 END), + COUNT(*) + FROM artists WHERE {self._has_source_id_clause()} + """) + row = cursor.fetchone() or (0, 0, 0, 0, 0) + out.update(matched=int(row[0] or 0), not_found=int(row[1] or 0), + error=int(row[2] or 0), pending=int(row[3] or 0), total=int(row[4] or 0)) + except Exception as exc: + logger.debug("Similar Artists _db_counts failed: %s", exc) + return out diff --git a/database/music_database.py b/database/music_database.py index aceb49c4..3e7cdd67 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -427,6 +427,9 @@ class MusicDatabase: # Add Amazon artist ID column (migration) self._add_amazon_columns(cursor) + # Add Similar-Artists worker tracking columns (migration) + self._add_similar_artists_worker_columns(cursor) + # Backfill match_status for rows that already have an external ID but # NULL status. Prevents enrichment workers from re-processing these # rows forever. Must run AFTER all *_match_status columns have been @@ -2417,6 +2420,28 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding Discogs columns: {e}") + def _add_similar_artists_worker_columns(self, cursor): + """Add Similar-Artists worker tracking columns to the artists table. + + Mirrors the per-source enrichment pattern: a match_status (NULL = + unattempted, then 'matched'/'not_found'/'error') + last_attempted + timestamp so the SimilarArtistsWorker can pick the next library artist to + fetch MusicMap similars for and retry transient failures after a window. + Idempotent — only adds columns that aren't already present. + """ + try: + cursor.execute("PRAGMA table_info(artists)") + artists_columns = [column[1] for column in cursor.fetchall()] + + if 'similar_artists_match_status' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN similar_artists_match_status TEXT") + if 'similar_artists_last_attempted' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN similar_artists_last_attempted TIMESTAMP") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_similarartists_status ON artists (similar_artists_match_status)") + except Exception as e: + logger.error(f"Error adding similar-artists worker columns: {e}") + def _add_amazon_columns(self, cursor): """Add Amazon enrichment tracking columns to artists, albums, and tracks.""" try: diff --git a/tests/test_similar_artists_worker.py b/tests/test_similar_artists_worker.py new file mode 100644 index 00000000..3f496041 --- /dev/null +++ b/tests/test_similar_artists_worker.py @@ -0,0 +1,146 @@ +"""Seam tests for the Similar-Artists enrichment worker's pure logic. + +The worker fills the similar_artists table for LIBRARY artists (the watchlist +scanner only does watchlist artists). These tests exercise the import-light +seams in isolation — no DB, no MusicMap — via injected fakes: + + - pick_source_artist_id → keying priority (and skip un-matched artists) + - map_payload_to_store_kwargs → MusicMap {id,source} → the right id column + - process_artist → fetch→match→store orchestration + status codes +""" + +from __future__ import annotations + +import core.similar_artists_worker as w + + +# -------------------------------------------------------------------------- +# pick_source_artist_id — which id keys the artist's similars (must match the +# watchlist scanner's priority so both write the SAME source_artist_id). +# -------------------------------------------------------------------------- + +def test_pick_source_artist_id_priority(): + row = {'spotify_artist_id': 'sp1', 'itunes_artist_id': 'it1', 'deezer_id': 'dz1'} + assert w.pick_source_artist_id(row) == 'sp1' # spotify wins + assert w.pick_source_artist_id({'itunes_artist_id': 'it1', 'deezer_id': 'dz1'}) == 'it1' + assert w.pick_source_artist_id({'deezer_id': 'dz1'}) == 'dz1' + assert w.pick_source_artist_id({'musicbrainz_id': 'mb1'}) == 'mb1' + + +def test_pick_source_artist_id_none_when_unmatched(): + # Library artist not matched to any metadata source yet → skip (None). + assert w.pick_source_artist_id({'spotify_artist_id': None, 'itunes_artist_id': ''}) is None + assert w.pick_source_artist_id({}) is None + + +# -------------------------------------------------------------------------- +# map_payload_to_store_kwargs — MusicMap payload {id, source} → store kwarg. +# -------------------------------------------------------------------------- + +def test_map_payload_each_source(): + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'spotify'}) == {'similar_artist_spotify_id': 'x'} + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'itunes'}) == {'similar_artist_itunes_id': 'x'} + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'deezer'}) == {'similar_artist_deezer_id': 'x'} + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'musicbrainz'}) == {'similar_artist_musicbrainz_id': 'x'} + + +def test_map_payload_unknown_source_or_no_id(): + # discogs has no column → name-only (empty kwargs), not a crash. + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'discogs'}) == {} + assert w.map_payload_to_store_kwargs({'source': 'spotify'}) == {} # no id + + +# -------------------------------------------------------------------------- +# process_artist — fetch → store, status classification, keying. +# -------------------------------------------------------------------------- + +def _capture_store(): + calls = [] + + def store(**kwargs): + calls.append(kwargs) + return True + return store, calls + + +def test_process_artist_matched_stores_with_keying(): + store, calls = _capture_store() + payload = { + 'success': True, + 'similar_artists': [ + {'name': 'B', 'id': 'sp_b', 'source': 'spotify', 'genres': ['rap'], 'popularity': 70}, + {'name': 'C', 'id': 'it_c', 'source': 'itunes', 'image_url': 'http://x'}, + ], + } + status, count, detail = w.process_artist('SRC1', 'A', lambda n, l: payload, store, limit=25, profile_id=1) + assert status == 'matched' and count == 2 and detail == '' + # All similars keyed by the SOURCE artist id we passed (not the library PK). + assert all(c['source_artist_id'] == 'SRC1' for c in calls) + assert all(c['profile_id'] == 1 for c in calls) + # Provider id mapped to the right column; rank preserved in order. + assert calls[0]['similar_artist_spotify_id'] == 'sp_b' and calls[0]['similarity_rank'] == 1 + assert calls[1]['similar_artist_itunes_id'] == 'it_c' and calls[1]['similarity_rank'] == 2 + assert calls[0]['genres'] == ['rap'] and calls[0]['popularity'] == 70 + + +def test_process_artist_not_found_when_no_matches(): + store, calls = _capture_store() + status, count, detail = w.process_artist('S', 'A', lambda n, l: {'success': True, 'similar_artists': []}, store) + assert status == 'not_found' and count == 0 and calls == [] and detail == 'no matches' + + +def test_process_artist_not_found_on_404(): + # Genuinely no MusicMap entry — shouldn't be retried as an error. + store, _ = _capture_store() + status, _, _ = w.process_artist('S', 'A', lambda n, l: {'success': False, 'status_code': 404}, store) + assert status == 'not_found' + + +def test_process_artist_error_on_outage_is_retriable_and_explains_why(): + # 5xx / no providers → transient error (retried after retry_days), and the + # reason is surfaced (code + message) so the cause is diagnosable, not silent. + store, _ = _capture_store() + status, _, detail = w.process_artist( + 'S', 'A', lambda n, l: {'success': False, 'status_code': 502, 'error': 'Failed to fetch from MusicMap'}, store) + assert status == 'error' + assert '502' in detail and 'MusicMap' in detail + + +def test_process_artist_error_when_fetch_raises_carries_detail(): + store, _ = _capture_store() + + def boom(n, l): + raise RuntimeError('musicmap down') + status, count, detail = w.process_artist('S', 'A', boom, store) + assert status == 'error' and count == 0 and 'musicmap down' in detail + + +def test_process_artist_skips_similars_without_a_storable_source_id(): + # Every stored similar MUST carry a metadata source id (spotify/itunes/deezer/ + # musicbrainz) — otherwise it's not actionable. A match on a source with no id + # column (e.g. discogs) is skipped, never stored name-only. + store, calls = _capture_store() + payload = {'success': True, 'similar_artists': [ + {'name': 'KeepMe', 'id': 'sp1', 'source': 'spotify'}, + {'name': 'DropMe', 'id': 'dg1', 'source': 'discogs'}, # no id column → must skip + {'name': 'NoId', 'source': 'spotify'}, # no id at all → must skip + ]} + status, count, _ = w.process_artist('S', 'A', lambda n, l: payload, store) + assert count == 1 and len(calls) == 1 + assert calls[0]['similar_artist_name'] == 'KeepMe' + assert calls[0]['similar_artist_spotify_id'] == 'sp1' + + +def test_process_artist_skips_unstorable_but_counts_real(): + # A store() that fails for one row shouldn't abort the rest. + calls = [] + + def store(**kwargs): + calls.append(kwargs) + return kwargs['similar_artist_name'] != 'B' # B fails to store + payload = {'success': True, 'similar_artists': [ + {'name': 'B', 'id': '1', 'source': 'spotify'}, + {'name': 'C', 'id': '2', 'source': 'spotify'}, + ]} + status, count, _ = w.process_artist('S', 'A', lambda n, l: payload, store) + assert status == 'matched' and count == 1 and len(calls) == 2 diff --git a/web_server.py b/web_server.py index 0e8c0d68..6009563d 100644 --- a/web_server.py +++ b/web_server.py @@ -28284,6 +28284,19 @@ def get_artist_map_explore(): return _artists_map_get_artist_map_explore() +@app.route('/api/discover/artist-map/perf', methods=['POST']) +def log_artist_map_perf(): + """Debug sink: the artist-map frontend POSTs its render timings here (toggled + with 'd' on the map) so they land in app.log — the on-canvas overlay text + can't be copied. Used to find the real drag/zoom bottleneck.""" + try: + data = request.get_json(silent=True) or {} + logger.info("[ARTMAP-PERF] %s", json.dumps(data, ensure_ascii=False)) + except Exception as e: + logger.debug("artist-map perf log failed: %s", e) + return ('', 204) + + @app.route('/api/discover/build-playlist/search-artists', methods=['GET']) def search_artists_for_playlist(): """Search for artists to use as seeds for custom playlist building""" @@ -32876,6 +32889,27 @@ except Exception as e: amazon_worker = None +# --- Similar Artists Worker Initialization --- +# Fills the similar_artists table for LIBRARY artists (the watchlist scanner only +# covers watchlist artists). Runs by default (like the metadata workers); it +# self-paces (~3s/artist) and backs off on MusicMap outages. Respects a saved +# pause choice across restarts. +similar_artists_worker = None +try: + from core.similar_artists_worker import SimilarArtistsWorker + similar_artists_db = MusicDatabase() + similar_artists_worker = SimilarArtistsWorker(database=similar_artists_db) + similar_artists_worker.start() + if config_manager.get('similar_artists_enrichment_paused', False): + similar_artists_worker.pause() + logger.info("Similar Artists worker initialized (paused — restored from config)") + else: + logger.info("Similar Artists worker initialized and started") +except Exception as e: + logger.error(f"Similar Artists worker initialization failed: {e}") + similar_artists_worker = None + + # ================================================================================================ # SPOTIFY ENRICHMENT INTEGRATION # ================================================================================================ @@ -34640,6 +34674,11 @@ _register_enrichment_services([ worker_getter=lambda: amazon_worker, config_paused_key='amazon_enrichment_paused', ), + _EnrichmentService( + id='similar_artists', display_name='Similar Artists', + worker_getter=lambda: similar_artists_worker, + config_paused_key='similar_artists_enrichment_paused', + ), ]) _configure_enrichment_api( @@ -34722,6 +34761,7 @@ def _emit_enrichment_status_loop(): 'tidal-enrichment': lambda: tidal_enrichment_worker, 'qobuz-enrichment': lambda: qobuz_enrichment_worker, 'amazon-enrichment': lambda: amazon_worker, + 'similar_artists': lambda: similar_artists_worker, 'hydrabase': lambda: hydrabase_worker, 'soulid': lambda: soulid_worker, 'listening-stats': lambda: listening_stats_worker, diff --git a/webui/index.html b/webui/index.html index eb3f5ac6..f9c2507a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -565,6 +565,28 @@ + +
+ +
+
+
Similar Artists Enrichment
+
+
Status: Idle +
+
No active matches +
+
Progress: 0 / 0 +
+
+
+
+
- + ${(_emWorkerById[id] && _emWorkerById[id].relationship) ? '' + : ``}
diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js index 5cb59c37..efdfbb29 100644 --- a/webui/static/enrichment.js +++ b/webui/static/enrichment.js @@ -1311,6 +1311,91 @@ if (document.readyState === 'loading') { } } +// =================================================================== +// SIMILAR ARTISTS (MUSICMAP) WORKER — dashboard bubble +// =================================================================== + +async function updateSimilarArtistsEnrichmentStatus() { + if (socketConnected) return; + if (document.hidden) return; + try { + const response = await fetch('/api/enrichment/similar_artists/status'); + if (!response.ok) return; + updateSimilarArtistsEnrichmentStatusFromData(await response.json()); + } catch (error) { + console.error('Error updating Similar Artists enrichment status:', error); + } +} + +function updateSimilarArtistsEnrichmentStatusFromData(data) { + const button = document.getElementById('similar-artists-enrich-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.paused) button.classList.add('paused'); + else if (data.idle) button.classList.add('complete'); + else if (data.running && !data.paused) button.classList.add('active'); + + const tStatus = document.getElementById('similar-artists-enrich-tooltip-status'); + const tCurrent = document.getElementById('similar-artists-enrich-tooltip-current'); + const tProgress = document.getElementById('similar-artists-enrich-tooltip-progress'); + + if (tStatus) { + if (data.paused) tStatus.textContent = 'Paused'; + else if (data.idle) tStatus.textContent = 'Complete'; + else if (data.running) tStatus.textContent = 'Running'; + else tStatus.textContent = 'Idle'; + } + if (tCurrent) { + if (data.idle) tCurrent.textContent = 'All library artists processed'; + else if (data.current_item) tCurrent.textContent = `Now: ${data.current_item}`; + else tCurrent.textContent = 'No active matches'; + } + // DB-backed artist progress (matches the Manage modal exactly). + if (tProgress) { + const a = (data.progress && data.progress.artists) || {}; + tProgress.textContent = `Artists: ${a.matched || 0} / ${a.total || 0} (${a.percent || 0}%)`; + } +} + +async function toggleSimilarArtistsEnrichment() { + // Identical logic to the other orbs (e.g. toggleAmazonEnrichment): if the orb + // is 'active' (running) → pause, otherwise → resume. A paused orb isn't + // 'active', so this resumes it. + try { + const button = document.getElementById('similar-artists-enrich-button'); + if (!button) return; + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/enrichment/similar_artists/pause' : '/api/enrichment/similar_artists/resume'; + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Similar Artists enrichment`); + await updateSimilarArtistsEnrichmentStatus(); + } catch (error) { + console.error('Error toggling Similar Artists enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +// Initialize Similar Artists UI on page load — identical pattern to AudioDB/Deezer +// (addEventListener for the click; no inline onclick on the button). +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('similar-artists-enrich-button'); + if (button) { + button.addEventListener('click', toggleSimilarArtistsEnrichment); + updateSimilarArtistsEnrichmentStatus(); + setInterval(updateSimilarArtistsEnrichmentStatus, 2000); + } + }); +} else { + const button = document.getElementById('similar-artists-enrich-button'); + if (button) { + button.addEventListener('click', toggleSimilarArtistsEnrichment); + updateSimilarArtistsEnrichmentStatus(); + setInterval(updateSimilarArtistsEnrichmentStatus, 2000); + } +} + // =================================================================== // HYDRABASE P2P MIRROR WORKER // =================================================================== diff --git a/webui/static/library.js b/webui/static/library.js index 2eae1a2c..e9850567 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -879,15 +879,6 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt const bulkBar = document.getElementById('enhanced-bulk-bar'); if (bulkBar) bulkBar.classList.remove('visible'); - // Restore persisted view preference. Non-admins can't see / toggle the - // Enhanced control so only honour the saved choice for admins; default - // is still Standard. Wrapped in try/catch because localStorage can be - // disabled in private-browsing modes. - let _preferEnhanced = false; - try { - _preferEnhanced = localStorage.getItem(_libraryViewModeKey()) === 'enhanced'; - } catch (_) { /* localStorage unavailable */ } - // Navigate to artist detail page navigateToPage('artist-detail', { artistId, @@ -902,15 +893,11 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt _updateArtistDetailBackButtonLabel(); - // Load artist data + // Load artist data. The persisted Enhanced-view preference is applied INSIDE + // loadArtistDetailData, once we know whether this artist is in the library — + // source-only artists have no Enhanced view, so forcing it there left the + // Enhanced pane empty and hid the discography. loadArtistDetailData(artistId, artistName); - - // Apply persisted Enhanced view after the standard data load is kicked off. - // toggleEnhancedView() triggers its own loadEnhancedViewData() in parallel; - // the brief Standard render is hidden as soon as the toggle flips. - if (_preferEnhanced && isEnhancedAdmin()) { - toggleEnhancedView(true); - } } function _updateArtistDetailBackButtonLabel() { @@ -1095,6 +1082,18 @@ async function loadArtistDetailData(artistId, artistName) { checkArtistEnhanceEligibility(artistDetailPageState.currentArtistId); } + // Apply the persisted Enhanced/Standard preference now that we know the + // artist's status. Only LIBRARY artists have an Enhanced view — forcing + // it on a source-only artist (no DB record) showed an empty Enhanced pane + // and hid the discography. Source-only artists always stay on Standard. + if (!isSourceOnlyArtist && isEnhancedAdmin()) { + let _preferEnhanced = false; + try { + _preferEnhanced = localStorage.getItem(_libraryViewModeKey()) === 'enhanced'; + } catch (_) { /* localStorage unavailable */ } + if (_preferEnhanced) toggleEnhancedView(true); + } + } catch (error) { console.error(`❌ Error loading artist detail data:`, error); diff --git a/webui/static/style.css b/webui/static/style.css index 5ffeaa8b..575ce2c9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34248,6 +34248,21 @@ div.artist-hero-badge { .artmap-tool-btn.artmap-zoom { border: none; background: none; width: 30px; padding: 0; justify-content: center; border-radius: 6px; } .artmap-tool-btn.artmap-zoom:hover { background: rgba(255,255,255,0.06); } +/* Mobile: the toolbar wraps — back/title/stats + compact tools on row 1, the + search drops to its own full-width row below so nothing gets crushed. */ +@media (max-width: 760px) { + .artist-map-toolbar { flex-wrap: wrap; padding: 8px 10px; gap: 8px; } + /* Clear the fixed hamburger menu (top:16 left:16, 44px wide) so the map's + back button isn't hidden under it. */ + .artmap-nav-left { flex: 1 1 auto; min-width: 0; gap: 6px; margin-left: 52px; } + .artmap-brand-text { display: none; } + .artmap-brand { flex: none; } + .artmap-stats { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .artmap-nav-right { flex: 0 0 auto; gap: 3px; } + .artmap-nav-center { order: 3; flex: 1 1 100%; max-width: none; margin: 0; } + .artmap-search-wrap { width: 100%; } +} + /* Shortcuts modal */ .artmap-shortcuts-modal { background: rgba(20,20,32,0.95); border-radius: 14px; width: 340px; max-width: 90vw; @@ -34299,6 +34314,7 @@ div.artist-hero-badge { .artmap-tip-info { min-width: 0; } .artmap-tip-name { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .artmap-tip-badge { display: inline-block; font-size: 9px; font-weight: 700; color: #fbbf24; margin-top: 2px; } +.artmap-tip-conn { font-size: 10px; font-weight: 600; color: rgba(138,43,226,0.85); margin-top: 3px; } .artmap-tip-genres { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; } .artmap-tip-genres span { font-size: 9px; padding: 2px 6px; border-radius: 4px; @@ -34357,6 +34373,36 @@ div.artist-hero-badge { .artmap-explore-input::placeholder { color: rgba(255,255,255,0.2); } .artmap-search-prompt-actions { display: flex; justify-content: flex-end; gap: 8px; } +/* Artist Explorer — search + select picker */ +.artmap-explore-search-wrap { position: relative; } +.artmap-explore-search-wrap .artmap-explore-input { margin-bottom: 12px; } +.artmap-explore-spinner { position: absolute; right: 12px; top: 11px; pointer-events: none; } +.artmap-explore-spinner .watch-all-loading-spinner { width: 18px; height: 18px; border-width: 2px; } +.artmap-explore-results { + max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; + margin-bottom: 18px; +} +.artmap-explore-results:empty { margin-bottom: 0; } +.artmap-explore-result { + display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 10px; + cursor: pointer; transition: background 0.15s ease; +} +.artmap-explore-result:hover { background: rgba(138,43,226,0.14); } +.artmap-explore-result img { + width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; + background: rgba(255,255,255,0.06); +} +.artmap-explore-result-name { + flex: 1 1 auto; min-width: 0; color: #fff; font-size: 14px; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.artmap-explore-result-go { + flex: 0 0 auto; font-size: 11px; font-weight: 700; color: rgba(138,43,226,0.9); + opacity: 0; transition: opacity 0.15s ease; +} +.artmap-explore-result:hover .artmap-explore-result-go { opacity: 1; } +.artmap-explore-empty { padding: 18px; text-align: center; color: rgba(255,255,255,0.35); font-size: 13px; } + /* Genre picker modal */ .artmap-genre-picker-modal { background: linear-gradient(165deg, #1e1e32 0%, #181828 100%); @@ -39664,6 +39710,74 @@ div.artist-hero-badge { font-weight: 600; } +/* Similar Artists (MusicMap relationship worker) dashboard bubble — mirrors the + per-source bubbles with a purple accent. The MusicMap logo is a wide branded + image, so it's cover-cropped + rounded rather than icon-filtered. */ +.similar-artists-enrich-button-container { position: relative; display: inline-block; margin-right: 12px; } +.similar-artists-enrich-button { + position: relative; width: 44px; height: 44px; + background: linear-gradient(135deg, rgba(168,85,247,0.10) 0%, rgba(124,58,237,0.16) 100%); + backdrop-filter: blur(20px) saturate(1.4); -webkit-backdrop-filter: blur(20px) saturate(1.4); + border: 1.5px solid rgba(168,85,247,0.22); border-radius: 50%; cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all 0.3s cubic-bezier(0.4,0,0.2,1); + box-shadow: 0 4px 16px rgba(168,85,247,0.12), 0 2px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.08); +} +.similar-artists-enrich-logo { + width: 26px; height: 26px; opacity: 0.7; object-fit: cover; border-radius: 7px; + transition: all 0.3s ease; +} +.similar-artists-enrich-button:hover { + transform: scale(1.1); border-color: rgba(168,85,247,0.45); + box-shadow: 0 6px 24px rgba(168,85,247,0.28), 0 3px 12px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.12); +} +.similar-artists-enrich-spinner { + position: absolute; width: 44px; height: 44px; border: 3px solid transparent; + border-top-color: rgba(168,85,247,0.85); border-right-color: rgba(168,85,247,0.5); + border-radius: 50%; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; +} +@keyframes similar-artists-enrich-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } +.similar-artists-enrich-button.active .similar-artists-enrich-spinner { opacity: 1; animation: similar-artists-enrich-spin 1.2s linear infinite; } +.similar-artists-enrich-button.active { + background: linear-gradient(135deg, rgba(168,85,247,0.20) 0%, rgba(124,58,237,0.26) 100%); + border-color: rgba(168,85,247,0.55); + box-shadow: 0 6px 24px rgba(168,85,247,0.35), 0 3px 12px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.15); +} +.similar-artists-enrich-button.active .similar-artists-enrich-logo { opacity: 1; } +.similar-artists-enrich-button.complete { + background: linear-gradient(135deg, rgba(76,175,80,0.15) 0%, rgba(56,142,60,0.20) 100%); + border-color: rgba(76,175,80,0.35); + box-shadow: 0 4px 16px rgba(76,175,80,0.2), 0 2px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.08); +} +.similar-artists-enrich-button.complete .similar-artists-enrich-spinner { opacity: 0; } +.similar-artists-enrich-button.paused { + background: linear-gradient(135deg, rgba(255,193,7,0.12) 0%, rgba(255,152,0,0.18) 100%); + border-color: rgba(255,193,7,0.35); + box-shadow: 0 4px 16px rgba(255,193,7,0.2), 0 2px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.08); +} +.similar-artists-enrich-button.paused .similar-artists-enrich-logo { opacity: 0.5; } +.similar-artists-enrich-button.paused .similar-artists-enrich-spinner { opacity: 0; } +.similar-artists-enrich-tooltip { + position: absolute; left: 50%; top: calc(100% + 12px); + transform: translateX(-50%) translateY(-5px); z-index: 5000; + opacity: 0; visibility: hidden; transition: all 0.3s cubic-bezier(0.4,0,0.2,1); pointer-events: none; +} +.similar-artists-enrich-button:hover + .similar-artists-enrich-tooltip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); } +.similar-artists-enrich-tooltip-content { + min-width: 260px; + background: linear-gradient(135deg, rgba(30,30,30,0.98) 0%, rgba(20,20,20,0.99) 100%); + backdrop-filter: blur(40px) saturate(1.6); -webkit-backdrop-filter: blur(40px) saturate(1.6); + border: 1px solid rgba(255,255,255,0.3); border-radius: 16px; padding: 16px 18px; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 6px 20px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.1); +} +.similar-artists-enrich-tooltip-header { + font-family: 'SF Pro Display', -apple-system, sans-serif; font-size: 13px; font-weight: 600; + color: rgba(255,255,255,0.95); letter-spacing: -0.2px; margin-bottom: 12px; + padding-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.08); +} +.similar-artists-enrich-tooltip-body { display: flex; flex-direction: column; gap: 8px; } +#similar-artists-enrich-tooltip-status { color: #c084fc; font-weight: 600; } + .deezer-button-container { position: relative; display: inline-block; diff --git a/webui/static/worker-orbs.js b/webui/static/worker-orbs.js index a578809b..3e137429 100644 --- a/webui/static/worker-orbs.js +++ b/webui/static/worker-orbs.js @@ -22,6 +22,7 @@ { container: '.qobuz-enrich-button-container', color: [1, 112, 239] }, { container: '.discogs-button-container', color: [180, 180, 180] }, { container: '.amazon-enrich-button-container', color: [255, 153, 0] }, + { container: '.similar-artists-enrich-button-container', color: [168, 85, 247] }, { container: '.hydrabase-button-container', color: [200, 200, 200] }, { container: '.soulid-button-container', color: [29, 185, 84], rainbow: true }, { container: '.repair-button-container', color: [180, 130, 255], rainbow: true },