Merge pull request #782 from Nezreka/feature/artist-map-v2

Feature/artist map v2
This commit is contained in:
BoulderBadgeDad 2026-06-03 16:38:53 -07:00 committed by GitHub
commit 25222bd8f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2494 additions and 750 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -565,6 +565,28 @@
</div>
</div>
</div>
<!-- Similar Artists (MusicMap) Enrichment Status Icon -->
<div class="similar-artists-enrich-button-container">
<button class="similar-artists-enrich-button" id="similar-artists-enrich-button" title="Similar Artists (MusicMap) Enrichment">
<img src="https://www.music-map.com/elements/objects/og_logo.png"
alt="Similar Artists" class="similar-artists-enrich-logo">
<div class="similar-artists-enrich-spinner"></div>
</button>
<div class="similar-artists-enrich-tooltip" id="similar-artists-enrich-tooltip">
<div class="similar-artists-enrich-tooltip-content">
<div class="similar-artists-enrich-tooltip-header">Similar Artists Enrichment</div>
<div class="similar-artists-enrich-tooltip-body" id="similar-artists-enrich-tooltip-body">
<div class="tooltip-status">Status: <span
id="similar-artists-enrich-tooltip-status">Idle</span>
</div>
<div class="tooltip-current" id="similar-artists-enrich-tooltip-current">No active matches
</div>
<div class="tooltip-progress" id="similar-artists-enrich-tooltip-progress">Progress: 0 / 0
</div>
</div>
</div>
</div>
</div>
<!-- Hydrabase P2P Mirror Status Icon -->
<div class="hydrabase-button-container" id="hydrabase-button-container" style="display: none;">
<button class="hydrabase-button" id="hydrabase-button" title="Hydrabase P2P Mirror">

View file

@ -459,6 +459,7 @@ function initializeWebSocket() {
socket.on('enrichment:tidal-enrichment', (data) => updateTidalEnrichmentStatusFromData(data));
socket.on('enrichment:qobuz-enrichment', (data) => updateQobuzEnrichmentStatusFromData(data));
socket.on('enrichment:amazon-enrichment', (data) => updateAmazonEnrichmentStatusFromData(data));
socket.on('enrichment:similar_artists', (data) => updateSimilarArtistsEnrichmentStatusFromData(data));
socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data));
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data));

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,12 @@ const ENRICHMENT_WORKERS = [
{ id: 'tidal', name: 'Tidal', color: '#00cfe6', logoSel: '.tidal-enrich-logo', imgFilter: 'invert(1) brightness(1.8)', imgRound: true },
{ id: 'qobuz', name: 'Qobuz', color: '#0070ef', logoSel: '.qobuz-enrich-logo', imgFilter: 'invert(1)', imgRound: true },
{ id: 'amazon', name: 'Amazon Music', color: '#ff9900', logoSel: '.amazon-enrich-logo', imgFilter: 'brightness(0) invert(1)' },
// Relationship worker (MusicMap → similar artists), not a metadata source.
// No dashboard logo element, so it uses a direct MusicMap logo URL.
// relationship:true tells the detail panel to drop the (inapplicable)
// manual-match affordance.
{ id: 'similar_artists', name: 'Similar Artists', color: '#a855f7', relationship: true,
logoUrl: 'https://www.music-map.com/elements/objects/og_logo.png', imgRound: true },
];
const _emWorkerById = Object.fromEntries(ENRICHMENT_WORKERS.map(w => [w.id, w]));
@ -48,7 +54,9 @@ function _emHexToRgb(hex) {
// Resolve a worker's logo URL from the live dashboard bubble (null if absent).
function _emLogoSrc(workerId) {
const w = _emWorkerById[workerId];
if (!w || !w.logoSel) return null;
if (!w) return null;
if (w.logoUrl) return w.logoUrl; // direct URL (workers w/o a dashboard logo)
if (!w.logoSel) return null;
const img = document.querySelector(w.logoSel);
return img && img.src ? img.src : null;
}
@ -825,7 +833,8 @@ function _emRenderUnmatchedList() {
<div class="em-row-meta">${statusBadge} ${last}</div>
</div>
<div class="em-row-actions">
<button class="em-btn em-btn--sm" onclick="openEnrichmentMatch('${id}','${entity}','${safeId}', this)">Match</button>
${(_emWorkerById[id] && _emWorkerById[id].relationship) ? ''
: `<button class="em-btn em-btn--sm" onclick="openEnrichmentMatch('${id}','${entity}','${safeId}', this)">Match</button>`}
<button class="em-btn em-btn--sm em-btn--ghost" title="Re-queue for the worker to try again"
onclick="retryEnrichmentItem('${id}','${entity}','${safeId}', this)">Retry</button>
</div>

View file

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

View file

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

View file

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

View file

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