Merge pull request #782 from Nezreka/feature/artist-map-v2
Feature/artist map v2
This commit is contained in:
commit
25222bd8f7
13 changed files with 2494 additions and 750 deletions
|
|
@ -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
|
||||
|
|
|
|||
343
core/similar_artists_worker.py
Normal file
343
core/similar_artists_worker.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
146
tests/test_similar_artists_worker.py
Normal file
146
tests/test_similar_artists_worker.py
Normal 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 → 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ===================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue