diff --git a/core/api_call_tracker.py b/core/api_call_tracker.py index 357ba4e4..f90a6dae 100644 --- a/core/api_call_tracker.py +++ b/core/api_call_tracker.py @@ -267,7 +267,12 @@ class ApiCallTracker: def save(self): - """Persist 24h minute history to disk. Call on shutdown.""" + """Persist 24h minute history to disk. Call on shutdown. + + Uses an atomic write (write to a sibling .tmp file, fsync, rename) so + a SIGINT/SIGTERM/crash mid-write can't leave the JSON file truncated. + Without this, an interrupted shutdown corrupts the history file and + the next startup loses 24h of metrics.""" try: now = time.time() cutoff = now - 86400 @@ -283,10 +288,22 @@ class ApiCallTracker: if entries: data[key] = entries events = [dict(e) for e in self._events if e['ts'] >= cutoff] - with open(_PERSIST_PATH, 'w') as f: - json.dump({'ts': now, 'history': data, 'events': events}, f) + + payload = {'ts': now, 'history': data, 'events': events} + tmp_path = _PERSIST_PATH + '.tmp' + with open(tmp_path, 'w') as f: + json.dump(payload, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, _PERSIST_PATH) except Exception as e: logger.error(f"[ApiCallTracker] Failed to save history: {e}") + # Best-effort cleanup of stale tmp file from a failed write. + try: + if os.path.exists(_PERSIST_PATH + '.tmp'): + os.remove(_PERSIST_PATH + '.tmp') + except Exception: + pass def _load(self): """Restore 24h minute history from disk. Called on init.""" diff --git a/core/artist_source_detail.py b/core/artist_source_detail.py new file mode 100644 index 00000000..c62a5293 --- /dev/null +++ b/core/artist_source_detail.py @@ -0,0 +1,186 @@ +"""Synthesize an artist-detail response for an artist that isn't in the library. + +Extracted from ``web_server.py`` so the logic is importable at test time. +The route handler in ``web_server.py`` is now a thin wrapper that builds the +per-source clients (which live as module globals there), calls this function, +and wraps the return value in ``jsonify``. + +Used by ``/api/artist-detail/`` when the URL is called with a ``source`` +query parameter and the library DB lookup misses. Enriches the response with +whatever metadata we can pull on demand: + + * Image URL (via ``metadata_service.get_artist_image_url``) + * Source-specific artist info — genres + follower count from the named + source's ``get_artist`` / ``get_artist_info`` helper + * Last.fm bio + listeners + playcount + URL (by artist name) + * Discography from the named source, with variant dedup disabled so every + release surfaces + +All per-source clients are passed in explicitly. Callers that can't or don't +want to provide a given client pass ``None`` — the corresponding enrichment +branch becomes a no-op. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional, Tuple + +from core.artist_source_lookup import SOURCE_ID_FIELD + +logger = logging.getLogger("artist_source_detail") + + +def build_source_only_artist_detail( + artist_id: str, + artist_name: str, + source: str, + *, + spotify_client: Optional[Any] = None, + deezer_client: Optional[Any] = None, + itunes_client: Optional[Any] = None, + discogs_client: Optional[Any] = None, + lastfm_api_key: Optional[str] = None, +) -> Tuple[Dict[str, Any], int]: + """Build the artist-detail payload for a source-only artist. + + Returns ``(payload_dict, http_status)``. Callers wrap the dict in + ``jsonify`` or equivalent. Status is 200 on success, 404 when the + source's discography lookup returned no releases. + """ + # Deferred import — keeps the top-level module importable in test rigs + # that stub out only what they need (same pattern `_find_library_artist` + # uses). + from core.metadata_service import ( + MetadataLookupOptions, + get_artist_detail_discography, + get_artist_image_url, + ) + + resolved_name = (artist_name or artist_id or "").strip() + + # 1. Image URL via the same helper /api/artist//image uses. + image_url: Optional[str] = None + try: + image_url = get_artist_image_url(artist_id, source_override=source) + except Exception as e: + logger.debug(f"Artist image lookup failed for {source}:{artist_id}: {e}") + + # 2. Source-side artist info (image, genres, followers depending on source). + source_genres: list = [] + source_followers: Optional[int] = None + try: + if source == "spotify" and spotify_client is not None: + sp_artist = spotify_client.get_artist(artist_id, allow_fallback=False) + if sp_artist: + source_genres = sp_artist.get("genres") or [] + source_followers = (sp_artist.get("followers") or {}).get("total") + if not image_url and sp_artist.get("images"): + image_url = sp_artist["images"][0].get("url") + elif source == "deezer" and deezer_client is not None: + dz_artist = deezer_client.get_artist_info(artist_id) + if dz_artist: + source_genres = dz_artist.get("genres") or [] + source_followers = (dz_artist.get("followers") or {}).get("total") + elif source == "itunes" and itunes_client is not None: + it_artist = itunes_client.get_artist(artist_id) + if it_artist: + source_genres = it_artist.get("genres") or [] + elif source == "discogs" and discogs_client is not None: + dc_artist = discogs_client.get_artist(artist_id) + if dc_artist: + source_genres = dc_artist.get("genres") or [] + except Exception as e: + logger.debug(f"Source-side artist info lookup failed for {source}:{artist_id}: {e}") + + # 3. Last.fm enrichment by artist name. + lastfm_bio: Optional[str] = None + lastfm_listeners: Optional[int] = None + lastfm_playcount: Optional[int] = None + lastfm_url: Optional[str] = None + if resolved_name and lastfm_api_key: + try: + from core.lastfm_client import LastFMClient + lastfm = LastFMClient(api_key=lastfm_api_key) + lf_info = lastfm.get_artist_info(resolved_name) + if lf_info: + bio_obj = lf_info.get("bio") or {} + lastfm_bio = bio_obj.get("content") or bio_obj.get("summary") + stats_obj = lf_info.get("stats") or {} + if stats_obj.get("listeners"): + try: + lastfm_listeners = int(stats_obj["listeners"]) + except (ValueError, TypeError): + pass + if stats_obj.get("playcount"): + try: + lastfm_playcount = int(stats_obj["playcount"]) + except (ValueError, TypeError): + pass + lastfm_url = lf_info.get("url") + except Exception as e: + logger.debug(f"Last.fm enrichment failed for {resolved_name}: {e}") + + # 4. Discography from the specified source. Skip variant dedup so the + # page shows every release the source returns — matches the inline + # Artists-page behaviour that this view was modelled after. + discography_result = get_artist_detail_discography( + artist_id, + artist_name=resolved_name or artist_id, + options=MetadataLookupOptions( + source_override=source, + allow_fallback=True, + skip_cache=False, + max_pages=0, + limit=50, + artist_source_ids={source: artist_id}, + dedup_variants=False, + ), + ) + + if not discography_result.get("success"): + return { + "success": False, + "error": discography_result.get("error", "Could not load discography"), + "source": source, + }, 404 + + artist_info: Dict[str, Any] = { + "id": artist_id, + "name": resolved_name or artist_id, + "image_url": image_url, + "server_source": None, # not in library + "genres": source_genres, + } + + # Stamp the source-specific ID so the correct service badge renders on the + # hero (e.g. source=deezer -> deezer_id; source=spotify -> spotify_artist_id). + source_id_field = SOURCE_ID_FIELD.get(source) + if source_id_field: + artist_info[source_id_field] = artist_id + + if source_followers is not None: + artist_info["followers"] = source_followers + if lastfm_bio: + artist_info["lastfm_bio"] = lastfm_bio + if lastfm_listeners is not None: + artist_info["lastfm_listeners"] = lastfm_listeners + if lastfm_playcount is not None: + artist_info["lastfm_playcount"] = lastfm_playcount + if lastfm_url: + artist_info["lastfm_url"] = lastfm_url + + logger.info( + f"Source-only artist-detail: {artist_info['name']} from {source} — " + f"albums={len(discography_result.get('albums', []))}, " + f"eps={len(discography_result.get('eps', []))}, " + f"singles={len(discography_result.get('singles', []))}, " + f"genres={len(source_genres)}, lastfm_bio={'yes' if lastfm_bio else 'no'}" + ) + + return { + "success": True, + "artist": artist_info, + "discography": discography_result, + "enrichment_coverage": {}, + }, 200 diff --git a/core/artist_source_lookup.py b/core/artist_source_lookup.py new file mode 100644 index 00000000..b965d4cf --- /dev/null +++ b/core/artist_source_lookup.py @@ -0,0 +1,88 @@ +"""Source-artist → library lookup helpers. + +Extracted from `web_server.py` so the logic can be imported and unit-tested +without booting the Flask app, Spotify client, Soulseek connection, etc. + +Two concepts live here: + + * ``SOURCE_ID_FIELD`` — the per-source column on the ``artists`` table that + stores the external service ID (Spotify track ID, Deezer artist ID, …). + This map is what ties a result clicked in the source-aware Search results + back to a library record so we can serve the richer library view. + + * ``find_library_artist_for_source`` — given a source-aware click (e.g. + ``deezer:525046``), try to locate a matching library artist. First by + direct column match against the source's ID column, then by case- + insensitive name match scoped to the active media server. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +logger = logging.getLogger("artist_source_lookup") + + +SOURCE_ONLY_ARTIST_SOURCES = frozenset({ + "spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz", +}) + + +SOURCE_ID_FIELD = { + "spotify": "spotify_artist_id", + "itunes": "itunes_artist_id", + "deezer": "deezer_id", + "discogs": "discogs_id", + "hydrabase": "soul_id", + "musicbrainz": "musicbrainz_id", +} + + +def find_library_artist_for_source( + database, + source: str, + source_artist_id: str, + artist_name: Optional[str] = None, + active_server: Optional[str] = None, +) -> Optional[str]: + """Return the library PK of an artist matching the source-aware click. + + Lookup order: + 1. Direct match on the source-specific ID column (server-agnostic — any + library record with the right external ID is a hit). + 2. Case-insensitive name match within ``active_server`` (defaults to the + active media server when not provided), so we don't jump the user + across server contexts on a name collision. + + Returns ``None`` on miss or on any database error. + """ + column = SOURCE_ID_FIELD.get(source) + if not column: + return None + + try: + with database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1", + (str(source_artist_id),), + ) + row = cursor.fetchone() + if row: + return row[0] + + if artist_name and active_server: + cursor.execute( + "SELECT id FROM artists " + "WHERE LOWER(name) = LOWER(?) AND server_source = ? LIMIT 1", + (artist_name, active_server), + ) + row = cursor.fetchone() + if row: + return row[0] + except Exception as e: + logger.debug( + f"Library upgrade lookup failed for {source}:{source_artist_id}: {e}" + ) + return None diff --git a/core/metadata_service.py b/core/metadata_service.py index 04013a49..15387201 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -36,6 +36,10 @@ class MetadataLookupOptions: max_pages: int = 0 limit: int = 50 artist_source_ids: Optional[Dict[str, str]] = None + dedup_variants: bool = True # Collapse "Deluxe Edition" / "Remastered" etc. + # into a single canonical release card. Off + # gives the inline-Artists-page behaviour of + # showing every variant the source returns. # ============================================================================= @@ -633,9 +637,10 @@ def get_artist_detail_discography( else: albums.append(card) - albums = _dedup_variant_releases(albums) - eps = _dedup_variant_releases(eps) - singles = _dedup_variant_releases(singles) + if options is None or options.dedup_variants: + albums = _dedup_variant_releases(albums) + eps = _dedup_variant_releases(eps) + singles = _dedup_variant_releases(singles) albums = _sort_discography_releases(albums) eps = _sort_discography_releases(eps) diff --git a/tests/test_artist_source_detail.py b/tests/test_artist_source_detail.py new file mode 100644 index 00000000..400bf7fe --- /dev/null +++ b/tests/test_artist_source_detail.py @@ -0,0 +1,274 @@ +"""Tests for ``core.artist_source_detail.build_source_only_artist_detail``. + +The function used to live inline inside ``web_server.py``; a prior version of +these tests AST-parsed the function body to assert on response keys because +``web_server.py`` couldn't be imported at test time. Now that the logic lives +in a side-effect-free core module with dependency-injected clients, the tests +just call it directly with mocks. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from core import artist_source_detail +from core.artist_source_detail import build_source_only_artist_detail + + +# --------------------------------------------------------------------------- +# Fixtures — stubs for the metadata_service helpers the function calls +# --------------------------------------------------------------------------- + +def _success_discography(**overrides): + result = { + "success": True, + "albums": [{"id": "a1", "title": "Album One"}], + "eps": [], + "singles": [{"id": "s1", "title": "Single One"}], + } + result.update(overrides) + return result + + +def _empty_discography(): + return { + "success": False, + "error": "No releases found for artist", + } + + +@pytest.fixture +def _stub_metadata(monkeypatch): + """Replace the metadata_service imports with controllable stubs. + + The function imports ``get_artist_image_url`` and + ``get_artist_detail_discography`` lazily inside its body (deferred import), + so we patch on the metadata_service module directly. + """ + from core import metadata_service + + state = { + "image_url": None, + "discography": _success_discography(), + "last_options": None, + "last_discog_call": None, + } + + def fake_get_artist_image_url(artist_id, source_override=None): + return state["image_url"] + + def fake_get_artist_detail_discography(artist_id, artist_name="", options=None): + state["last_options"] = options + state["last_discog_call"] = (artist_id, artist_name) + return state["discography"] + + monkeypatch.setattr(metadata_service, "get_artist_image_url", fake_get_artist_image_url) + monkeypatch.setattr(metadata_service, "get_artist_detail_discography", fake_get_artist_detail_discography) + + return state + + +# --------------------------------------------------------------------------- +# Group A — Success-path response shape + source-specific ID stamping +# --------------------------------------------------------------------------- + +class TestResponseShape: + def test_success_returns_expected_envelope(self, _stub_metadata): + payload, status = build_source_only_artist_detail( + "dz-123", "Artist One", "deezer", + ) + assert status == 200 + assert payload["success"] is True + assert payload["discography"] == _stub_metadata["discography"] + assert payload["enrichment_coverage"] == {} + assert payload["artist"]["id"] == "dz-123" + assert payload["artist"]["name"] == "Artist One" + assert payload["artist"]["server_source"] is None + assert "genres" in payload["artist"] + + def test_empty_artist_name_falls_back_to_id(self, _stub_metadata): + payload, status = build_source_only_artist_detail( + "dz-123", "", "deezer", + ) + assert status == 200 + assert payload["artist"]["name"] == "dz-123" + + def test_failure_returns_404(self, _stub_metadata): + _stub_metadata["discography"] = _empty_discography() + payload, status = build_source_only_artist_detail( + "dz-missing", "Unknown Artist", "deezer", + ) + assert status == 404 + assert payload["success"] is False + assert payload["source"] == "deezer" + assert "error" in payload + + @pytest.mark.parametrize("source,expected_field", [ + ("spotify", "spotify_artist_id"), + ("itunes", "itunes_artist_id"), + ("deezer", "deezer_id"), + ("discogs", "discogs_id"), + ("hydrabase", "soul_id"), + ("musicbrainz", "musicbrainz_id"), + ]) + def test_source_specific_id_field_is_stamped(self, _stub_metadata, source, expected_field): + payload, _ = build_source_only_artist_detail("the-id", "Artist", source) + assert payload["artist"][expected_field] == "the-id" + + +# --------------------------------------------------------------------------- +# Group B — Discography options contract (the bug that motivated the extract) +# --------------------------------------------------------------------------- + +class TestDiscographyOptions: + def test_dedup_variants_disabled(self, _stub_metadata): + """Source-only view must show every release variant, matching the + retired inline Artists page behaviour.""" + build_source_only_artist_detail("dz-1", "Artist", "deezer") + opts = _stub_metadata["last_options"] + assert opts is not None + assert opts.dedup_variants is False + + def test_passes_source_override_and_artist_source_ids(self, _stub_metadata): + build_source_only_artist_detail("sp-999", "Artist", "spotify") + opts = _stub_metadata["last_options"] + assert opts.source_override == "spotify" + assert opts.artist_source_ids == {"spotify": "sp-999"} + + +# --------------------------------------------------------------------------- +# Group C — Per-source enrichment +# --------------------------------------------------------------------------- + +class TestPerSourceEnrichment: + def test_spotify_extracts_genres_followers_and_image_fallback(self, _stub_metadata): + spotify = SimpleNamespace( + get_artist=lambda aid, allow_fallback=False: { + "genres": ["alt rock", "emo"], + "followers": {"total": 12345}, + "images": [{"url": "https://sp/img.jpg"}], + } + ) + payload, _ = build_source_only_artist_detail( + "sp-1", "Artist", "spotify", spotify_client=spotify, + ) + assert payload["artist"]["genres"] == ["alt rock", "emo"] + assert payload["artist"]["followers"] == 12345 + # image_url falls back to Spotify's image when metadata_service returned None + assert payload["artist"]["image_url"] == "https://sp/img.jpg" + + def test_deezer_extracts_genres_and_followers(self, _stub_metadata): + deezer = SimpleNamespace( + get_artist_info=lambda aid: { + "genres": ["pop"], + "followers": {"total": 500}, + } + ) + payload, _ = build_source_only_artist_detail( + "dz-1", "Artist", "deezer", deezer_client=deezer, + ) + assert payload["artist"]["genres"] == ["pop"] + assert payload["artist"]["followers"] == 500 + + def test_itunes_extracts_genres_only(self, _stub_metadata): + itunes = SimpleNamespace(get_artist=lambda aid: {"genres": ["rock"]}) + payload, _ = build_source_only_artist_detail( + "it-1", "Artist", "itunes", itunes_client=itunes, + ) + assert payload["artist"]["genres"] == ["rock"] + assert "followers" not in payload["artist"] + + def test_discogs_extracts_genres_only(self, _stub_metadata): + discogs = SimpleNamespace(get_artist=lambda aid: {"genres": ["jazz"]}) + payload, _ = build_source_only_artist_detail( + "dc-1", "Artist", "discogs", discogs_client=discogs, + ) + assert payload["artist"]["genres"] == ["jazz"] + + def test_client_none_is_safe(self, _stub_metadata): + """Missing client for the requested source is a no-op, not a crash.""" + payload, status = build_source_only_artist_detail( + "dz-1", "Artist", "deezer", deezer_client=None, + ) + assert status == 200 + assert payload["artist"]["genres"] == [] + + def test_client_exception_does_not_propagate(self, _stub_metadata): + """A failing source client should log and move on; the response still builds.""" + def _boom(_): + raise RuntimeError("deezer down") + + deezer = SimpleNamespace(get_artist_info=_boom) + payload, status = build_source_only_artist_detail( + "dz-1", "Artist", "deezer", deezer_client=deezer, + ) + assert status == 200 + assert payload["artist"]["genres"] == [] + + +# --------------------------------------------------------------------------- +# Group D — Last.fm enrichment +# --------------------------------------------------------------------------- + +class _FakeLastFM: + def __init__(self, api_key=None): + self.api_key = api_key + + def get_artist_info(self, name): + return { + "bio": {"content": "Long bio text", "summary": "Short summary"}, + "stats": {"listeners": "5000", "playcount": "99999"}, + "url": "https://last.fm/artist", + } + + +class TestLastFmEnrichment: + def _patch_lastfm(self, monkeypatch, cls=_FakeLastFM): + import core.lastfm_client as lastfm_module + monkeypatch.setattr(lastfm_module, "LastFMClient", cls) + + def test_lastfm_fields_populated_when_api_key_present(self, _stub_metadata, monkeypatch): + self._patch_lastfm(monkeypatch) + payload, _ = build_source_only_artist_detail( + "dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY", + ) + artist = payload["artist"] + assert artist["lastfm_bio"] == "Long bio text" + assert artist["lastfm_listeners"] == 5000 + assert artist["lastfm_playcount"] == 99999 + assert artist["lastfm_url"] == "https://last.fm/artist" + + def test_no_lastfm_when_api_key_missing(self, _stub_metadata, monkeypatch): + self._patch_lastfm(monkeypatch) + payload, _ = build_source_only_artist_detail( + "dz-1", "Artist", "deezer", lastfm_api_key=None, + ) + assert "lastfm_bio" not in payload["artist"] + assert "lastfm_listeners" not in payload["artist"] + + def test_summary_used_when_bio_content_missing(self, _stub_metadata, monkeypatch): + class _SummaryOnly(_FakeLastFM): + def get_artist_info(self, name): + return { + "bio": {"summary": "Just a summary"}, + "stats": {}, + "url": "", + } + self._patch_lastfm(monkeypatch, _SummaryOnly) + payload, _ = build_source_only_artist_detail( + "dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY", + ) + assert payload["artist"]["lastfm_bio"] == "Just a summary" + + def test_lastfm_exception_does_not_propagate(self, _stub_metadata, monkeypatch): + class _Broken(_FakeLastFM): + def get_artist_info(self, name): + raise RuntimeError("last.fm rate limited") + self._patch_lastfm(monkeypatch, _Broken) + payload, status = build_source_only_artist_detail( + "dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY", + ) + assert status == 200 + assert "lastfm_bio" not in payload["artist"] diff --git a/tests/test_artist_source_lookup.py b/tests/test_artist_source_lookup.py new file mode 100644 index 00000000..75ca4582 --- /dev/null +++ b/tests/test_artist_source_lookup.py @@ -0,0 +1,239 @@ +"""Tests for the source-artist → library lookup helpers in +``core/artist_source_lookup.py``. + +These exist to catch the class of bug we hit in April 2026 where the +watchlist-config enrichment query referenced a column name (``deezer_artist_id``) +that lived on ``watchlist_artists`` but NOT on ``artists``, producing a +``no such column`` error on every request. + +The earlier version of this file AST-parsed ``web_server.py`` because the +logic lived inline there and could not be imported at test time. The logic +has since been extracted to a side-effect-free module, so we can just import +and call it directly. +""" + +from __future__ import annotations + +import pytest + +from core.artist_source_lookup import ( + SOURCE_ID_FIELD, + SOURCE_ONLY_ARTIST_SOURCES, + find_library_artist_for_source, +) +from database.music_database import MusicDatabase + + +EXPECTED_SOURCE_ID_FIELD = { + "spotify": "spotify_artist_id", + "itunes": "itunes_artist_id", + "deezer": "deezer_id", + "discogs": "discogs_id", + "hydrabase": "soul_id", + "musicbrainz": "musicbrainz_id", +} + + +@pytest.fixture +def db(tmp_path): + """Fresh MusicDatabase — runs all migrations so source-id columns exist.""" + return MusicDatabase(str(tmp_path / "music.db")) + + +def _insert_artist(db, *, artist_id, name, server_source="plex", **extra): + """Insert a row into the artists table with the given extra columns.""" + cols = ["id", "name", "server_source"] + list(extra.keys()) + vals = [artist_id, name, server_source] + list(extra.values()) + placeholders = ",".join("?" for _ in cols) + with db._get_connection() as conn: + conn.execute( + f"INSERT INTO artists ({','.join(cols)}) VALUES ({placeholders})", + vals, + ) + conn.commit() + + +# =========================================================================== +# Group A — SOURCE_ID_FIELD constants +# =========================================================================== + +class TestSourceIdFieldMapping: + """The mapping the lookup uses to join source artists back to the library + ``artists`` table must stay in sync with this test's expectations AND with + the real column names on the table.""" + + def test_mapping_matches_expected(self): + assert SOURCE_ID_FIELD == EXPECTED_SOURCE_ID_FIELD, ( + "SOURCE_ID_FIELD changed; update EXPECTED_SOURCE_ID_FIELD " + "(and the test body) to match." + ) + + def test_source_only_set_matches_mapping_keys(self): + """Sources eligible for the source-only fallback must all have a + column to look them up by — otherwise the upgrade path silently + returns None.""" + assert SOURCE_ONLY_ARTIST_SOURCES == frozenset(SOURCE_ID_FIELD.keys()) + + def test_every_mapped_column_exists_on_artists_table(self, db): + """Regression for the 2026-04 ``deezer_artist_id`` typo: every column + referenced by SOURCE_ID_FIELD must exist on the ``artists`` table.""" + with db._get_connection() as conn: + cursor = conn.execute("PRAGMA table_info(artists)") + existing = {row[1] for row in cursor.fetchall()} + + missing = { + source: column + for source, column in SOURCE_ID_FIELD.items() + if column not in existing + } + assert not missing, ( + "Columns declared in SOURCE_ID_FIELD are missing from the " + f"artists table: {missing}. Available columns: {sorted(existing)}" + ) + + +# =========================================================================== +# Group B — find_library_artist_for_source behaviour +# =========================================================================== + +class TestFindLibraryArtistForSource: + """Behavioural tests against a real (in-memory) MusicDatabase.""" + + @pytest.mark.parametrize("source,column", list(EXPECTED_SOURCE_ID_FIELD.items())) + def test_lookup_by_source_id_column(self, db, source, column): + source_value = f"{source}-test-artist-123" + _insert_artist( + db, + artist_id=f"pk-{source}", + name=f"{source.title()} Test Artist", + **{column: source_value}, + ) + + result = find_library_artist_for_source(db, source, source_value) + assert result == f"pk-{source}" + + def test_unknown_source_returns_none(self, db): + assert find_library_artist_for_source( + db, "made-up-source", "anything", artist_name="Anything" + ) is None + + def test_lookup_misses_when_source_id_unknown(self, db): + _insert_artist(db, artist_id="pk-real", name="Real Artist", deezer_id="dz-real") + assert find_library_artist_for_source(db, "deezer", "dz-not-real") is None + + def test_artist_name_is_optional(self, db): + """Callers that don't have a name handy should be able to omit it + without falling through to the name-fallback branch.""" + _insert_artist(db, artist_id="pk-q", name="Some Artist", server_source="plex") + # No source-id match, no name passed → must return None even when + # active_server is set (otherwise we'd risk matching by None name). + assert find_library_artist_for_source( + db, "deezer", "no-id-match", active_server="plex" + ) is None + + def test_name_fallback_matches_within_active_server(self, db): + _insert_artist(db, artist_id="pk-a", name="Kendrick Lamar", server_source="plex") + _insert_artist(db, artist_id="pk-b", name="KENDRICK LAMAR", server_source="jellyfin") + + result = find_library_artist_for_source( + db, "deezer", "no-id-match", artist_name="kendrick lamar", + active_server="plex", + ) + assert result == "pk-a" + + def test_name_fallback_skips_other_servers(self, db): + """Active-server scope is required so we don't jump the user across + server contexts on a name collision.""" + _insert_artist(db, artist_id="pk-jelly", name="Taylor Swift", server_source="jellyfin") + + result = find_library_artist_for_source( + db, "deezer", "no-id-match", artist_name="Taylor Swift", + active_server="plex", + ) + assert result is None + + def test_name_fallback_requires_active_server(self, db): + """Without an active_server we shouldn't fall through to a global + name match — too easy to land the user on the wrong record.""" + _insert_artist(db, artist_id="pk-x", name="Some Artist", server_source="plex") + + result = find_library_artist_for_source( + db, "deezer", "no-id-match", artist_name="Some Artist", + active_server=None, + ) + assert result is None + + def test_id_match_wins_over_name_match(self, db): + """If both a source-id match and a name match exist, the id match + should take priority — it's the more reliable signal.""" + _insert_artist( + db, artist_id="pk-id-match", name="Different Name", + deezer_id="dz-shared", server_source="plex", + ) + _insert_artist( + db, artist_id="pk-name-match", name="The Searched Artist", + server_source="plex", + ) + + result = find_library_artist_for_source( + db, "deezer", "dz-shared", artist_name="The Searched Artist", + active_server="plex", + ) + assert result == "pk-id-match" + + +# =========================================================================== +# Group C — Watchlist-config enrichment query schema contract +# =========================================================================== + +class TestWatchlistConfigEnrichmentQueries: + """The watchlist-config GET (web_server.py ~line 42196) joins + ``watchlist_artists`` against ``artists``. Both tables use different + column names for the same external IDs (``deezer_id`` on artists, + ``deezer_artist_id`` on watchlist_artists). The queries must use the + correct column per table.""" + + def test_artists_enrichment_query_executes(self, db): + """Run the exact SELECT from web_server.py verbatim — must not raise + ``no such column``.""" + with db._get_connection() as conn: + conn.execute( + """ + SELECT banner_url, summary, style, mood, label, genres + FROM artists + WHERE spotify_artist_id = ? + OR itunes_artist_id = ? + OR deezer_id = ? + OR discogs_id = ? + LIMIT 1 + """, + ("x", "x", "x", "x"), + ) + + def test_watchlist_join_query_executes(self, db): + """The paired query hits ``watchlist_artists`` where the Deezer column + is ``deezer_artist_id`` — confirm that shape works too.""" + with db._get_connection() as conn: + conn.execute( + """ + SELECT rr.album_name, rr.release_date, rr.album_cover_url, rr.track_count + FROM recent_releases rr + JOIN watchlist_artists wa ON rr.watchlist_artist_id = wa.id + WHERE wa.spotify_artist_id = ? + OR wa.itunes_artist_id = ? + OR wa.deezer_artist_id = ? + ORDER BY rr.release_date DESC + LIMIT 6 + """, + ("x", "x", "x"), + ) + + def test_artists_table_does_not_have_watchlist_column_names(self, db): + """Document the schema split that caused the original bug: these + suffixed names only exist on ``watchlist_artists``, never ``artists``.""" + with db._get_connection() as conn: + cursor = conn.execute("PRAGMA table_info(artists)") + artists_cols = {row[1] for row in cursor.fetchall()} + + assert "deezer_artist_id" not in artists_cols + assert "discogs_artist_id" not in artists_cols diff --git a/tests/test_metadata_service_discography.py b/tests/test_metadata_service_discography.py index 48767063..fc9e4bde 100644 --- a/tests/test_metadata_service_discography.py +++ b/tests/test_metadata_service_discography.py @@ -483,6 +483,61 @@ def test_get_artist_detail_discography_dedups_variant_releases(monkeypatch): assert result["albums"][0]["track_count"] == 10 +def test_get_artist_detail_discography_keeps_variants_when_dedup_disabled(monkeypatch): + """MetadataLookupOptions.dedup_variants=False is the source-only artist + detail code path — used so the standalone /artist-detail page can show + every release the source returns (matching the retired inline Artists + page behaviour).""" + monkeypatch.setattr( + metadata_service, + "get_artist_discography", + lambda artist_id, artist_name='', options=None: { + "albums": [ + { + "id": "album-standard", + "name": "Variant Album", + "album_type": "album", + "image_url": "https://img.example/standard.jpg", + "release_date": "2024-01-05", + "total_tracks": 10, + }, + { + "id": "album-swedish", + "name": "Variant Album (Swedish Edition)", + "album_type": "album", + "image_url": "https://img.example/swedish.jpg", + "release_date": "2024-01-05", + "total_tracks": 12, + }, + { + "id": "album-remaster", + "name": "Variant Album (2023 Abbey Road Remaster)", + "album_type": "album", + "image_url": "https://img.example/remaster.jpg", + "release_date": "2024-01-05", + "total_tracks": 10, + }, + ], + "singles": [], + "source": "deezer", + "source_priority": ["deezer", "spotify"], + }, + ) + + result = metadata_service.get_artist_detail_discography( + "artist-1", + "Artist One", + MetadataLookupOptions(dedup_variants=False), + ) + + assert result["success"] is True + assert [album["id"] for album in result["albums"]] == [ + "album-standard", + "album-swedish", + "album-remaster", + ] + + def test_get_artist_discography_keeps_provider_artist_ids(monkeypatch): class _SpotifyArtistIdClient(_FakeSourceClient): def get_artist_albums(self, artist_id, **kwargs): diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index 89a12e5a..a801f7d1 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -27,9 +27,11 @@ _ROOT = Path(__file__).resolve().parent.parent _STATIC = _ROOT / "webui" / "static" _INDEX = _ROOT / "webui" / "index.html" -# The 17 modules that replaced script.js (order matters for first/last checks) +# The 17 modules that replaced script.js + shared-helpers.js extracted from +# artists.js (order matters for first/last checks) SPLIT_MODULES = [ "core.js", + "shared-helpers.js", "media-player.js", "settings.js", "search.js", @@ -37,7 +39,6 @@ SPLIT_MODULES = [ "downloads.js", "wishlist-tools.js", "sync-services.js", - "artists.js", "api-monitor.js", "library.js", "beatport-ui.js", @@ -55,7 +56,7 @@ NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "work # In a plain + @@ -8053,7 +7894,6 @@ - diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 3784e28c..c900d824 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -1231,13 +1231,16 @@ function _renderWishlistNebula(albumTracks, singleTracks, artistImageMap, curren field.innerHTML = html; } -// Enhancement 8: navigate to artist detail from wishlist +// Enhancement 8: navigate to the Search page pre-filled with this artist's name function _navigateToArtistFromWishlist(artistName) { - // Try to find the artist in the library DB by searching - navigateToPage('artists'); + navigateToPage('search'); setTimeout(() => { - const searchInput = document.querySelector('.artist-search-input, #artist-search'); - if (searchInput) { searchInput.value = artistName; searchInput.dispatchEvent(new Event('input')); } + const searchInput = document.getElementById('enhanced-search-input'); + if (searchInput) { + searchInput.value = artistName; + searchInput.dispatchEvent(new Event('input')); + searchInput.focus(); + } }, 300); } @@ -2382,16 +2385,8 @@ async function openWatchlistArtistDetailView(artistId, artistName) { source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; } if (discogId) { - // Close detail overlay and navigate to Artists page closeWatchlistArtistDetailView(); - // Navigate to Artists page and load discography - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail( - { id: discogId, name: artistName, image_url: artist.image_url || '' }, - { source: source } - ); - }, 200); + navigateToArtistDetail(discogId, artistName, source); } }); diff --git a/webui/static/discover.js b/webui/static/discover.js index 8ddfb0a3..e056c772 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -739,16 +739,7 @@ async function checkRecommendedWatchlistStatuses(artists) { async function viewRecommendedArtistDiscography(artistId, artistName) { closeRecommendedArtistsModal(); - - const artist = { - id: artistId, - name: artistName - }; - - // Use same navigation pattern as hero slider - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist); + navigateToArtistDetail(artistId, artistName); } async function checkAllHeroWatchlistStatus() { @@ -830,25 +821,8 @@ async function viewDiscoverHeroDiscography() { return; } - // Create artist object matching the expected format - const artist = { - id: artistId, - name: artistName, - image_url: discoverHeroArtists[discoverHeroIndex]?.image_url || '', - genres: discoverHeroArtists[discoverHeroIndex]?.genres || [], - popularity: discoverHeroArtists[discoverHeroIndex]?.popularity || 0 - }; - console.log(`🎵 Navigating to artist detail for: ${artistName}`); - - // Navigate to Artists page - navigateToPage('artists'); - - // Small delay to let the page load - await new Promise(resolve => setTimeout(resolve, 100)); - - // Load the artist details - await selectArtistForDetail(artist); + navigateToArtistDetail(artistId, artistName); } function showDiscoverHeroEmpty() { @@ -4671,9 +4645,9 @@ function _renderYourArtistCard(artist) { const watchlistClass = artist.on_watchlist ? 'active' : ''; const hasId = artist.active_source_id && artist.active_source_id !== ''; - // Navigate to artist page (name click) + // Navigate to Artists page (name click) — source artist id, needs inline view const navAction = hasId - ? `event.stopPropagation(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artist.active_source_id)}', name:'${escapeForInlineJs(artist.artist_name)}', image_url:'${escapeForInlineJs(img)}'}), 200)` + ? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')` : ''; // Open info modal (card body click) — pass pool ID so we can look up all data @@ -4852,7 +4826,7 @@ async function openYourArtistInfoModal(poolId) { Explore - @@ -6752,7 +6726,7 @@ function _artMapSetupInteraction(canvas) {
Artist Info
-
+
💿 View Discography
@@ -7440,7 +7414,7 @@ async function openGenreDeepDive(genre) { // Always open on Artists page with discography — pass source for correct routing const imgUrl = _esc(a.image_url || ''); const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; + const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`; const srcClass = (a.source || '').toLowerCase(); return `
diff --git a/webui/static/docs.js b/webui/static/docs.js index c7d8e0dc..38220ffb 100644 --- a/webui/static/docs.js +++ b/webui/static/docs.js @@ -420,7 +420,7 @@ const DOCS_SECTIONS = [

How to: Set Up Auto-Downloads

Goal: Automatically download new releases from your favorite artists without manual intervention.

    -
  1. Add artists to your Watchlist — Search for artists on the Artists page and click the Watch button on each one
  2. +
  3. Add artists to your Watchlist — Find artists via the Search page (or click an artist anywhere in the app), then click the Watch button on the artist detail page
  4. Go to Automations — The built-in "Auto-Scan Watchlist" automation checks for new releases every 24 hours
  5. Enable "Auto-Process Wishlist" — This automation picks up new releases found by the scan and downloads them every 30 minutes
  6. Done! — New releases from watched artists are automatically found, queued, downloaded, tagged, and added to your library
  7. diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 3b18282f..f13bfb2e 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -634,16 +634,7 @@ function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, play if (!artistName) return; // Close the download modal if (playlistId) closeDownloadMissingModal(playlistId); - // Navigate to Artists page and load discography - navigateToPage('artists'); - setTimeout(() => { - // If we have an artist ID, use it directly - // If not, search by name — selectArtistForDetail handles both - selectArtistForDetail( - { id: artistId || artistName, name: artistName, image_url: imageUrl || '' }, - source ? { source: source } : undefined - ); - }, 200); + navigateToArtistDetail(artistId || artistName, artistName, source || null); } async function closeDownloadMissingModal(playlistId) { @@ -4267,335 +4258,7 @@ function updateModalSyncProgress(playlistId, progress) { } -// Download tracking state management - matching GUI functionality -let activeDownloads = {}; -let finishedDownloads = {}; -let downloadStatusInterval = null; -let isDownloadPollingActive = false; - -async function loadDownloadsData() { - // Downloads page loads search results dynamically - console.log('Downloads page loaded'); - - // Event listeners are already set up in initializeSearch() - don't duplicate them - const clearButton = document.querySelector('.controls-panel__clear-btn'); - const cancelAllButton = document.querySelector('.controls-panel__cancel-all-btn'); - - if (clearButton) { - clearButton.addEventListener('click', clearFinishedDownloads); - } - if (cancelAllButton) { - cancelAllButton.addEventListener('click', cancelAllDownloads); - } - - // Start sophisticated polling system (1-second interval like GUI) - startDownloadPolling(); - - // Initialize tab management - initializeDownloadTabs(); -} - -function startDownloadPolling() { - if (isDownloadPollingActive) return; - - console.log('Starting download status polling (1-second interval)'); - isDownloadPollingActive = true; - - // Initial call - updateDownloadQueues(); - - // Start 1-second polling (matching GUI's 1000ms timer) - downloadStatusInterval = setInterval(updateDownloadQueues, 1000); -} - -function stopDownloadPolling() { - if (downloadStatusInterval) { - clearInterval(downloadStatusInterval); - downloadStatusInterval = null; - } - isDownloadPollingActive = false; - console.log('Stopped download status polling'); -} - -async function updateDownloadQueues() { - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/downloads/status'); - const data = await response.json(); - - if (data.error) { - console.error("Error fetching download status:", data.error); - return; - } - - const newActive = {}; - const newFinished = {}; - - // Terminal states matching GUI logic - const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored']; - - // Process transfers exactly like GUI - data.transfers.forEach(item => { - const isTerminal = terminalStates.some(state => - item.state && item.state.includes(state) - ); - - if (isTerminal) { - newFinished[item.id] = item; - } else { - newActive[item.id] = item; - } - }); - - // Update global state - activeDownloads = newActive; - finishedDownloads = newFinished; - - // Render both queues - renderQueue('active-queue', activeDownloads, true); - renderQueue('finished-queue', finishedDownloads, false); - - // Update tab counts - updateTabCounts(); - - // Update stats in the side panel - updateDownloadStats(); - - } catch (error) { - // Only log errors occasionally to avoid console spam - if (Math.random() < 0.1) { - console.error("Failed to update download queues:", error); - } - } -} - -function renderQueue(containerId, downloads, isActiveQueue) { - const container = document.getElementById(containerId); - if (!container) return; - - const downloadIds = Object.keys(downloads); - - if (downloadIds.length === 0) { - container.innerHTML = `
    ${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}
    `; - return; - } - - let html = ''; - for (const id of downloadIds) { - const item = downloads[id]; - - // Extract display title from filename - let title = 'Unknown File'; - if (item.filename) { - // YouTube/Tidal filenames are encoded as "id||title" - if ((item.username === 'youtube' || item.username === 'tidal' || item.username === 'qobuz' || item.username === 'hifi') && item.filename.includes('||')) { - const parts = item.filename.split('||'); - title = parts[1] || parts[0]; // Use title part, fallback to id - } else { - // Regular Soulseek filename - extract last part of path - title = item.filename.split(/[\\/]/).pop(); - } - } - - const progress = item.percentComplete || 0; - const bytesTransferred = item.bytesTransferred || 0; - const totalBytes = item.size || 0; - const speed = item.averageSpeed || 0; - - // Format file size - const formatSize = (bytes) => { - if (!bytes) return 'Unknown size'; - const units = ['B', 'KB', 'MB', 'GB']; - let size = bytes; - let unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - return `${size.toFixed(1)} ${units[unitIndex]}`; - }; - - // Format speed - const formatSpeed = (bytesPerSecond) => { - if (!bytesPerSecond || bytesPerSecond <= 0) return ''; - return `${formatSize(bytesPerSecond)}/s`; - }; - - let actionButtonHTML = ''; - if (isActiveQueue) { - // Active items get progress bar and cancel button - actionButtonHTML = ` -
    -
    -
    -
    -
    - ${item.state} - ${progress.toFixed(1)}% - ${speed > 0 ? `• ${formatSpeed(speed)}` : ''} - ${totalBytes > 0 ? `• ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''} -
    -
    - - `; - } else { - // Finished items get status and open button - let statusClass = ''; - if (item.state.includes('Cancelled')) statusClass = 'status--cancelled'; - else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed'; - else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed'; - - actionButtonHTML = ` -
    - ${item.state} -
    - - `; - } - - // Enrich with metadata from backend context (artist, album, artwork) - const meta = item._meta || {}; - const sourceLabels = { youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', lidarr: 'Lidarr' }; - const sourceBadge = sourceLabels[item.username] || item.username; - - html += ` -
    -
    - ${meta.artwork_url - ? `` - : '
    '} -
    -
    -
    ${title}
    - ${meta.artist || meta.album ? ` -
    - ${meta.artist ? `${escapeHtml(meta.artist)}` : ''} - ${meta.artist && meta.album ? '·' : ''} - ${meta.album ? `${escapeHtml(meta.album)}` : ''} -
    - ` : ''} -
    - ${sourceBadge} - ${meta.quality ? `${escapeHtml(meta.quality)}` : ''} -
    -
    -
    - ${actionButtonHTML} -
    -
    - `; - } - container.innerHTML = html; -} - -function updateTabCounts() { - const activeCount = Object.keys(activeDownloads).length; - const finishedCount = Object.keys(finishedDownloads).length; - - const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]'); - const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]'); - - if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`; - if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`; -} - -function updateDownloadStats() { - const activeCount = Object.keys(activeDownloads).length; - const finishedCount = Object.keys(finishedDownloads).length; - - const activeLabel = document.getElementById('active-downloads-label'); - const finishedLabel = document.getElementById('finished-downloads-label'); - - if (activeLabel) activeLabel.textContent = `• Active Downloads: ${activeCount}`; - if (finishedLabel) finishedLabel.textContent = `• Finished Downloads: ${finishedCount}`; -} - -function initializeDownloadTabs() { - const tabButtons = document.querySelectorAll('.tab-btn'); - tabButtons.forEach(btn => { - btn.addEventListener('click', () => switchDownloadTab(btn)); - }); -} - -function switchDownloadTab(button) { - const targetTabId = button.getAttribute('data-tab'); - - // Update buttons - document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content panes - document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active')); - const targetQueue = document.getElementById(targetTabId); - if (targetQueue) targetQueue.classList.add('active'); -} - -async function cancelDownloadItem(downloadId, username) { - try { - const response = await fetch('/api/downloads/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ download_id: downloadId, username: username }) - }); - const result = await response.json(); - - if (result.success) { - showToast('Download cancelled', 'success'); - } else { - showToast(`Failed to cancel: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error cancelling download:', error); - showToast('Error sending cancel request', 'error'); - } -} - -async function clearFinishedDownloads() { - const finishedCount = Object.keys(finishedDownloads).length; - if (finishedCount === 0) { - showToast('No finished downloads to clear', 'error'); - return; - } - - try { - const response = await fetch('/api/downloads/clear-finished', { - method: 'POST' - }); - const result = await response.json(); - - if (result.success) { - showToast('Finished downloads cleared', 'success'); - } else { - showToast(`Failed to clear: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error clearing finished downloads:', error); - showToast('Error sending clear request', 'error'); - } -} - -async function cancelAllDownloads() { - if (!await showConfirmDialog({ title: 'Cancel All Downloads', message: 'Cancel ALL active downloads and clear the transfer list? This cannot be undone.', confirmText: 'Cancel All', destructive: true })) { - return; - } - - try { - const response = await fetch('/api/downloads/cancel-all', { - method: 'POST' - }); - const result = await response.json(); - - if (result.success) { - showToast('All downloads cancelled and cleared', 'success'); - } else { - showToast(`Failed to cancel: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error cancelling all downloads:', error); - showToast('Error cancelling downloads', 'error'); - } -} - -// REPLACE the old performDownloadsSearch function with this new one. +// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option). async function performDownloadsSearch() { const query = document.getElementById('downloads-search-input').value.trim(); if (!query) { @@ -5454,10 +5117,11 @@ const _gsState = { function _gsUpdateVisibility() { const bar = document.getElementById('gsearch-bar'); if (!bar) return; - // Hide on downloads page where enhanced search already exists - const onDownloads = typeof currentPage !== 'undefined' && currentPage === 'downloads'; - bar.style.display = onDownloads ? 'none' : ''; - if (onDownloads && _gsState.active) _gsDeactivate(); + // Hide on the Search page where the unified search already exists. Accept the + // legacy 'downloads' id for callers that predate the page rename. + const onSearchPage = typeof currentPage !== 'undefined' && (currentPage === 'search' || currentPage === 'downloads'); + bar.style.display = onSearchPage ? 'none' : ''; + if (onSearchPage && _gsState.active) _gsDeactivate(); } function _gsDeactivate() { @@ -5492,13 +5156,7 @@ async function _gsPerformSearch(query) { results.classList.add('visible'); try { - const res = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: _gsState.abortCtrl.signal, - }); - const data = await res.json(); + const data = await enhancedSearchFetch(query, { signal: _gsState.abortCtrl.signal }); _gsState.data = data; _gsState.activeSource = data.primary_source || 'spotify'; _gsState.sources = {}; @@ -5767,18 +5425,8 @@ function _gsSwitchSource(src) { function _gsClickArtist(id, name, isLibrary) { _gsDeactivate(); - if (isLibrary) { - // Same as enhanced search: navigateToArtistDetail - navigateToArtistDetail(id, name); - } else { - // Same as enhanced search: navigate to Artists page + selectArtistForDetail - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail({ id, name, image_url: '' }, { - source: _gsState.activeSource || '', - }); - }, 150); - } + const source = isLibrary ? null : (_gsState.activeSource || null); + navigateToArtistDetail(id, name, source); } async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { @@ -5872,8 +5520,8 @@ async function _gsClickTrack(artistName, trackName, albumName, trackId, imageUrl ); } catch (e) { console.error('Error opening track download:', e); - // Fallback: navigate to enhanced search - navigateToPage('downloads'); + // Fallback: navigate to the unified Search page + navigateToPage('search'); setTimeout(() => { const input = document.getElementById('enhanced-search-input'); if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); } diff --git a/webui/static/helper.js b/webui/static/helper.js index c75bf24b..62d17da5 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -917,17 +917,13 @@ const HELPER_CONTENT = { description: 'Search for music across your configured metadata sources and download from Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer.', docsId: 'search' }, - '#toggle-download-manager-btn': { - title: 'Toggle Download Manager', - description: 'Show or hide the download manager panel on the right side. The panel shows active downloads, finished downloads, and queue management.', - docsId: 'search-manager' - }, - '.search-mode-toggle': { - title: 'Search Mode', - description: 'Switch between Enhanced Search (categorized metadata results with album art) and Basic Search (raw Soulseek results with detailed file info and filters).', + '.search-source-picker-container': { + title: 'Search From', + description: 'Pick which metadata source to search. "All sources (Auto)" keeps the multi-source fan-out behavior; any specific source hits only that provider. "Soulseek (raw files)" switches to raw P2P file search with quality filters.', tips: [ - 'Enhanced: shows Artists, Albums, Singles, Tracks from your metadata source', - 'Basic: shows raw Soulseek P2P results with format, bitrate, size, uploader info' + 'Auto: searches your configured primary source plus library matches', + 'Spotify / Apple Music / Deezer / Discogs / Hydrabase / MusicBrainz: metadata-only results for that provider', + 'Soulseek: raw file results with format, bitrate, size, uploader — same as the old Basic Search' ], docsId: 'search-enhanced' }, @@ -955,7 +951,7 @@ const HELPER_CONTENT = { }, '#enh-spotify-artists-section': { title: 'Artists', - description: 'Artists from your metadata source matching the search. Click to view their full discography on the Artists page.', + description: 'Artists from your metadata source matching the search. Click one to open their discography.', }, '#enh-albums-section': { title: 'Albums', @@ -1010,33 +1006,7 @@ const HELPER_CONTENT = { docsId: 'search-basic' }, - // Download Manager Side Panel - '.downloads-side-panel': { - title: 'Download Manager', - description: 'Shows all active and completed downloads. Manage downloads, clear completed items, or cancel active transfers.', - docsId: 'search-manager' - }, - '.controls-panel': { - title: 'Download Controls', - description: 'Overview of active and finished download counts. Clear Completed removes finished items from the list. Clear Current cancels all active downloads.', - docsId: 'search-manager' - }, - '.controls-panel__clear-btn': { - title: 'Clear Completed', - description: 'Remove all finished downloads from the download manager list. Doesn\'t affect the downloaded files — they\'re already in your library.', - }, - '.controls-panel__cancel-all-btn': { - title: 'Clear Current', - description: 'Cancel all active downloads in progress. Files that were partially downloaded will be cleaned up.', - }, - '#active-queue': { - title: 'Download Queue', - description: 'Active downloads in progress. Each item shows track name, format, download speed, progress, and a cancel button.', - }, - '#finished-queue': { - title: 'Finished Downloads', - description: 'Completed downloads. Shows track name, format, file size, and final status (success or error).', - }, + // (Download Manager side-panel was retired — see the dedicated Downloads page) // ─── DISCOVER PAGE ──────────────────────────────────────────────── @@ -1290,139 +1260,30 @@ const HELPER_CONTENT = { description: 'Personalized mixes generated from your listening patterns. Each mix focuses on a different aspect of your taste — genre clusters, mood, or artist groups.', }, - // ─── ARTISTS PAGE ───────────────────────────────────────────────── + // ─── ARTIST DETAIL PAGE ─────────────────────────────────────────── + // (The standalone /artist-detail page is the unified destination for + // both library and metadata-source artists. The inline /artists page + // was retired in the unification project.) - // Search State - '.artists-search-state': { - title: 'Artist Search', - description: 'Search for any artist by name. Results show artist cards with images — click one to view their full discography.', - docsId: 'art-search' - }, - '#artists-search-input': { - title: 'Search Input', - description: 'Type an artist name to search across your active metadata source. Results appear as you type with a short debounce delay.', - docsId: 'art-search' - }, - '#artists-search-status': { - title: 'Search Status', - description: 'Shows the current search state — ready, searching, or result count.', - }, - - // Results State - '#artists-results-state': { - title: 'Search Results', - description: 'Artist cards matching your search. Click any artist to view their full discography with albums, singles, and EPs.', - docsId: 'art-search' - }, - '#artists-back-button': { - title: 'Back to Search', - description: 'Return to the initial search view to start a new artist search.', - }, - '#artists-cards-container': { - title: 'Artist Cards', - description: 'Each card shows an artist from your metadata source. Click to load their full discography.', - }, - - // Artist Detail — Hero - '#artists-hero-section': { - title: 'Artist Profile', - description: 'Rich artist profile with photo, name, genres, bio, and service links. Data comes from your metadata cache and library enrichment (Last.fm, Spotify, MusicBrainz, etc.).', - tips: [ - 'Service badges link to the artist on each platform', - 'Bio comes from Last.fm enrichment if the artist is in your library', - 'Listener and play count stats from Last.fm', - 'Genre pills combine metadata source genres with Last.fm tags' - ], - docsId: 'art-detail' - }, - '.artists-hero-name': { - title: 'Artist Name', - description: 'The artist\'s official name from your metadata source.', - }, - '.artists-hero-badges': { - title: 'Service Badges', - description: 'Links to this artist on external services. Click any badge to open the artist\'s page on that platform. Badges appear for services where this artist has been enriched.', - tips: [ - 'Spotify, MusicBrainz, Deezer, iTunes, Last.fm, Genius, Tidal, Qobuz', - 'Badges only appear when the artist has an ID for that service', - 'Data comes from library enrichment workers' - ] - }, - '.artists-hero-genres': { - title: 'Genres', - description: 'Genre tags for this artist. Combines genres from your metadata source with Last.fm tags for comprehensive coverage.', - }, - '.artists-hero-bio': { - title: 'Artist Bio', - description: 'Biography from Last.fm. Click "Read more" to expand. Only available for artists in your library that have been enriched by the Last.fm worker.', - }, - '.artists-hero-stats': { - title: 'Listener Stats', - description: 'Last.fm listener count and total play count. Shows how popular this artist is globally on Last.fm.', - }, - '#artist-detail-watchlist-btn': { - title: 'Watchlist', - description: 'Add or remove this artist from your Watchlist. Watched artists are scanned for new releases which get added to your Wishlist for download.', - docsId: 'art-watchlist' - }, - '#artist-detail-watchlist-settings-btn': { - title: 'Watchlist Settings', - description: 'Configure which release types to monitor for this artist — Albums, EPs, Singles, and content filters (live, remixes, acoustic, compilations).', - docsId: 'art-settings' - }, - - // Discography Tabs - '.artist-detail-tabs': { - title: 'Discography Tabs', - description: 'Switch between Albums and Singles & EPs. Each tab shows the artist\'s releases in that category.', - docsId: 'art-detail' - }, - '#albums-tab': { - title: 'Albums', - description: 'Full-length studio albums by this artist. Click any album card to open the download modal.', - }, - '#singles-tab': { - title: 'Singles & EPs', - description: 'Singles and extended plays by this artist. Includes single tracks, 2-3 track releases, and EPs.', - }, - - // Album Cards - '#album-cards-container': { - title: 'Album Grid', - description: 'Album cards with cover art. Click any album to open the download modal where you can select tracks, check library matches, and start downloading.', - tips: [ - 'Cover art fills the card with album name overlaid at the bottom', - 'Completion badges show ownership status (Complete, Partial, Missing)', - 'Cards check your library automatically after loading' - ], - docsId: 'art-detail' - }, - '#singles-cards-container': { - title: 'Singles Grid', - description: 'Single and EP cards. Same behavior as albums — click to open the download modal.', - }, '.album-card': { title: 'Release Card', description: 'An album, single, or EP from this artist. Click to open the download modal with track selection, library matching, and download controls.', tips: [ - 'Completion overlay shows how many tracks you own', - 'Green = complete, Yellow = partial, Red = missing', - '"Checking..." means library match is in progress' + 'Big-photo cover art fills the card with title and year overlaid at the bottom', + 'Completion badge (top-right) shows ownership status: ✓ Owned / N/M / Missing', + 'Library artists check ownership in the background — badge starts as "Checking…" then resolves' ] }, '.completion-overlay': { - title: 'Completion Status', - description: 'Shows how many tracks from this release are in your library. "Complete" means you have all tracks, "Partial" shows owned/total, "Missing" means none found.', + title: 'Completion Badge', + description: 'Top-right badge showing ownership state for library artists. ✓ Owned = full match, N/M = partial (owned/total tracks), Missing = no match. Source artists don\'t show this badge.', }, - - // Similar Artists - '#similar-artists-section': { + '#ad-similar-artists-section': { title: 'Similar Artists', - description: 'Artists with a similar sound, streamed in real-time from your metadata source. Click any card to view that artist\'s discography.', + description: 'Artists with a similar sound, fetched from MusicMap by name. Works for both library and source artists. Click any bubble to navigate to that artist\'s detail page.', tips: [ - 'Cards load progressively as the server finds matches', - 'Click a card to navigate to that artist\'s discography', - 'Images are lazy-loaded for performance' + 'Bubbles load progressively', + 'Click navigates to the standalone artist-detail page' ], docsId: 'art-detail' }, @@ -1430,6 +1291,11 @@ const HELPER_CONTENT = { title: 'Similar Artist', description: 'An artist similar to the one you\'re viewing. Click to load their discography and browse their releases.', }, + '.search-source-picker-container': { + title: 'Search Source', + description: 'Pick which metadata source the Search page queries. "All sources (Auto)" fans out across configured providers (the legacy default); pick a specific source to constrain the lookup. "Soulseek (raw files)" routes to the file-search pipeline that used to be the Basic mode.', + docsId: 'search' + }, // ─── AUTOMATIONS PAGE ───────────────────────────────────────────── @@ -2378,9 +2244,9 @@ function toggleHelperMode() { const PAGE_TOUR_MAP = { 'dashboard': 'dashboard', 'sync': 'sync-playlist', - 'downloads': 'first-download', + 'search': 'first-download', + 'downloads': 'first-download', // legacy id — the Search page used to be called 'downloads' 'discover': 'discover', - 'artists': 'artists-browse', 'automations': 'automations', 'library': 'library', 'stats': 'stats', @@ -2542,15 +2408,11 @@ const HELPER_TOURS = { description: 'Step-by-step guide to downloading your first album.', icon: '⬇️', steps: [ - // Search page layout (top-to-bottom, elements visible on load) - { page: 'downloads', selector: '.search-mode-toggle', title: 'Search Modes', description: 'Two search modes: Enhanced Search (default) shows categorized results from your metadata source. Classic Search queries your download source directly for raw file results.' }, - { page: 'downloads', selector: '.enhanced-search-input-wrapper', title: 'Search for Music', description: 'Type an artist or album name here. Results appear in categorized sections — Artists, Albums, Singles/EPs, and Tracks. Try searching for your favorite artist now!' }, - { page: 'downloads', selector: '#toggle-download-manager-btn', title: 'Download Manager', description: 'This button toggles the download manager panel on the right side. It shows active downloads with progress bars, queued items, and completed downloads with their file paths.' }, - - // What results look like (describe since they appear after searching) - { page: 'downloads', selector: '#enh-results-container', title: 'Search Results', description: 'After searching, results appear here organized by type: Artists at the top as cards, then Albums, Singles/EPs, and individual Tracks. "In Library" badges mark items you already own.' }, - { page: 'downloads', selector: '.search-mode-toggle', title: 'Downloading an Album', description: 'Click any album card to open the download modal. You\'ll see the tracklist, quality options, and a big "Download Album" button. Individual tracks have a play button to preview before downloading.' }, - { page: 'downloads', selector: '.enhanced-search-input-wrapper', title: 'That\'s It!', description: 'Search, click, download — it\'s that simple. Albums go to your configured download path, get tagged with metadata, and sync to your media server automatically. 🎉' }, + { page: 'search', selector: '.search-source-picker-container', title: 'Pick a Search Source', description: '"All sources (Auto)" fans out across every provider. Pick a specific one (Spotify, Apple Music, Deezer, etc.) to get results from just that catalog. "Soulseek (raw files)" is the old Basic mode — raw P2P file results with quality filters.' }, + { page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Search for Music', description: 'Type an artist or album name here. Results appear in categorized sections — Artists, Albums, Singles/EPs, and Tracks. Try searching for your favorite artist now!' }, + { page: 'search', selector: '#enh-results-container', title: 'Search Results', description: 'After searching, results appear organized by type: Artists at the top as cards, then Albums, Singles/EPs, and individual Tracks. "In Library" badges mark items you already own.' }, + { page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Downloading an Album', description: 'Click any album card to open the download modal. You\'ll see the tracklist, quality options, and a big "Download Album" button. Individual tracks have a play button to preview before downloading.' }, + { page: 'search', selector: '.enhanced-search-input-wrapper', title: 'That\'s It!', description: 'Search, click, download. Albums go to your configured download path, get tagged with metadata, and sync to your media server automatically. Active downloads live on the dedicated Downloads page.' }, ] }, 'sync-playlist': { @@ -2576,20 +2438,8 @@ const HELPER_TOURS = { { page: 'sync', selector: '.sync-sidebar', title: 'Sync Controls', description: 'The command center. Select playlists with checkboxes on the left, then click "Start Sync" here. Progress bars, match counts, and logs update in real-time. That\'s the sync flow! 🎉' }, ] }, - 'artists-browse': { - title: 'Browse Artists', - description: 'Search for artists and explore their discography.', - icon: '🎤', - steps: [ - // Artists list page (visible on load) - { page: 'artists', selector: '#artists-search-input', title: 'Search for an Artist', description: 'Type any artist name to search your metadata source. Results appear instantly as cards below. Click one to open their full profile and discography.' }, - - // Artist detail page (describe what they'll see after clicking) - { page: 'artists', selector: '#artists-search-input', title: 'Artist Profile', description: 'After clicking an artist, you\'ll see a rich hero section with their photo, bio, genres, listening stats from Last.fm, and links to external services like Spotify and MusicBrainz.' }, - { page: 'artists', selector: '#artists-search-input', title: 'Discography & Downloads', description: 'Below the hero, tabs show Albums and Singles/EPs. Click any release to open the download modal. The "Similar Artists" section at the bottom shows recommendations — click any to keep exploring.' }, - { page: 'artists', selector: '#artists-search-input', title: 'Try It Now!', description: 'Search for your favorite artist above to see their full profile. From there you can download albums, explore similar artists, and add them to your watchlist. 🎉' }, - ] - }, + // 'artists-browse' tour retired — the Artists sidebar entry was replaced by the + // unified Search page (see the first-download tour for the new flow). 'automations': { title: 'Build an Automation', description: 'Create automated workflows with triggers and actions.', @@ -3111,7 +2961,7 @@ const SETUP_STEPS = [ { id: 'download-source', label: 'Set Up Download Source', desc: 'Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer', icon: '⬇️', page: 'settings', settingsTab: 'downloads' }, { id: 'download-paths', label: 'Configure Download Paths', desc: 'Where music is saved and organized', icon: '📁', page: 'settings', settingsTab: 'downloads' }, { id: 'first-scan', label: 'Run First Library Scan', desc: 'Import your existing collection from media server', icon: '🔍', page: 'dashboard', selector: '#db-updater-card' }, - { id: 'first-download', label: 'Download Your First Track', desc: 'Search for and download something', icon: '🎶', page: 'downloads' }, + { id: 'first-download', label: 'Download Your First Track', desc: 'Search for and download something', icon: '🎶', page: 'search' }, { id: 'watchlist', label: 'Add an Artist to Watchlist', desc: 'Monitor for new releases automatically', icon: '👁️', page: 'library' }, { id: 'automation', label: 'Create an Automation', desc: 'Schedule tasks and build workflows', icon: '🤖', page: 'automations' }, ]; @@ -3218,14 +3068,8 @@ async function _checkSetupStatus() { results['first-download'] = Date.now(); _markSetupComplete('first-download'); } - // Also check the finished queue (if on downloads page) - if (!results['first-download']) { - const fq = document.querySelector('#finished-queue'); - if (fq && fq.querySelector('.download-item')) { - results['first-download'] = Date.now(); - _markSetupComplete('first-download'); - } - } + // (The legacy #finished-queue side-panel was retired; the dashboard stat card + // above is now the single source of truth for the first-download milestone.) } return results; @@ -3598,7 +3442,22 @@ function closeHelperSearch() { // WHAT'S NEW (Phase 6) // ═══════════════════════════════════════════════════════════════════════════ +// Entries tagged with `unreleased: true` are accumulating under a version label +// but won't display until the build version catches up. The Search/Artists +// unification project stays folded here at 2.40 until the whole thing ships. const WHATS_NEW = { + '2.40': [ + // --- Search & Artists unification (in progress, not yet released) --- + { date: 'Unreleased — Search & Artists unification', unreleased: true }, + { title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search', unreleased: true }, + { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search', unreleased: true }, + { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search', unreleased: true }, + { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search', unreleased: true }, + { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search', unreleased: true }, + { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true }, + { title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true }, + { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true }, + ], '2.39': [ // --- April 22, 2026 --- { date: 'April 22, 2026' }, @@ -3802,7 +3661,13 @@ function _getCurrentVersion() { } function _getLatestWhatsNewVersion() { - const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); + // Only surface entries whose version number is <= the current build. Entries + // sitting at higher versions are unreleased work-in-progress and shouldn't + // flag as "new" in the helper badge until the build catches up. + const buildVer = parseFloat(_getCurrentVersion()) || 2.39; + const versions = Object.keys(WHATS_NEW) + .filter(v => (parseFloat(v) || 0) <= buildVer) + .sort((a, b) => parseFloat(b) - parseFloat(a)); return versions[0] || '2.39'; } @@ -3891,8 +3756,11 @@ function _openFullChangelog() { } function _showOlderNotes() { - // Cycle to next older version in the what's new panel - const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); + // Cycle to next older version in the what's new panel (skip unreleased entries) + const buildVer = parseFloat(_getCurrentVersion()) || 2.39; + const versions = Object.keys(WHATS_NEW) + .filter(v => (parseFloat(v) || 0) <= buildVer) + .sort((a, b) => parseFloat(b) - parseFloat(a)); const panel = _helperPopover; if (!panel) return; const currentTitle = panel.querySelector('.helper-popover-title'); diff --git a/webui/static/init.js b/webui/static/init.js index 047e3675..a92a010f 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -187,9 +187,29 @@ function applyReduceEffects(enabled) { // ── Profile System ───────────────────────────────────────────── let currentProfile = null; +// Normalize legacy allowed_pages entries so the profile edit forms (which are +// built from the new pageLabels map containing 'search') don't drop access +// when saving a profile that was stored before the Search page rename. +// 'downloads' was the old Search id, 'artists' was the retired inline page. +function _normalizeLegacyAllowedPages(pages) { + if (!Array.isArray(pages)) return pages; + const mapped = pages.map(id => (id === 'downloads' || id === 'artists') ? 'search' : id); + return [...new Set(mapped)]; +} + +function _normalizeLegacyHomePage(homePage) { + if (homePage === 'downloads' || homePage === 'artists') return 'search'; + return homePage; +} + function getProfileHomePage() { if (!currentProfile) return 'dashboard'; - if (currentProfile.home_page) return currentProfile.home_page; + // Legacy profiles stored the Search page as either 'downloads' (the old id + // before the rename) or 'artists' (the retired inline Artists page). + // Both fold into the unified Search page now. + let home = currentProfile.home_page; + if (home === 'downloads' || home === 'artists') home = 'search'; + if (home) return home; return currentProfile.is_admin ? 'dashboard' : 'discover'; } @@ -197,16 +217,25 @@ function isPageAllowed(pageId) { if (!currentProfile) return true; if (currentProfile.id === 1) return true; if (pageId === 'help' || pageId === 'issues') return true; + if (pageId === 'settings') return currentProfile.is_admin; if (pageId === 'artist-detail') { - // artist-detail requires library access + // artist-detail is reachable from both Library and Search results, so + // either grant unlocks it. Without this, a Search-only profile couldn't + // open a source artist, and a legacy artists-only profile would hit the + // home-redirect recursion path below. const ap = currentProfile.allowed_pages; if (!ap) return true; - return ap.includes('library'); + return ap.includes('library') || ap.includes('search') + || ap.includes('downloads') || ap.includes('artists'); } - if (pageId === 'settings') return currentProfile.is_admin; const ap = currentProfile.allowed_pages; if (!ap) return true; // null = all pages - return ap.includes(pageId); + if (ap.includes(pageId)) return true; + // Legacy compat: 'downloads' (old Search id) and 'artists' (retired inline + // Artists page) both fold into the unified 'search' page. + if (pageId === 'search' && (ap.includes('downloads') || ap.includes('artists'))) return true; + if ((pageId === 'downloads' || pageId === 'artists') && ap.includes('search')) return true; + return false; } function canDownload() { @@ -1568,9 +1597,11 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar const isAdmin = currentProfile && currentProfile.is_admin; const isEditingAdmin = profileSettings.is_admin; const editColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6']; + // 'search' replaces the legacy 'downloads'/'artists' ids; legacy values are + // normalized on read below so saving a legacy profile upgrades it. const pageLabels = { - dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover', - artists: 'Artists', automations: 'Automations', library: 'Library', stats: 'Listening Stats', + dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover', + automations: 'Automations', library: 'Library', stats: 'Listening Stats', 'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs' }; @@ -1622,14 +1653,16 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar defaultOpt.value = ''; defaultOpt.textContent = isEditingAdmin ? 'Default (Dashboard)' : 'Default (Discover)'; homeSelect.appendChild(defaultOpt); - // Filter home page options to only allowed pages - const allowedSet = profileSettings.allowed_pages; + // Normalize legacy ids so the form shows the right options/selection for + // profiles saved before the Search page rename. + const allowedSet = _normalizeLegacyAllowedPages(profileSettings.allowed_pages); + const normalizedHome = _normalizeLegacyHomePage(profileSettings.home_page); Object.entries(pageLabels).forEach(([id, label]) => { if (allowedSet && !allowedSet.includes(id)) return; // Skip non-permitted const opt = document.createElement('option'); opt.value = id; opt.textContent = label; - if (id === profileSettings.home_page) opt.selected = true; + if (id === normalizedHome) opt.selected = true; homeSelect.appendChild(opt); }); form.appendChild(homeSelect); @@ -1754,9 +1787,11 @@ function showSelfEditForm() { const existing = document.getElementById('self-edit-form'); if (existing) existing.remove(); + // 'search' replaces the legacy 'downloads'/'artists' ids; legacy values are + // normalized on read below so saving a legacy profile upgrades it. const pageLabels = { - dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover', - artists: 'Artists', automations: 'Automations', library: 'Library', stats: 'Listening Stats', + dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover', + automations: 'Automations', library: 'Library', stats: 'Listening Stats', 'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs' }; @@ -1791,13 +1826,16 @@ function showSelfEditForm() { defaultOpt.value = ''; defaultOpt.textContent = 'Default (Discover)'; homeSelect.appendChild(defaultOpt); - const ap = currentProfile.allowed_pages; + // Normalize legacy ids so the form shows the right options/selection for + // profiles saved before the Search page rename. + const ap = _normalizeLegacyAllowedPages(currentProfile.allowed_pages); + const normalizedHome = _normalizeLegacyHomePage(currentProfile.home_page); Object.entries(pageLabels).forEach(([id, label]) => { if (ap && !ap.includes(id)) return; const opt = document.createElement('option'); opt.value = id; opt.textContent = label; - if (id === currentProfile.home_page) opt.selected = true; + if (id === normalizedHome) opt.selected = true; homeSelect.appendChild(opt); }); form.appendChild(homeSelect); @@ -1919,7 +1957,6 @@ function initApp() { initExpandedPlayer(); initializeSyncPage(); initializeWatchlist(); - initializeDownloadManagerToggle(); // Initialize WebSocket connection (falls back to HTTP polling if unavailable) @@ -1993,7 +2030,7 @@ function initializeNavigation() { } const _DEEPLINK_VALID_PAGES = new Set([ - 'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations', + 'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist', 'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer', 'hydrabase', 'tools' @@ -2005,7 +2042,7 @@ function _getPageFromPath() { const basePage = path.split('/')[0]; if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; // Context-dependent pages fall back to a sensible parent - if (basePage === 'artist-detail') return 'artists'; + if (basePage === 'artist-detail') return 'library'; if (basePage === 'playlist-explorer') return 'library'; return basePage; } @@ -2109,38 +2146,12 @@ function initializeWatchlist() { console.log('Watchlist system initialized'); } -function initializeDownloadManagerToggle() { - const toggleButton = document.getElementById('toggle-download-manager-btn'); - const downloadsContent = document.querySelector('.downloads-content'); - - if (!toggleButton || !downloadsContent) { - console.log('Download manager toggle not found on this page'); - return; - } - - // Load saved state from localStorage (hidden by default for more search space) - const isHidden = localStorage.getItem('downloadManagerHidden') !== 'false'; - if (isHidden) { - downloadsContent.classList.add('manager-hidden'); - } - - // Add click handler - toggleButton.addEventListener('click', () => { - const isCurrentlyHidden = downloadsContent.classList.contains('manager-hidden'); - - if (isCurrentlyHidden) { - downloadsContent.classList.remove('manager-hidden'); - localStorage.setItem('downloadManagerHidden', 'false'); - } else { - downloadsContent.classList.add('manager-hidden'); - localStorage.setItem('downloadManagerHidden', 'true'); - } - }); - - console.log('Download manager toggle initialized'); -} - function navigateToPage(pageId, options = {}) { + // Backwards-compat aliases — both legacy ids fold into the unified Search page. + // 'downloads' was the Search page's old id; 'artists' was the retired inline + // Artists page, now replaced by clicking artists from the unified Search. + if (pageId === 'downloads' || pageId === 'artists') pageId = 'search'; + if (pageId === currentPage) return; // Permission guard — redirect to home page if not allowed @@ -2152,14 +2163,14 @@ function navigateToPage(pageId, options = {}) { return; } - // Update navigation buttons (only if there's a nav button for this page) + // Update navigation buttons (only if there's a nav button for this page). + // Pages reachable from many surfaces (artist-detail, playlist-explorer) + // intentionally have no [data-page] match here — the sidebar shouldn't + // imply a section the user didn't actually navigate via. document.querySelectorAll('.nav-button').forEach(btn => { btn.classList.remove('active'); }); - - // Handle artist-detail page specially - it should highlight the 'library' nav button - const navPageId = pageId === 'artist-detail' ? 'library' : pageId; - const navButton = document.querySelector(`[data-page="${navPageId}"]`); + const navButton = document.querySelector(`[data-page="${pageId}"]`); if (navButton) { navButton.classList.add('active'); } @@ -2179,7 +2190,7 @@ function navigateToPage(pageId, options = {}) { } } - // Show/hide global search bar (hide on downloads page where enhanced search exists) + // Show/hide global search bar (hide on search page where the unified search lives) if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility(); // Show/hide discover download sidebar based on page @@ -2236,21 +2247,12 @@ async function loadPageData(pageId) { initializeSyncPage(); await loadSyncData(); break; - case 'downloads': + case 'search': initializeSearch(); initializeSearchModeToggle(); initializeFilters(); - await loadDownloadsData(); - break; - case 'artists': - // Only fully initialize if not already initialized - if (!artistsPageState.isInitialized) { - initializeArtistsPage(); - } else { - // Just restore state if already initialized - restoreArtistsPageState(); - } break; + // 'artists' page retired — aliased to 'search' at the top of navigateToPage case 'active-downloads': loadActiveDownloadsPage(); break; diff --git a/webui/static/library.js b/webui/static/library.js index 675187b8..b74a30ed 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -640,6 +640,11 @@ let artistDetailPageState = { currentArtistId: null, currentArtistName: null, currentArtistSource: null, + // Stack of origins captured by navigateToArtistDetail for the back button. + // Each entry is either {type:'page', pageId} or {type:'artist', id, name, source} + // so chained navigation (Search → A → similar B → similar C) walks back one + // step at a time instead of jumping straight to Search. + originStack: [], enhancedView: false, enhancedData: null, expandedAlbums: new Set(), @@ -655,8 +660,61 @@ let discographyFilterState = { ownership: 'all' // 'all', 'owned', 'missing' }; -function navigateToArtistDetail(artistId, artistName) { - console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId})`); +// Friendly labels for the dynamic "← Back to X" button on the artist-detail page. +// Page id (the value of currentPage) -> button label. +const _ARTIST_DETAIL_BACK_LABELS = { + library: 'Back to Library', + search: 'Back to Search', + discover: 'Back to Discover', + watchlist: 'Back to Watchlist', + wishlist: 'Back to Wishlist', + stats: 'Back to Stats', + 'playlist-explorer': 'Back to Explorer', + automations: 'Back to Automations', + dashboard: 'Back to Dashboard', + sync: 'Back to Sync', + 'active-downloads': 'Back to Downloads', +}; + +function navigateToArtistDetail(artistId, artistName, sourceOverride = null, options = {}) { + console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId}${sourceOverride ? `, source: ${sourceOverride}` : ''})`); + + // Capture the current location on the origin stack BEFORE navigateToPage + // flips currentPage. The back button walks this stack one step at a time, + // so a chain like Search → A → similar B → similar C steps back through + // C → B → A → Search instead of jumping straight home. `skipOriginPush` + // lets the back button re-enter a prior artist without re-pushing. + if (!options.skipOriginPush) { + // Fresh entry (from a non-artist page) starts a new chain; any stale + // entries from a prior artist-detail visit are dropped. + if (currentPage !== 'artist-detail') { + artistDetailPageState.originStack = []; + } + + let entry; + if (currentPage === 'artist-detail' && artistDetailPageState.currentArtistId) { + entry = { + type: 'artist', + id: artistDetailPageState.currentArtistId, + name: artistDetailPageState.currentArtistName, + source: artistDetailPageState.currentArtistSource, + }; + } else { + const pageId = (typeof currentPage === 'string' && currentPage && currentPage !== 'artist-detail') + ? currentPage : 'library'; + entry = { type: 'page', pageId }; + } + + // Avoid pushing a duplicate top entry on repeated clicks of the same target. + const top = artistDetailPageState.originStack[artistDetailPageState.originStack.length - 1]; + const isDuplicate = top && top.type === entry.type && ( + (entry.type === 'page' && top.pageId === entry.pageId) || + (entry.type === 'artist' && String(top.id) === String(entry.id)) + ); + if (!isDuplicate) { + artistDetailPageState.originStack.push(entry); + } + } // Abort any in-progress completion stream if (artistDetailPageState.completionController) { @@ -672,7 +730,7 @@ function navigateToArtistDetail(artistId, artistName) { // Store current artist info and reset enhanced view state artistDetailPageState.currentArtistId = artistId; artistDetailPageState.currentArtistName = artistName; - artistDetailPageState.currentArtistSource = null; + artistDetailPageState.currentArtistSource = sourceOverride || null; artistDetailPageState.enhancedData = null; artistDetailPageState.expandedAlbums = new Set(); artistDetailPageState.selectedTracks = new Set(); @@ -703,6 +761,9 @@ function navigateToArtistDetail(artistId, artistName) { // Navigate to artist detail page navigateToPage('artist-detail'); + // Update back-button label to reflect where the next pop will land. + _updateArtistDetailBackButtonLabel(); + // Initialize if needed and load data if (!artistDetailPageState.isInitialized) { initializeArtistDetailPage(); @@ -712,22 +773,57 @@ function navigateToArtistDetail(artistId, artistName) { loadArtistDetailData(artistId, artistName); } +function _updateArtistDetailBackButtonLabel() { + const backBtnLabel = document.querySelector('#artist-detail-back-btn span'); + if (!backBtnLabel) return; + const stack = artistDetailPageState.originStack || []; + const top = stack[stack.length - 1]; + if (!top) { + backBtnLabel.textContent = `← ${_ARTIST_DETAIL_BACK_LABELS.library}`; + } else if (top.type === 'artist') { + backBtnLabel.textContent = `← Back to ${top.name}`; + } else { + const friendly = _ARTIST_DETAIL_BACK_LABELS[top.pageId] || _ARTIST_DETAIL_BACK_LABELS.library; + backBtnLabel.textContent = `← ${friendly}`; + } +} + function initializeArtistDetailPage() { console.log("🔧 Initializing Artist Detail page..."); - // Initialize back button + // Initialize back button — pops the origin stack one step at a time so a + // chain like Search → A → B → C walks back through C → B → A → Search + // instead of jumping straight to the original entry page. const backBtn = document.getElementById("artist-detail-back-btn"); if (backBtn) { backBtn.addEventListener("click", () => { - console.log("🔙 Returning to Library page"); - // Abort any in-progress completion stream + // Abort any in-progress completion stream regardless of destination if (artistDetailPageState.completionController) { artistDetailPageState.completionController.abort(); artistDetailPageState.completionController = null; } - // Clear artist detail state so we go back to the list view + + const stack = artistDetailPageState.originStack || []; + if (stack.length > 0) { + const target = stack.pop(); + if (target.type === 'artist') { + // Re-enter a prior artist in the chain without re-pushing, + // so the stack keeps shrinking as the user steps back. + navigateToArtistDetail(target.id, target.name, target.source, { skipOriginPush: true }); + return; + } + // target.type === 'page' — fully exit the artist-detail chain + artistDetailPageState.currentArtistId = null; + artistDetailPageState.currentArtistName = null; + artistDetailPageState.originStack = []; + navigateToPage(target.pageId); + return; + } + + // No history — default to library artistDetailPageState.currentArtistId = null; artistDetailPageState.currentArtistName = null; + artistDetailPageState.originStack = []; navigateToPage('library'); }); } @@ -764,8 +860,21 @@ async function loadArtistDetailData(artistId, artistName) { // Don't update header until data loads to avoid showing stale data try { - // Call API to get artist discography data - const response = await fetch(`/api/artist-detail/${artistId}`); + // Call API to get artist discography data. If this artist came from a + // metadata source (not the library), pass source + name so the backend + // can synthesize a response from that source instead of 404ing on the + // local DB lookup. + const params = new URLSearchParams(); + if (artistDetailPageState.currentArtistSource) { + params.set('source', artistDetailPageState.currentArtistSource); + } + if (artistName) { + params.set('name', artistName); + } + const qs = params.toString(); + const response = await fetch( + `/api/artist-detail/${encodeURIComponent(artistId)}${qs ? '?' + qs : ''}` + ); if (!response.ok) { throw new Error(`Failed to load artist data: ${response.statusText}`); @@ -790,6 +899,18 @@ async function loadArtistDetailData(artistId, artistName) { // Populate the page with data (which updates the hero section and sets textContent) populateArtistDetailPage(data); + // Library upgrade — if the backend resolved this source-artist click to + // an existing library record (e.g. clicking a Deezer result for an + // artist already in your Plex), data.artist.id is the library PK. + // Update currentArtistId so subsequent library-only API calls (Enhanced + // view, completion checks, server sync) hit the right id. Also flip + // the body source flag from 'source' back to 'library' so the + // library-only UI re-shows. + if (data.artist && data.artist.id && String(data.artist.id) !== String(artistDetailPageState.currentArtistId)) { + console.log(`📚 Library upgrade: ${artistDetailPageState.currentArtistId} → ${data.artist.id}`); + artistDetailPageState.currentArtistId = data.artist.id; + } + // Keep the resolved metadata source for album-track lookups. artistDetailPageState.currentArtistSource = data.discography?.source || data.artist?.source || null; @@ -810,8 +931,11 @@ async function loadArtistDetailData(artistId, artistName) { } } - // Check if artist has tracks eligible for quality enhancement - checkArtistEnhanceEligibility(artistId); + // Check if artist has tracks eligible for quality enhancement. + // Use currentArtistId (not the closure arg) because the library-upgrade + // branch above may have rewritten it from the source ID to the library PK, + // and /api/library/artist//quality-analysis only works on library PKs. + checkArtistEnhanceEligibility(artistDetailPageState.currentArtistId); } catch (error) { console.error(`❌ Error loading artist detail data:`, error); @@ -936,6 +1060,11 @@ function populateArtistDetailPage(data) { console.log(`📀 EPs:`, discography.eps); console.log(`📀 Singles:`, discography.singles); + // Tag the body so CSS can hide library-only UI for source artists (e.g. + // the Enhanced view toggle, the Status filter, completion bars). Set + // BEFORE rendering so any layout-dependent code sees the right state. + document.body.dataset.artistSource = (artist && artist.server_source) ? 'library' : 'source'; + // Update hero section with image, name, and stats updateArtistHeroSection(artist, discography); @@ -948,10 +1077,25 @@ function populateArtistDetailPage(data) { // Populate discography sections populateDiscographySections(discography); - // Initialize library watchlist button if it exists (for library page) + // Initialize the watchlist button. Library artists that have been enriched + // get the canonical Spotify identity; source artists fall back to the id + // they came in with (Deezer/iTunes/Discogs/etc.). const libraryWatchlistBtn = document.getElementById('library-artist-watchlist-btn'); - if (libraryWatchlistBtn && data.spotify_artist && data.spotify_artist.spotify_artist_id) { - initializeLibraryWatchlistButton(data.spotify_artist.spotify_artist_id, data.spotify_artist.spotify_artist_name); + if (libraryWatchlistBtn) { + const watchlistId = (data.spotify_artist && data.spotify_artist.spotify_artist_id) + || artist.id; + const watchlistName = (data.spotify_artist && data.spotify_artist.spotify_artist_name) + || artist.name; + if (watchlistId && watchlistName) { + initializeLibraryWatchlistButton(watchlistId, watchlistName); + } + } + + // Load Similar Artists section (works for both library + source artists via + // MusicMap name lookup). Fire-and-forget — the function handles its own + // loading state and errors. + if (artist && artist.name && typeof loadSimilarArtists === 'function') { + loadSimilarArtists(artist.name); } } @@ -1038,6 +1182,17 @@ function updateArtistHeaderStats(albumCount, trackCount) { function updateArtistHeroSection(artist, discography) { console.log("🖼️ Updating artist hero section"); + // Blurred background image (inline-Artists hero treatment) — set whenever + // we have an image_url; falls back to clearing the bg if not. + const heroBg = document.getElementById("artist-detail-hero-bg"); + if (heroBg) { + if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") { + heroBg.style.backgroundImage = `url('${artist.image_url}')`; + } else { + heroBg.style.backgroundImage = ''; + } + } + // Update artist image with detailed debugging const imageElement = document.getElementById("artist-detail-image"); const fallbackElement = document.getElementById("artist-detail-image-fallback"); @@ -1296,22 +1451,34 @@ function populateReleaseSection(sectionType, releases) { grid.appendChild(card); }); + // Trigger lazy background-image loading on the new cards + if (typeof observeLazyBackgrounds === 'function') { + observeLazyBackgrounds(grid); + } + console.log(`📀 Populated ${sectionType} section: ${ownedCount} owned, ${missingCount} missing`); - console.log(`📀 Grid element:`, grid); - console.log(`📀 Grid children count:`, grid.children.length); } function createReleaseCard(release) { const card = document.createElement("div"); const isChecking = release.owned === null; - card.className = `release-card${isChecking ? " checking" : (release.owned ? "" : " missing")}`; + // .release-card keeps existing filter/state CSS + JS queries working; + // .album-card adopts the big-photo visual treatment from the retired + // inline Artists page (full-bleed image, gradient overlay, info pinned). + let stateCls = ''; + if (isChecking) stateCls = ' checking'; + else if (release.owned === false) stateCls = ' missing'; + card.className = `release-card album-card${stateCls}`; + const releaseId = release.id || ""; card.setAttribute("data-release-id", releaseId); + card.setAttribute("data-album-id", releaseId); + card.setAttribute("data-album-name", release.title || ""); + card.setAttribute("data-album-type", release.album_type || "album"); // Store mutable reference so stream updates propagate to click handler card._releaseData = release; // Tag card for content-type filtering - const titleLower = (release.title || '').toLowerCase(); const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^]]*\]/i; const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i; const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i; @@ -1322,10 +1489,90 @@ function createReleaseCard(release) { card.setAttribute("data-is-compilation", isCompilation ? "true" : "false"); card.setAttribute("data-is-featured", isFeatured ? "true" : "false"); - // Add MusicBrainz icon if available - let mbIcon = null; + // Background image — use data-bg-src for IntersectionObserver lazy loading + // (observeLazyBackgrounds is called by the caller after appending the grid). + const imageDiv = document.createElement("div"); + imageDiv.className = "album-card-image"; + if (release.image_url && release.image_url.trim() !== "") { + imageDiv.dataset.bgSrc = release.image_url; + } + card.appendChild(imageDiv); + + // Completion overlay — top-right floating badge. For library artists this + // shows the ownership state; for source artists (no library data) the + // overlay is omitted entirely so the card just shows the artwork + title. + const isSourceContext = (document.body.dataset.artistSource === 'source'); + if (!isSourceContext) { + const overlay = document.createElement("div"); + let overlayCls = ''; + let overlayLabel = ''; + + if (isChecking || release.track_completion === 'checking') { + overlayCls = 'checking'; + overlayLabel = 'Checking...'; + } else if (release.owned) { + const tc = release.track_completion; + if (tc && typeof tc === 'object') { + const ownedTracks = tc.owned_tracks || 0; + const totalTracks = tc.total_tracks || 0; + const missingTracks = tc.missing_tracks || 0; + if (missingTracks === 0) { + overlayCls = 'completed'; + overlayLabel = '✓ Owned'; + } else { + const pct = totalTracks > 0 ? Math.round((ownedTracks / totalTracks) * 100) : 0; + overlayCls = pct >= 75 ? 'nearly_complete' : 'partial'; + overlayLabel = `${ownedTracks}/${totalTracks}`; + } + } else { + const pct = release.track_completion || 100; + if (pct === 100) { + overlayCls = 'completed'; + overlayLabel = '✓ Owned'; + } else { + overlayCls = pct >= 75 ? 'nearly_complete' : 'partial'; + overlayLabel = `${pct}%`; + } + } + } else { + overlayCls = 'missing'; + overlayLabel = 'Missing'; + } + + overlay.className = `completion-overlay ${overlayCls}`; + overlay.innerHTML = `${overlayLabel}`; + card.appendChild(overlay); + } + + // Year — extract from release_date or fall back to year field + let yearText = ""; + if (release.release_date) { + try { + const yearMatch = release.release_date.match(/^(\d{4})/); + if (yearMatch) { + const ry = parseInt(yearMatch[1]); + if (ry && ry > 1900 && ry <= new Date().getFullYear() + 1) yearText = ry.toString(); + } else { + const ry = new Date(release.release_date).getFullYear(); + if (ry && !isNaN(ry) && ry > 1900 && ry <= new Date().getFullYear() + 1) yearText = ry.toString(); + } + } catch (e) { /* fall through */ } + } + if (!yearText && release.year) yearText = release.year.toString(); + + // Content (bottom-pinned over gradient) + const content = document.createElement("div"); + content.className = "album-card-content"; + const _esc = (s) => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + content.innerHTML = ` +
    ${_esc(release.title)}
    + ${yearText ? `
    ${_esc(yearText)}
    ` : ''} + `; + card.appendChild(content); + + // Add MusicBrainz icon LAST so it sits above the gradient overlay if (release.musicbrainz_release_id) { - mbIcon = document.createElement("div"); + const mbIcon = document.createElement("div"); mbIcon.className = "mb-card-icon"; mbIcon.title = "View on MusicBrainz"; mbIcon.innerHTML = ``; @@ -1333,149 +1580,6 @@ function createReleaseCard(release) { e.stopPropagation(); window.open(`https://musicbrainz.org/release/${release.musicbrainz_release_id}`, '_blank'); }; - } - - // Create image - const imageContainer = document.createElement("div"); - if (release.image_url && release.image_url.trim() !== "") { - const img = document.createElement("img"); - img.src = release.image_url; - img.alt = release.title; - img.className = "release-image"; - img.loading = 'lazy'; - img.onerror = () => { - imageContainer.innerHTML = `
    💿
    `; - }; - imageContainer.appendChild(img); - } else { - imageContainer.innerHTML = `
    💿
    `; - } - - // Create title - const title = document.createElement("h4"); - title.className = "release-title"; - title.textContent = release.title; - title.title = release.title; - - // Create year - extract from release_date (Spotify format) or fall back to year field - const year = document.createElement("div"); - year.className = "release-year"; - - let yearText = "Unknown Year"; - - // DEBUG: Log the release data to see what we're working with (remove this after testing) - // console.log(`🔍 DEBUG: Release "${release.title}" data:`, { - // title: release.title, - // owned: release.owned, - // year: release.year, - // release_date: release.release_date, - // track_completion: release.track_completion - // }); - - // First try to extract year from release_date (Spotify format: "YYYY-MM-DD") - if (release.release_date) { - try { - // Extract year directly from string to avoid timezone issues - const yearMatch = release.release_date.match(/^(\d{4})/); - if (yearMatch) { - const releaseYear = parseInt(yearMatch[1]); - if (releaseYear && !isNaN(releaseYear) && releaseYear > 1900 && releaseYear <= new Date().getFullYear() + 1) { - yearText = releaseYear.toString(); - } - } else { - // Fallback to Date parsing if format is different - const releaseYear = new Date(release.release_date).getFullYear(); - if (releaseYear && !isNaN(releaseYear) && releaseYear > 1900 && releaseYear <= new Date().getFullYear() + 1) { - yearText = releaseYear.toString(); - } - } - } catch (e) { - console.warn('Error parsing release_date:', release.release_date, e); - } - } - - // Fallback to direct year field if release_date parsing failed - if (yearText === "Unknown Year" && release.year) { - yearText = release.year.toString(); - } - - year.textContent = yearText; - - // Create completion info - const completion = document.createElement("div"); - completion.className = "release-completion"; - - const completionText = document.createElement("span"); - const completionBar = document.createElement("div"); - completionBar.className = "completion-bar"; - - const completionFill = document.createElement("div"); - completionFill.className = "completion-fill"; - - if (release.owned === null || release.track_completion === 'checking') { - // Checking state - ownership not yet resolved - completionText.textContent = "Checking..."; - completionText.className = "completion-text checking"; - completionFill.className += " checking"; - completionFill.style.width = "100%"; - } else if (release.owned) { - // Handle new detailed track completion object - if (release.track_completion && typeof release.track_completion === 'object') { - const completion = release.track_completion; - const percentage = completion.percentage || 100; - const ownedTracks = completion.owned_tracks || 0; - const totalTracks = completion.total_tracks || 0; - const missingTracks = completion.missing_tracks || 0; - - completionFill.style.width = `${percentage}%`; - - if (missingTracks === 0) { - completionText.textContent = `Complete (${ownedTracks})`; - completionText.className = "completion-text complete"; - completionFill.className += " complete"; - } else { - completionText.textContent = `${ownedTracks}/${totalTracks} tracks`; - completionText.className = "completion-text partial"; - completionFill.className += " partial"; - - // Add missing tracks indicator - completionText.title = `Missing ${missingTracks} track${missingTracks !== 1 ? 's' : ''}`; - } - } else { - // Fallback for legacy simple percentage - const percentage = release.track_completion || 100; - completionFill.style.width = `${percentage}%`; - - if (percentage === 100) { - completionText.textContent = "Complete"; - completionText.className = "completion-text complete"; - completionFill.className += " complete"; - } else { - completionText.textContent = `${percentage}%`; - completionText.className = "completion-text partial"; - completionFill.className += " partial"; - } - } - } else { - const totalTr = release.total_tracks || release.track_completion?.total_tracks || 0; - completionText.textContent = totalTr > 0 ? `Missing (${totalTr} tracks)` : "Not in library"; - completionText.className = "completion-text missing"; - completionFill.className += " missing"; - completionFill.style.width = "0%"; - } - - completionBar.appendChild(completionFill); - completion.appendChild(completionText); - completion.appendChild(completionBar); - - // Assemble card - card.appendChild(imageContainer); - card.appendChild(title); - card.appendChild(year); - card.appendChild(completion); - - // Add MusicBrainz icon LAST to ensure it's on top - if (release.musicbrainz_release_id && mbIcon) { // Check if mbIcon was created card.appendChild(mbIcon); } @@ -1710,37 +1814,35 @@ function updateLibraryReleaseCard(data) { } } - // Update completion text element in-place - const completionText = card.querySelector('.completion-text'); - if (completionText) { - completionText.classList.remove('checking', 'complete', 'partial', 'missing'); + // Update the floating completion-overlay badge (new big-photo card markup). + const overlay = card.querySelector('.completion-overlay'); + const overlayStatus = overlay && overlay.querySelector('.completion-status'); + if (overlay && overlayStatus) { + overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'error'); + let badgeCls = ''; + let badgeText = ''; + let badgeTitle = ''; if (isOwned) { if (effectiveMissing <= 0) { - completionText.textContent = `Complete (${data.owned_tracks})`; - completionText.className = 'completion-text complete'; + badgeCls = 'completed'; + badgeText = '✓ Owned'; + badgeTitle = `Complete (${data.owned_tracks} tracks)`; } else { - completionText.textContent = `${data.owned_tracks}/${data.expected_tracks} tracks`; - completionText.className = 'completion-text partial'; - completionText.title = `Missing ${effectiveMissing} track${effectiveMissing !== 1 ? 's' : ''}`; + const pct = data.completion_percentage || Math.round((data.owned_tracks / data.expected_tracks) * 100); + badgeCls = pct >= 75 ? 'nearly_complete' : 'partial'; + badgeText = `${data.owned_tracks}/${data.expected_tracks}`; + badgeTitle = `Missing ${effectiveMissing} track${effectiveMissing !== 1 ? 's' : ''}`; } } else { - completionText.textContent = 'Missing'; - completionText.className = 'completion-text missing'; - } - } - - // Update completion fill bar in-place - const completionFill = card.querySelector('.completion-fill'); - if (completionFill) { - completionFill.classList.remove('checking', 'complete', 'partial', 'missing'); - if (isOwned) { - const pct = isComplete ? 100 : (data.completion_percentage || 100); - completionFill.style.width = `${pct}%`; - completionFill.classList.add(effectiveMissing <= 0 ? 'complete' : 'partial'); - } else { - completionFill.style.width = '0%'; - completionFill.classList.add('missing'); + badgeCls = 'missing'; + badgeText = 'Missing'; + badgeTitle = data.expected_tracks > 0 + ? `${data.expected_tracks} track${data.expected_tracks !== 1 ? 's' : ''} not in library` + : 'Not in library'; } + overlay.classList.add(badgeCls); + overlayStatus.textContent = badgeText; + overlay.title = badgeTitle; } // Display format tags on owned releases @@ -2021,7 +2123,7 @@ async function openDiscographyModal() { } if (!artist || !discography) { - showToast('No discography found. Try searching this artist on the Artists page instead.', 'error'); + showToast('No discography found. Try searching this artist from the Search page instead.', 'error'); return; } @@ -2373,6 +2475,10 @@ function toggleEnhancedView(enabled) { if (i < dividers.length - 1) d.style.display = enabled ? 'none' : ''; }); + // Similar Artists is part of the standard view — hide it in Enhanced. + const similarSection = document.getElementById('ad-similar-artists-section'); + if (similarSection) similarSection.style.display = enabled ? 'none' : ''; + if (enabled) { standardSections.classList.add('hidden'); enhancedContainer.classList.remove('hidden'); diff --git a/webui/static/search.js b/webui/static/search.js index 2931d91b..0cae5787 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -1,6 +1,22 @@ // SEARCH FUNCTIONALITY // =============================== +// Shared enhanced-search fetch used by the Search page and the global widget. +// Pass source to restrict results to a single metadata provider; omit or pass +// null/'auto' to let the backend fan out across all configured sources. +async function enhancedSearchFetch(query, { source = null, signal = null } = {}) { + const body = { query }; + if (source && source !== 'auto') body.source = source; + const res = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: signal || undefined, + }); + if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`); + return res.json(); +} + function initializeSearch() { // --- FIX: Corrected the element IDs to match the HTML --- const searchInput = document.getElementById('downloads-search-input'); @@ -40,41 +56,38 @@ function initializeSearchModeToggle() { return; } - const toggleContainer = document.querySelector('.search-mode-toggle'); - const modeBtns = document.querySelectorAll('.search-mode-btn'); + const sourceSelect = document.getElementById('search-source-select'); const basicSection = document.getElementById('basic-search-section'); const enhancedSection = document.getElementById('enhanced-search-section'); - if (!toggleContainer || !modeBtns.length || !basicSection || !enhancedSection) { - console.warn('Search mode toggle elements not found'); + if (!sourceSelect || !basicSection || !enhancedSection) { + console.warn('Search source picker elements not found'); return; } searchModeToggleInitialized = true; - console.log('✅ Initializing search mode toggle (first time only)'); + console.log('✅ Initializing search source picker (first time only)'); - modeBtns.forEach(btn => { - btn.addEventListener('click', () => { - const mode = btn.dataset.mode; + // Current source selection — 'auto' (fan-out) by default. Soulseek routes + // to the raw-file basic search; everything else routes to enhanced. + let currentSearchSource = sourceSelect.value || 'auto'; - // Update button active states - modeBtns.forEach(b => b.classList.remove('active')); - btn.classList.add('active'); + const applySourceSelection = (value) => { + currentSearchSource = value; + if (value === 'soulseek') { + basicSection.classList.add('active'); + enhancedSection.classList.remove('active'); + } else { + basicSection.classList.remove('active'); + enhancedSection.classList.add('active'); + } + }; - // Update toggle slider position - toggleContainer.setAttribute('data-active', mode); + applySourceSelection(currentSearchSource); - // Toggle sections - if (mode === 'basic') { - basicSection.classList.add('active'); - enhancedSection.classList.remove('active'); - console.log('Switched to basic search mode'); - } else { - basicSection.classList.remove('active'); - enhancedSection.classList.add('active'); - console.log('Switched to enhanced search mode'); - } - }); + sourceSelect.addEventListener('change', (e) => { + applySourceSelection(e.target.value); + console.log('Search source →', currentSearchSource); }); // Initialize enhanced search @@ -191,7 +204,9 @@ function initializeSearchModeToggle() { const dropdown = document.getElementById('enhanced-dropdown'); if (dropdown && !dropdown.classList.contains('hidden')) { const isClickInside = e.target.closest('.enhanced-search-input-wrapper'); - if (!isClickInside) { + // Modal sits above the dropdown; closing it shouldn't dismiss results. + const isClickInModal = e.target.closest('.download-missing-modal'); + if (!isClickInside && !isClickInModal) { hideDropdown(); } } @@ -205,7 +220,14 @@ function initializeSearchModeToggle() { showDropdown(); const loadingText = document.getElementById('enhanced-loading-text'); if (loadingText) { - loadingText.textContent = `Searching across ${currentMusicSourceName} and your library...`; + const _sourceLabelMap = { + spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', + discogs: 'Discogs', hydrabase: 'Hydrabase', musicbrainz: 'MusicBrainz', + }; + const _sourceName = currentSearchSource && currentSearchSource !== 'auto' + ? (_sourceLabelMap[currentSearchSource] || currentSearchSource) + : currentMusicSourceName; + loadingText.textContent = `Searching across ${_sourceName} and your library...`; } loadingState.classList.remove('hidden'); emptyState.classList.add('hidden'); @@ -225,16 +247,10 @@ function initializeSearchModeToggle() { _enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query }; try { - const response = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: abortController.signal + const data = await enhancedSearchFetch(query, { + source: currentSearchSource, + signal: abortController.signal, }); - - if (!response.ok) throw new Error('Search failed'); - - const data = await response.json(); console.log('Enhanced results:', data); // Store multi-source state @@ -322,22 +338,11 @@ function initializeSearchModeToggle() { name: artist.name, meta: 'Artist', badge: sourceBadge, - onClick: async () => { + onClick: () => { const sourceOverride = _activeSearchSource; console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); hideDropdown(); - - // Navigate to Artists page - navigateToPage('artists'); - - // Small delay to let the page load - await new Promise(resolve => setTimeout(resolve, 100)); - - // Load the artist details with source context - await selectArtistForDetail(artist, { - source: sourceOverride, - plugin: artist.external_urls?.hydrabase_plugin, - }); + navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); } }) ); @@ -919,7 +924,6 @@ function initializeSearchModeToggle() { async function handleEnhancedSearchAlbumClick(album) { console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`); - hideDropdown(); showLoadingOverlay('Loading album...'); try { @@ -1042,7 +1046,6 @@ function initializeSearchModeToggle() { async function streamEnhancedSearchTrack(track) { console.log(`▶️ Stream enhanced search track: ${track.name} by ${track.artist}`); - hideDropdown(); showLoadingOverlay(`Searching for ${track.name}...`); try { @@ -1100,7 +1103,6 @@ function initializeSearchModeToggle() { async function handleEnhancedSearchTrackClick(track) { console.log(`🎵 Enhanced search track clicked: ${track.name} by ${track.artist}`); - hideDropdown(); showLoadingOverlay('Loading track...'); try { @@ -1316,9 +1318,9 @@ function initializeSearchModeToggle() { dropdown.classList.remove('hidden'); updateToggleButtonState(); } - // Hide the page header + search mode toggle to reclaim space - const header = document.querySelector('#downloads-page .downloads-header'); - const modeToggle = document.querySelector('.search-mode-toggle-container'); + // Hide the page header + source picker to reclaim space + const header = document.querySelector('#search-page .downloads-header'); + const modeToggle = document.querySelector('.search-source-picker-container'); const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container'); if (header) header.classList.add('enh-results-active-hide'); if (modeToggle) modeToggle.classList.add('enh-results-active-hide'); @@ -1332,8 +1334,8 @@ function initializeSearchModeToggle() { updateToggleButtonState(); } // Restore hidden elements - const header = document.querySelector('#downloads-page .downloads-header'); - const modeToggle = document.querySelector('.search-mode-toggle-container'); + const header = document.querySelector('#search-page .downloads-header'); + const modeToggle = document.querySelector('.search-source-picker-container'); const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container'); if (header) header.classList.remove('enh-results-active-hide'); if (modeToggle) modeToggle.classList.remove('enh-results-active-hide'); diff --git a/webui/static/artists.js b/webui/static/shared-helpers.js similarity index 66% rename from webui/static/artists.js rename to webui/static/shared-helpers.js index c4e4744e..312278fb 100644 --- a/webui/static/artists.js +++ b/webui/static/shared-helpers.js @@ -1,1090 +1,21 @@ -// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY +// SHARED HELPERS +// ============================================================================ +// General-purpose helpers extracted from artists.js. These functions are used +// across discover.js, api-monitor.js, library.js, enrichment.js, wishlist- +// tools.js and others — they have no conceptual home in the old Artists page +// file. Moved here so artists.js can be deleted once the inline Artists page +// is fully retired. +// +// Load order: this file must load AFTER core.js (uses artistsPageState, +// artistDownloadBubbles, searchDownloadBubbles, beatportDownloadBubbles +// globals declared there) and BEFORE any file that calls these functions. // ============================================================================ -/** - * Initialize the artists page when navigated to (only runs once) - */ -function initializeArtistsPage() { - console.log('🎵 Initializing Artists Page (first time)'); - // Get DOM elements - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - const searchStatus = document.getElementById('artists-search-status'); - const backButton = document.getElementById('artists-back-button'); - const detailBackButton = document.getElementById('artist-detail-back-button'); +// ---------------------------------------------------------------------------- +// Discography completion checking (for artist-detail pages, library page) +// ---------------------------------------------------------------------------- - // Set up event listeners (only need to do this once) - if (searchInput) { - searchInput.addEventListener('input', handleArtistsSearchInput); - searchInput.addEventListener('keypress', handleArtistsSearchKeypress); - } - - if (headerSearchInput) { - headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput); - headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress); - } - - if (backButton) { - backButton.addEventListener('click', () => showArtistsSearchState()); - } - - if (detailBackButton) { - detailBackButton.addEventListener('click', () => { - // If there are no search results (user navigated directly to artist), - // go straight to the main search view instead of showing an empty results page - if (!artistsPageState.searchResults || artistsPageState.searchResults.length === 0) { - showArtistsSearchState(); - } else { - showArtistsResultsState(); - } - }); - } - - // Initialize tabs (only need to do this once) - initializeArtistTabs(); - - // Mark as initialized - artistsPageState.isInitialized = true; - - // Restore previous state instead of always resetting to search - restoreArtistsPageState(); - console.log('✅ Artists Page initialized successfully (ready for navigation)'); -} - -/** - * Restore the artists page to its previous state - */ -function restoreArtistsPageState() { - console.log(`🔄 Restoring artists page state: ${artistsPageState.currentView}`); - - switch (artistsPageState.currentView) { - case 'results': - // Restore search results state - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - console.log(`📦 Restoring search results for: "${artistsPageState.searchQuery}"`); - - // Restore search input values - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - if (searchInput) searchInput.value = artistsPageState.searchQuery; - if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; - - // Display the cached results - displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); - } else { - // No valid results state, fall back to search - showArtistsSearchState(); - } - break; - - case 'detail': - // Restore artist detail state - if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) { - console.log(`🎤 Restoring artist detail for: ${artistsPageState.selectedArtist.name}`); - - // First restore search results if they exist - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - if (searchInput) searchInput.value = artistsPageState.searchQuery; - if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; - } - - // Show artist detail state - showArtistDetailState(); - - // Update artist info in header - updateArtistDetailHeader(artistsPageState.selectedArtist); - - // Display cached discography - if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) { - displayArtistDiscography(artistsPageState.artistDiscography); - // Restore cached completion data instead of re-scanning - restoreCachedCompletionData(artistsPageState.selectedArtist.id); - } - } else { - // No valid detail state, fall back to search or results - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); - } else { - showArtistsSearchState(); - } - } - break; - - default: - case 'search': - // Show search state (but preserve any existing search query) - if (artistsPageState.searchQuery) { - const searchInput = document.getElementById('artists-search-input'); - if (searchInput) searchInput.value = artistsPageState.searchQuery; - } - showArtistsSearchState(); - break; - } -} - -/** - * Handle search input with debouncing - */ -function handleArtistsSearchInput(event) { - const query = event.target.value.trim(); - updateArtistsSearchStatus('searching'); - - // Clear existing timeout - if (artistsSearchTimeout) { - clearTimeout(artistsSearchTimeout); - } - - // Cancel any active search - if (artistsSearchController) { - artistsSearchController.abort(); - } - - if (query === '') { - updateArtistsSearchStatus('default'); - return; - } - - // Set up new debounced search - artistsSearchTimeout = setTimeout(() => { - performArtistsSearch(query); - }, 1000); // 1 second debounce -} - -/** - * Handle header search input (already in results state) - */ -function handleArtistsHeaderSearchInput(event) { - const query = event.target.value.trim(); - - // Update main search input to match - const mainInput = document.getElementById('artists-search-input'); - if (mainInput) { - mainInput.value = query; - } - - // Trigger search with same debouncing logic - handleArtistsSearchInput(event); -} - -/** - * Handle Enter key press in search inputs - */ -function handleArtistsSearchKeypress(event) { - if (event.key === 'Enter') { - event.preventDefault(); - const query = event.target.value.trim(); - - if (query && query !== artistsPageState.searchQuery) { - // Clear timeout and search immediately - if (artistsSearchTimeout) { - clearTimeout(artistsSearchTimeout); - } - performArtistsSearch(query); - } - } -} - -/** - * Perform artist search with API call - */ -async function performArtistsSearch(query) { - console.log(`🔍 Searching for artists: "${query}"`); - - // Check cache first - if (artistsPageState.cache.searches[query]) { - console.log('📦 Using cached search results'); - displayArtistsResults(query, artistsPageState.cache.searches[query]); - return; - } - - // Update status - updateArtistsSearchStatus('searching'); - - // Show loading cards immediately if we're in results view - if (artistsPageState.currentView === 'results') { - showSearchLoadingCards(); - } - - try { - // Set up abort controller - artistsSearchController = new AbortController(); - - const response = await fetch('/api/match/search', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: query, - context: 'artist' - }), - signal: artistsSearchController.signal - }); - - if (!response.ok) { - throw new Error(`Search failed: ${response.status}`); - } - - const data = await response.json(); - console.log(`✅ Found ${data.results?.length || 0} artists`); - - // Transform the results to flatten the nested artist data - const transformedResults = (data.results || []).map(result => { - // Extract artist data from the nested structure - const artist = result.artist || result; - return { - id: artist.id, - name: artist.name, - image_url: artist.image_url, - genres: artist.genres, - popularity: artist.popularity, - confidence: result.confidence || 0 - }; - }); - - console.log('🔧 Transformed results:', transformedResults); - - // Cache the transformed results - artistsPageState.cache.searches[query] = transformedResults; - - // Display results - displayArtistsResults(query, transformedResults); - - } catch (error) { - if (error.name !== 'AbortError') { - console.error('❌ Artist search failed:', error); - - // Provide specific error messages based on the error type - let errorMessage = 'Search failed. Please try again.'; - if (error.message.includes('401') || error.message.includes('authentication')) { - errorMessage = 'Spotify not authenticated. Please check your API settings.'; - } else if (error.message.includes('network') || error.message.includes('fetch')) { - errorMessage = 'Network error. Please check your connection.'; - } else if (error.message.includes('timeout')) { - errorMessage = 'Search timed out. Please try again.'; - } - - updateArtistsSearchStatus('error', errorMessage); - } - } finally { - artistsSearchController = null; - } -} - -/** - * Display artist search results - */ -function displayArtistsResults(query, results) { - console.log(`📊 Displaying ${results.length} artist results`); - - // Update state - artistsPageState.searchQuery = query; - artistsPageState.searchResults = results; - artistsPageState.currentView = 'results'; - - // Update header search input if different - const headerInput = document.getElementById('artists-header-search-input'); - if (headerInput && headerInput.value !== query) { - headerInput.value = query; - } - - // Show results state - showArtistsResultsState(); - - // Populate results - const container = document.getElementById('artists-cards-container'); - if (!container) return; - - if (results.length === 0) { - container.innerHTML = ` -
    -
    🔍
    -
    No artists found
    -
    Try a different search term
    -
    - `; - return; - } - - // Create artist cards - container.innerHTML = results.map(result => createArtistCardHTML(result)).join(''); - observeLazyBackgrounds(container); - - // Add event listeners to cards - container.querySelectorAll('.artist-card').forEach((card, index) => { - card.addEventListener('click', () => selectArtistForDetail(results[index])); - - // Extract colors from artist image for dynamic glow - const artist = results[index]; - if (artist.image_url) { - extractImageColors(artist.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - }); - - // Update watchlist status for all cards - updateArtistCardWatchlistStatus(); - - // Lazy load missing artist images - console.log('🖼️ Starting lazy load for artist images on Artists page...'); - if (typeof lazyLoadArtistImages === 'function') { - lazyLoadArtistImages(container); - } else if (typeof window.lazyLoadArtistImages === 'function') { - window.lazyLoadArtistImages(container); - } else { - console.error('❌ lazyLoadArtistImages function not found!'); - } - - // Add mouse wheel horizontal scrolling - container.addEventListener('wheel', (event) => { - if (event.deltaY !== 0) { - event.preventDefault(); - container.scrollLeft += event.deltaY; - } - }); -} - -/** - * Lazy load artist images for cards that don't have images yet. - * Fetches images asynchronously so search results appear immediately. - */ -async function lazyLoadArtistImages(container) { - if (!container) { - console.error('❌ lazyLoadArtistImages: container is null'); - return; - } - - // Find all cards that need images - const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); - - if (cardsNeedingImages.length === 0) { - console.log('✅ All artist cards have images'); - return; - } - - console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`); - - // Load images in parallel (but with a small batch to avoid overwhelming the server) - const batchSize = 5; - const cards = Array.from(cardsNeedingImages); - - for (let i = 0; i < cards.length; i += batchSize) { - const batch = cards.slice(i, i + batchSize); - - await Promise.all(batch.map(async (card) => { - const artistId = card.dataset.artistId; - if (!artistId) { - console.warn('⚠️ Card missing artistId:', card); - return; - } - - try { - console.log(`🔄 Fetching image for artist ${artistId}...`); - const response = await fetch(`/api/artist/${artistId}/image`); - const data = await response.json(); - - console.log(`📥 Got response for ${artistId}:`, data); - - if (data.success && data.image_url) { - // Update the card's background image - // Handle both card types (suggestion-card and artist-card) - if (card.classList.contains('suggestion-card')) { - card.style.backgroundImage = `url(${data.image_url})`; - card.style.backgroundSize = 'cover'; - card.style.backgroundPosition = 'center'; - } else if (card.classList.contains('artist-card')) { - const bgElement = card.querySelector('.artist-card-background'); - if (bgElement) { - // Clear the gradient first, then set the image - bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; - } - } - - card.dataset.needsImage = 'false'; - console.log(`✅ Loaded image for artist ${artistId}`); - } - } catch (error) { - console.error(`❌ Failed to load image for artist ${artistId}:`, error); - } - })); - } - - console.log('✅ Finished lazy loading artist images'); -} - -// Make function globally accessible -window.lazyLoadArtistImages = lazyLoadArtistImages; - -/** - * Create HTML for an artist card - */ -function createArtistCardHTML(artist) { - const imageUrl = artist.image_url || ''; - const genres = artist.genres && artist.genres.length > 0 ? - artist.genres.slice(0, 3).join(', ') : 'Various genres'; - const popularity = artist.popularity || 0; - - // Use data-bg-src for lazy background loading via IntersectionObserver - const backgroundAttr = imageUrl ? - `data-bg-src="${imageUrl}"` : - `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);"`; - - // Format popularity as a percentage for better UX - const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown'; - - // Track if image needs to be lazy loaded - const needsImage = imageUrl ? 'false' : 'true'; - - // Check for MusicBrainz ID - let mbIconHTML = ''; - if (artist.musicbrainz_id) { - mbIconHTML = ` -
    - -
    - `; - } - - return ` -
    - ${mbIconHTML} -
    -
    -
    -
    ${escapeHtml(artist.name)}
    -
    ${escapeHtml(genres)}
    -
    - 🔥 - ${popularityText} -
    -
    -
    - - -
    -
    -
    -
    - `; -} - -/** - * Select an artist and show their discography - */ -async function selectArtistForDetail(artist, options = {}) { - console.log(`🎤 Selected artist: ${artist.name}`); - - // Cancel any ongoing completion check from previous artist - if (artistCompletionController) { - console.log('⏹️ Canceling previous artist completion check'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream from previous artist - if (similarArtistsController) { - console.log('⏹️ Canceling previous similar artists stream'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - // Update state - artistsPageState.selectedArtist = artist; - artistsPageState.currentView = 'detail'; - artistsPageState.sourceOverride = options.source || artist.source || null; - artistsPageState.pluginOverride = options.plugin || null; - - // Show detail state - showArtistDetailState(); - - // Update artist info in header - updateArtistDetailHeader(artist); - - // Load discography (pass artist name for cross-source fallback) - await loadArtistDiscography(artist.id, artist.name, artistsPageState.sourceOverride, options.plugin); -} - -/** - * Load artist's discography from Spotify or iTunes - * @param {string} artistId - Artist ID (Spotify or iTunes format) - * @param {string} [artistName] - Optional artist name for fallback searches - */ -async function loadArtistDiscography(artistId, artistName = null, sourceOverride = null, pluginOverride = null) { - console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName}, source: ${sourceOverride || 'auto'})`); - - // Use source-prefixed cache key to avoid ID collisions between sources - const cacheKey = sourceOverride ? `${sourceOverride}:${artistId}` : artistId; - - // Check cache first - if (artistsPageState.cache.discography[cacheKey]) { - console.log('📦 Using cached discography'); - const cachedDiscography = artistsPageState.cache.discography[cacheKey]; - if (artistsPageState.selectedArtist) { - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - source: cachedDiscography.source || sourceOverride || artistsPageState.selectedArtist.source || null, - }; - } - artistsPageState.sourceOverride = cachedDiscography.source || sourceOverride || artistsPageState.sourceOverride || null; - displayArtistDiscography(cachedDiscography); - - // Load similar artists in parallel (don't wait) — always uses primary source - loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { - console.error('❌ Error loading similar artists:', err); - }); - - // Still check completion status for cached data - await checkDiscographyCompletion(artistId, cachedDiscography); - return; - } - - try { - // Show loading states - showDiscographyLoading(); - - // Build URL with optional artist name and source override for fallback - let url = `/api/artist/${artistId}/discography`; - const params = new URLSearchParams(); - if (artistName) params.set('artist_name', artistName); - if (sourceOverride) params.set('source', sourceOverride); - if (pluginOverride) params.set('plugin', pluginOverride); - if (params.toString()) url += `?${params.toString()}`; - - // Call the real API endpoint - const response = await fetch(url); - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Spotify not authenticated. Please check your API settings.'); - } - throw new Error(`Failed to load discography: ${response.status}`); - } - - const data = await response.json(); - - if (data.error) { - throw new Error(data.error); - } - - const discography = { - albums: data.albums || [], - singles: data.singles || [], - source: data.source || sourceOverride || null, - }; - - // Keep the resolved metadata source on the selected artist so album clicks - // can pass it through to /api/album//tracks. - if (artistsPageState.selectedArtist) { - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - source: discography.source, - }; - } - artistsPageState.sourceOverride = discography.source || artistsPageState.sourceOverride || null; - - // Update selected artist with full details from backend (includes MusicBrainz ID) - if (data.artist) { - console.log('✨ Updating artist details with fresh data from backend'); - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - ...data.artist - }; - } - - // Merge artist_info enrichment from discography response - if (data.artist_info) { - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - artist_info: data.artist_info, - }; - } - - // Refresh header with all available data - updateArtistDetailHeader(artistsPageState.selectedArtist); - - console.log(`✅ Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`); - - // Cache the results (use source-prefixed key if source override active) - artistsPageState.cache.discography[cacheKey] = discography; - artistsPageState.artistDiscography = discography; - - // Display results - displayArtistDiscography(discography); - - // Load similar artists and check completion in parallel (don't wait) - loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { - console.error('❌ Error loading similar artists:', err); - }); - - // Check completion status for all albums and singles - await checkDiscographyCompletion(artistId, discography); - - } catch (error) { - console.error('❌ Failed to load discography:', error); - showDiscographyError(error.message); - } -} - -/** - * Display artist's discography in tabs - */ -function displayArtistDiscography(discography) { - console.log(`📀 Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`); - - // Show Download Discography button(s) if there are any releases - const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); - const _discogWrap = document.getElementById('discog-download-wrap'); - if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; - const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); - if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; - - // Populate albums - const albumsContainer = document.getElementById('album-cards-container'); - if (albumsContainer) { - if (discography.albums?.length > 0) { - albumsContainer.innerHTML = discography.albums.map(album => createAlbumCardHTML(album)).join(''); - observeLazyBackgrounds(albumsContainer); - - // Add dynamic glow effects and click handlers to album cards - albumsContainer.querySelectorAll('.album-card').forEach((card, index) => { - const album = discography.albums[index]; - if (album.image_url) { - extractImageColors(album.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - - // Add click handler for download missing tracks modal - card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums')); - card.style.cursor = 'pointer'; - }); - } else { - albumsContainer.innerHTML = ` -
    -
    💿
    -
    No albums found
    -
    - `; - } - } - - // Populate singles - const singlesContainer = document.getElementById('singles-cards-container'); - if (singlesContainer) { - if (discography.singles?.length > 0) { - singlesContainer.innerHTML = discography.singles.map(single => createAlbumCardHTML(single)).join(''); - observeLazyBackgrounds(singlesContainer); - - // Add dynamic glow effects and click handlers to singles cards - singlesContainer.querySelectorAll('.album-card').forEach((card, index) => { - const single = discography.singles[index]; - if (single.image_url) { - extractImageColors(single.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - - // Add click handler for download missing tracks modal - card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles')); - card.style.cursor = 'pointer'; - }); - } else { - singlesContainer.innerHTML = ` -
    -
    🎵
    -
    No singles or EPs found
    -
    - `; - } - } - - // Auto-switch to Singles tab if no albums but has singles - if ((!discography.albums || discography.albums.length === 0) && - discography.singles && discography.singles.length > 0) { - console.log('📀 No albums found, auto-switching to Singles & EPs tab'); - - // Switch to singles tab - const albumsTab = document.getElementById('albums-tab'); - const singlesTab = document.getElementById('singles-tab'); - const albumsContent = document.getElementById('albums-content'); - const singlesContent = document.getElementById('singles-content'); - - if (albumsTab && singlesTab && albumsContent && singlesContent) { - // Remove active from albums - albumsTab.classList.remove('active'); - albumsContent.classList.remove('active'); - - // Add active to singles - singlesTab.classList.add('active'); - singlesContent.classList.add('active'); - } - } -} - -/** - * Load similar artists from MusicMap - */ -async function loadSimilarArtists(artistName) { - if (!artistName) { - console.warn('⚠️ No artist name provided for similar artists'); - return; - } - - console.log(`🔍 Loading similar artists for: ${artistName}`); - - // Get DOM elements - const section = document.getElementById('similar-artists-section'); - const loadingEl = document.getElementById('similar-artists-loading'); - const errorEl = document.getElementById('similar-artists-error'); - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!section || !loadingEl || !errorEl || !container) { - console.warn('⚠️ Similar artists section elements not found'); - return; - } - - // Show loading state - loadingEl.classList.remove('hidden'); - errorEl.classList.add('hidden'); - container.innerHTML = ''; - section.style.display = 'block'; - - try { - // Create new abort controller for this similar artists stream - similarArtistsController = new AbortController(); - - // Use streaming endpoint for real-time bubble creation - const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; - console.log(`📡 Streaming from: ${url}`); - - const response = await fetch(url, { - signal: similarArtistsController.signal - }); - - if (!response.ok) { - throw new Error(`Failed to fetch similar artists: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let artistCount = 0; - - // Read the stream - while (true) { - const { done, value } = await reader.read(); - - if (done) { - console.log('✅ Stream complete'); - break; - } - - // Decode the chunk and add to buffer - buffer += decoder.decode(value, { stream: true }); - - // Process complete messages (separated by \n\n) - const messages = buffer.split('\n\n'); - buffer = messages.pop() || ''; // Keep incomplete message in buffer - - for (const message of messages) { - if (!message.trim() || !message.startsWith('data: ')) continue; - - try { - const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix - - if (jsonData.error) { - throw new Error(jsonData.error); - } - - if (jsonData.artist) { - // Hide loading on first artist - if (artistCount === 0) { - loadingEl.classList.add('hidden'); - } - - // Create and append bubble immediately - const bubble = createSimilarArtistBubble(jsonData.artist); - container.appendChild(bubble); - artistCount++; - - console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`); - } - - if (jsonData.complete) { - console.log(`🎉 Streaming complete: ${jsonData.total} artists`); - - if (artistCount === 0) { - loadingEl.classList.add('hidden'); - container.innerHTML = ` -
    -
    🎵
    -
    No similar artists found
    -
    - `; - } else { - // Lazy load images for similar artists that don't have them - lazyLoadSimilarArtistImages(container); - } - } - } catch (parseError) { - console.error('❌ Error parsing stream message:', parseError); - } - } - } - - // Clear the controller when done - similarArtistsController = null; - - } catch (error) { - // Don't show error if it was aborted (user navigated away) - if (error.name === 'AbortError') { - console.log('⏹️ Similar artists stream aborted (user navigated to new artist)'); - loadingEl.classList.add('hidden'); - return; - } - - console.error('❌ Error loading similar artists:', error); - - // Hide loading, show error - loadingEl.classList.add('hidden'); - errorEl.classList.remove('hidden'); - - // Also show error message in container - container.innerHTML = ` -
    -
    ⚠️
    -
    ${error.message}
    -
    - `; - } finally { - // Always clear the controller - similarArtistsController = null; - } -} - -/** - * Lazy load images for similar artist bubbles that don't have images - */ -async function lazyLoadSimilarArtistImages(container) { - if (!container) return; - - const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]'); - - if (bubblesNeedingImages.length === 0) { - console.log('✅ All similar artist bubbles have images'); - return; - } - - console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`); - - // Load images in parallel batches - const batchSize = 5; - const bubbles = Array.from(bubblesNeedingImages); - - for (let i = 0; i < bubbles.length; i += batchSize) { - const batch = bubbles.slice(i, i + batchSize); - - await Promise.all(batch.map(async (bubble) => { - const artistId = bubble.getAttribute('data-artist-id'); - const artistSource = bubble.getAttribute('data-artist-source') || ''; - const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; - if (!artistId) return; - - try { - const params = new URLSearchParams(); - if (artistSource) params.set('source', artistSource); - if (artistPlugin) params.set('plugin', artistPlugin); - - const imageUrl = params.toString() - ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` - : `/api/artist/${encodeURIComponent(artistId)}/image`; - - const response = await fetch(imageUrl); - const data = await response.json(); - - if (data.success && data.image_url) { - const imageContainer = bubble.querySelector('.similar-artist-bubble-image'); - if (imageContainer) { - const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist'; - imageContainer.innerHTML = `${artistName}`; - bubble.setAttribute('data-needs-image', 'false'); - console.log(`✅ Loaded image for similar artist ${artistId}`); - } - } - } catch (error) { - console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error); - } - })); - } - - console.log('✅ Finished lazy loading similar artist images'); -} - -/** - * Display similar artist bubble cards progressively (one at a time with delay) - */ -function displaySimilarArtistsProgressively(artists) { - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!container) { - console.warn('⚠️ Similar artists container not found'); - return; - } - - // Clear container - container.innerHTML = ''; - - // Add each bubble with a delay to simulate progressive loading - artists.forEach((artist, index) => { - setTimeout(() => { - const bubble = createSimilarArtistBubble(artist); - container.appendChild(bubble); - }, index * 100); // 100ms delay between each bubble - }); - - console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`); -} - -/** - * Display similar artist bubble cards (all at once - legacy) - */ -function displaySimilarArtists(artists) { - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!container) { - console.warn('⚠️ Similar artists container not found'); - return; - } - - // Clear container - container.innerHTML = ''; - - // Create bubble cards with staggered animation - artists.forEach((artist, index) => { - const bubble = createSimilarArtistBubble(artist); - - // Add staggered animation delay (50ms per bubble) - bubble.style.animationDelay = `${index * 0.05}s`; - - container.appendChild(bubble); - }); - - console.log(`✅ Displayed ${artists.length} similar artist bubbles`); -} - -/** - * Create a similar artist bubble card element - */ -function createSimilarArtistBubble(artist) { - // Create bubble container - const bubble = document.createElement('div'); - bubble.className = 'similar-artist-bubble'; - bubble.setAttribute('data-artist-id', artist.id); - bubble.setAttribute('data-artist-source', artist.source || ''); - if (artist.plugin) { - bubble.setAttribute('data-artist-plugin', artist.plugin); - } - - // Track if image needs lazy loading - const hasImage = artist.image_url && artist.image_url.trim() !== ''; - bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true'); - - // Create image container - const imageContainer = document.createElement('div'); - imageContainer.className = 'similar-artist-bubble-image'; - - if (hasImage) { - const img = document.createElement('img'); - img.src = artist.image_url; - img.alt = artist.name; - - // Handle image load error - img.onerror = () => { - console.log(`Failed to load image for ${artist.name}`); - imageContainer.innerHTML = `
    🎵
    `; - bubble.setAttribute('data-needs-image', 'true'); - }; - - imageContainer.appendChild(img); - } else { - // No image - show fallback (will be lazy loaded) - imageContainer.innerHTML = `
    🎵
    `; - } - - // Create name element - const name = document.createElement('div'); - name.className = 'similar-artist-bubble-name'; - name.textContent = artist.name; - name.title = artist.name; // Tooltip for full name - - // Optional: Create genres element (hidden by default in CSS) - const genres = document.createElement('div'); - genres.className = 'similar-artist-bubble-genres'; - if (artist.genres && artist.genres.length > 0) { - genres.textContent = artist.genres.slice(0, 2).join(', '); - } - - // Assemble bubble - bubble.appendChild(imageContainer); - bubble.appendChild(name); - if (artist.genres && artist.genres.length > 0) { - bubble.appendChild(genres); - } - - // Add click handler to navigate to artist detail page - bubble.addEventListener('click', () => { - console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`); - // Navigate to this artist's detail page (same as clicking from search results) - selectArtistForDetail( - artist, - artist.source ? { source: artist.source, plugin: artist.plugin } : {} - ); - }); - - return bubble; -} - -/** - * Restore cached completion data without re-scanning the database - */ -function restoreCachedCompletionData(artistId) { - console.log(`📦 Restoring cached completion data for artist: ${artistId}`); - - const cachedData = artistsPageState.cache.completionData[artistId]; - if (!cachedData) { - console.log('⚠️ No cached completion data found, skipping restoration'); - return; - } - - // Restore album completion overlays - if (cachedData.albums) { - cachedData.albums.forEach(albumCompletion => { - updateAlbumCompletionOverlay(albumCompletion, 'albums'); - }); - console.log(`✅ Restored ${cachedData.albums.length} album completion overlays`); - } - - // Restore singles completion overlays - if (cachedData.singles) { - cachedData.singles.forEach(singleCompletion => { - updateAlbumCompletionOverlay(singleCompletion, 'singles'); - }); - console.log(`✅ Restored ${cachedData.singles.length} single completion overlays`); - } -} - -/** - * Check completion status for entire discography with streaming updates - */ async function checkDiscographyCompletion(artistId, discography) { console.log(`🔍 Starting streaming completion check for artist: ${artistId}`); @@ -1388,812 +319,13 @@ function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { } } -/** - * Show error state on all completion overlays - */ -function showCompletionError() { - const allOverlays = document.querySelectorAll('.completion-overlay.checking'); - allOverlays.forEach(overlay => { - overlay.classList.remove('checking'); - overlay.classList.add('error'); - overlay.innerHTML = 'Error'; - overlay.title = 'Failed to check completion status'; - }); -} -/** - * Create HTML for an album/single card - */ -function createAlbumCardHTML(album) { - const imageUrl = album.image_url || ''; - const year = album.release_date ? new Date(album.release_date).getFullYear() : ''; - const type = album.album_type === 'album' ? 'Album' : - album.album_type === 'single' ? 'Single' : 'EP'; +// ---------------------------------------------------------------------------- +// Download bubble infrastructure, image colour/glow helpers, HTML escape, +// service-status polling, enrichment-card rendering. All originally defined +// in artists.js but used broadly across the app. +// ---------------------------------------------------------------------------- - // Use data-bg-src for lazy background loading via IntersectionObserver - const backgroundAttr = imageUrl ? - `data-bg-src="${imageUrl}"` : - `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);"`; - - return ` -
    -
    -
    - Checking... -
    -
    -
    ${escapeHtml(album.name)}
    -
    ${year || 'Unknown'}
    -
    ${type}
    -
    -
    - `; -} - -/** - * Initialize artist detail tabs - */ -function initializeArtistTabs() { - const tabButtons = document.querySelectorAll('.artist-tab'); - const tabContents = document.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - const tabName = button.getAttribute('data-tab'); - - // Update button states - tabButtons.forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content states - tabContents.forEach(content => { - content.classList.remove('active'); - if (content.id === `${tabName}-content`) { - content.classList.add('active'); - } - }); - - console.log(`🔄 Switched to ${tabName} tab`); - }); - }); -} - -/** - * State management functions - */ -function showArtistsSearchState() { - console.log('🔄 Showing search state'); - - // Cancel any ongoing completion check when navigating back to search - if (artistCompletionController) { - console.log('⏹️ Canceling completion check (navigating back to search)'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream when navigating back to search - if (similarArtistsController) { - console.log('⏹️ Canceling similar artists stream (navigating back to search)'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.remove('hidden', 'fade-out'); - } - if (resultsState) { - resultsState.classList.add('hidden'); - resultsState.classList.remove('show'); - } - if (detailState) { - detailState.classList.add('hidden'); - detailState.classList.remove('show'); - } - - artistsPageState.currentView = 'search'; - updateArtistsSearchStatus('default'); - - // Show artist downloads section if there are active downloads - showArtistDownloadsSection(); -} - -function showArtistsResultsState() { - console.log('🔄 Showing results state'); - - // Cancel any ongoing completion check when navigating back - if (artistCompletionController) { - console.log('⏹️ Canceling completion check (navigating back to results)'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream when navigating back - if (similarArtistsController) { - console.log('⏹️ Canceling similar artists stream (navigating back to results)'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - // Clear artist-specific data when navigating back to results - // This ensures that selecting the same artist again will trigger a fresh scan - if (artistsPageState.selectedArtist) { - const artistId = artistsPageState.selectedArtist.id; - console.log(`🗑️ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`); - - // Clear artist-specific cache data - delete artistsPageState.cache.completionData[artistId]; - delete artistsPageState.cache.discography[artistId]; - - // Clear artist state - artistsPageState.selectedArtist = null; - artistsPageState.artistDiscography = { albums: [], singles: [] }; - } - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.add('fade-out'); - setTimeout(() => searchState.classList.add('hidden'), 200); - } - if (resultsState) { - resultsState.classList.remove('hidden'); - setTimeout(() => resultsState.classList.add('show'), 50); - } - if (detailState) { - detailState.classList.add('hidden'); - detailState.classList.remove('show'); - } - - artistsPageState.currentView = 'results'; -} - -function showArtistDetailState() { - console.log('🔄 Showing detail state'); - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.add('hidden', 'fade-out'); - } - if (resultsState) { - resultsState.classList.add('hidden'); - resultsState.classList.remove('show'); - } - if (detailState) { - detailState.classList.remove('hidden'); - setTimeout(() => detailState.classList.add('show'), 50); - } - - artistsPageState.currentView = 'detail'; -} - -/** - * Update search status text and styling - */ -function updateArtistsSearchStatus(status, message = null) { - const statusElement = document.getElementById('artists-search-status'); - if (!statusElement) return; - - // Clear all status classes - statusElement.classList.remove('searching', 'error'); - - switch (status) { - case 'default': - statusElement.textContent = 'Start typing to search for artists'; - break; - case 'searching': - statusElement.classList.add('searching'); - statusElement.textContent = 'Searching for artists...'; - break; - case 'error': - statusElement.classList.add('error'); - statusElement.innerHTML = ` -
    ${message || 'Search failed. Please try again.'}
    - - `; - break; - } -} - -/** - * Retry the last search query - */ -function retryLastSearch() { - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - // Get the last search query from either input - const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery; - - if (query) { - console.log(`🔄 Retrying search for: "${query}"`); - performArtistsSearch(query); - } -} - -/** - * Update artist detail header with artist info - */ -function updateArtistDetailHeader(artist) { - const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - const info = artist.artist_info || {}; - const imageUrl = artist.image_url || info.image_url || ''; - - // Background blur - const heroBg = document.getElementById('artists-hero-bg'); - if (heroBg) { - heroBg.style.backgroundImage = imageUrl ? `url('${imageUrl}')` : 'none'; - } - - // Artist image - const heroImage = document.getElementById('artists-hero-image'); - if (heroImage) { - if (imageUrl) { - heroImage.style.backgroundImage = `url('${imageUrl}')`; - heroImage.innerHTML = ''; - } else { - heroImage.style.backgroundImage = 'none'; - heroImage.innerHTML = '🎤'; - // Lazy load - fetch(`/api/artist/${artist.id}/image`) - .then(r => r.json()) - .then(d => { - if (d.success && d.image_url) { - heroImage.style.backgroundImage = `url('${d.image_url}')`; - heroImage.innerHTML = ''; - if (heroBg) heroBg.style.backgroundImage = `url('${d.image_url}')`; - artist.image_url = d.image_url; - } - }).catch(() => { }); - } - } - - // Name - const heroName = document.getElementById('artists-hero-name'); - if (heroName) heroName.textContent = artist.name || 'Unknown Artist'; - - // Badges (service links — real logos matching library page) - const badgesEl = document.getElementById('artists-hero-badges'); - if (badgesEl) { - const _hb = (logo, fallback, title, url) => { - const inner = logo - ? `${fallback}` - : `${fallback}`; - if (url) return `${inner}`; - return `
    ${inner}
    `; - }; - const badges = []; - if (info.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${info.spotify_artist_id}`)); - if (info.musicbrainz_id || artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${info.musicbrainz_id || artist.musicbrainz_id}`)); - if (info.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${info.deezer_id}`)); - if (info.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${info.itunes_artist_id}`)); - if (info.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', info.lastfm_url)); - if (info.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', info.genius_url)); - if (info.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${info.tidal_id}`)); - if (info.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${info.qobuz_id}`)); - if (info.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${info.discogs_id}`)); - badgesEl.innerHTML = badges.join(''); - } - - // Genres (pill tags — merge with Last.fm tags, deduplicated) - const genresEl = document.getElementById('artists-hero-genres'); - if (genresEl) { - let genres = info.genres || artist.genres || []; - // Merge Last.fm tags - const lfmTags = info.lastfm_tags || []; - if (Array.isArray(lfmTags) && lfmTags.length > 0) { - const existing = new Set(genres.map(g => g.toLowerCase())); - const newTags = lfmTags.filter(t => !existing.has(t.toLowerCase())); - genres = [...genres, ...newTags]; - } - if (genres.length > 0) { - genresEl.innerHTML = genres.slice(0, 8).map(g => - `${_esc(g)}` - ).join(''); - } else { - genresEl.innerHTML = ''; - } - } - - // Bio (Last.fm bio or summary fallback — matching library page pattern) - const bioEl = document.getElementById('artists-hero-bio'); - if (bioEl) { - const bio = info.lastfm_bio || info.bio || ''; - if (bio) { - // Strip HTML tags and "Read more on Last.fm" links - let cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); - if (cleanBio) { - bioEl.innerHTML = `${_esc(cleanBio)} - Read more`; - bioEl.style.display = ''; - } else { - bioEl.style.display = 'none'; - } - } else { - bioEl.style.display = 'none'; - } - } - - // Stats (Last.fm listeners + playcount, with followers fallback) - const statsEl = document.getElementById('artists-hero-stats'); - if (statsEl) { - const _fmtNum = (n) => { - if (!n || n <= 0) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toLocaleString(); - }; - let stats = ''; - if (info.lastfm_listeners) { - stats += `${_fmtNum(info.lastfm_listeners)} listeners`; - } - if (info.lastfm_playcount) { - stats += `${_fmtNum(info.lastfm_playcount)} plays`; - } - if (!stats && info.followers) { - stats += `${_fmtNum(info.followers)} followers`; - } - statsEl.innerHTML = stats; - } - - // Also update old hidden elements for any JS that references them - const oldImage = document.getElementById('search-artist-detail-image'); - if (oldImage && imageUrl) oldImage.style.backgroundImage = `url('${imageUrl}')`; - const oldName = document.getElementById('search-artist-detail-name'); - if (oldName) oldName.textContent = artist.name; - - // Initialize watchlist button - initializeArtistDetailWatchlistButton(artist); -} - -/** - * Initialize watchlist button for artist detail page - */ -async function initializeArtistDetailWatchlistButton(artist) { - const button = document.getElementById('artist-detail-watchlist-btn'); - if (!button) return; - - console.log(`🔧 Initializing watchlist button for artist: ${artist.name} (${artist.id})`); - - // Store artist info on the button for settings gear access - button.dataset.artistId = artist.id; - button.dataset.artistName = artist.name; - - // Reset button state completely - button.disabled = false; - button.classList.remove('watching'); - button.style.background = ''; - button.style.cursor = ''; - - // Remove any existing click handlers to prevent duplicates - button.onclick = null; - - // Set up new click handler - button.onclick = (event) => toggleArtistDetailWatchlist(event, artist.id, artist.name); - - // Check and update current status - await updateArtistDetailWatchlistButton(artist.id, artist.name); -} - -/** - * Toggle watchlist status for artist detail page - */ -async function toggleArtistDetailWatchlist(event, artistId, artistName) { - event.preventDefault(); - - const button = document.getElementById('artist-detail-watchlist-btn'); - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - // Show loading state - const originalText = text.textContent; - text.textContent = 'Loading...'; - button.disabled = true; - - try { - // Check current status - const checkResponse = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const checkData = await checkResponse.json(); - if (!checkData.success) { - throw new Error(checkData.error || 'Failed to check watchlist status'); - } - - const isWatching = checkData.is_watching; - - // Toggle watchlist status - const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; - const payload = isWatching ? - { artist_id: artistId } : - { artist_id: artistId, artist_name: artistName }; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to update watchlist'); - } - - // Update button appearance - if (isWatching) { - // Was watching, now removed - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - console.log(`❌ Removed ${artistName} from watchlist`); - } else { - // Was not watching, now added - icon.textContent = '👁️'; - text.textContent = 'Remove from Watchlist'; - button.classList.add('watching'); - console.log(`✅ Added ${artistName} to watchlist`); - } - - // Show/hide watchlist settings gear - const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); - if (settingsBtn) { - if (!isWatching) { - // Just added to watchlist — show gear - settingsBtn.classList.remove('hidden'); - settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, artistName); - } else { - // Just removed from watchlist — hide gear - settingsBtn.classList.add('hidden'); - settingsBtn.onclick = null; - } - } - - // Update dashboard watchlist count - updateWatchlistButtonCount(); - - // Update any visible artist cards - updateArtistCardWatchlistStatus(); - - } catch (error) { - console.error('Error toggling watchlist:', error); - text.textContent = originalText; - - // Show error feedback - const originalBackground = button.style.background; - button.style.background = 'rgba(255, 59, 48, 0.3)'; - setTimeout(() => { - button.style.background = originalBackground; - }, 2000); - } finally { - button.disabled = false; - } -} - -/** - * Update artist detail watchlist button status - */ -async function updateArtistDetailWatchlistButton(artistId, artistName) { - const button = document.getElementById('artist-detail-watchlist-btn'); - if (!button) { - console.warn('⚠️ Artist detail watchlist button not found'); - return; - } - - // Use passed name or fall back to stored data attribute - const name = artistName || button.dataset.artistName || ''; - - try { - console.log(`🔍 Checking watchlist status for artist: ${artistId}`); - - const response = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const data = await response.json(); - if (data.success) { - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - console.log(`📊 Watchlist status for ${artistId}: ${data.is_watching ? 'WATCHING' : 'NOT WATCHING'}`); - - // Ensure button is enabled - button.disabled = false; - - // Show/hide watchlist settings gear - const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); - if (settingsBtn) { - if (data.is_watching) { - settingsBtn.classList.remove('hidden'); - settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, name); - } else { - settingsBtn.classList.add('hidden'); - settingsBtn.onclick = null; - } - } - - if (data.is_watching) { - icon.textContent = '👁️'; - text.textContent = 'Remove from Watchlist'; - button.classList.add('watching'); - } else { - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - } - } else { - console.error('❌ Failed to check watchlist status:', data.error); - } - } catch (error) { - console.error('❌ Error checking watchlist status:', error); - // Ensure button doesn't get stuck in bad state - button.disabled = false; - } -} - -/** - * Show loading state for discography - */ -function showDiscographyLoading() { - const albumsContainer = document.getElementById('album-cards-container'); - const singlesContainer = document.getElementById('singles-cards-container'); - - const loadingHtml = ` -
    -
    -
    -
    Loading...
    -
    -
    -
    -
    -
    -
    - `.repeat(4); - - if (albumsContainer) albumsContainer.innerHTML = loadingHtml; - if (singlesContainer) singlesContainer.innerHTML = loadingHtml; -} - -/** - * Show error state for discography - */ -function showDiscographyError(message = 'Failed to load discography') { - const albumsContainer = document.getElementById('album-cards-container'); - const singlesContainer = document.getElementById('singles-cards-container'); - - const errorHtml = ` -
    -
    ⚠️
    -
    Failed to load discography
    -
    ${escapeHtml(message)}
    -
    - `; - - if (albumsContainer) albumsContainer.innerHTML = errorHtml; - if (singlesContainer) singlesContainer.innerHTML = errorHtml; -} - -/** - * Show loading cards while searching - */ -function showSearchLoadingCards() { - const container = document.getElementById('artists-cards-container'); - if (!container) return; - - const loadingCardHtml = ` -
    -
    -
    -
    -
    Loading...
    -
    Fetching data...
    -
    - - Loading... -
    -
    -
    - `; - - // Show 6 loading cards - container.innerHTML = loadingCardHtml.repeat(6); -} - -// =============================== -// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION -// =============================== - -/** - * Get the completion status of an album from cached data or DOM - * @param {string} albumId - The album ID - * @param {string} albumType - The album type ('albums' or 'singles') - * @returns {Object|null} - Completion status object or null - */ -function getAlbumCompletionStatus(albumId, albumType) { - try { - // First, check cached completion data - const artistId = artistsPageState.selectedArtist?.id; - if (artistId && artistsPageState.cache.completionData[artistId]) { - const cachedData = artistsPageState.cache.completionData[artistId]; - const dataArray = albumType === 'albums' ? cachedData.albums : cachedData.singles; - - if (dataArray) { - const completionData = dataArray.find(item => item.album_id === albumId || item.id === albumId); - if (completionData) { - console.log(`📊 Found cached completion data for album ${albumId}:`, completionData); - return completionData; - } - } - } - - // Fallback: Check DOM completion overlay - const containerId = albumType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; - const container = document.getElementById(containerId); - - if (container) { - const albumCard = container.querySelector(`[data-album-id="${albumId}"]`); - if (albumCard) { - const overlay = albumCard.querySelector('.completion-overlay'); - if (overlay) { - // Extract status from overlay classes - const classList = Array.from(overlay.classList); - const statusClasses = ['completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error']; - const status = statusClasses.find(cls => classList.includes(cls)); - - if (status) { - console.log(`📊 Found DOM completion status for album ${albumId}: ${status}`); - return { status, completion_percentage: status === 'completed' ? 100 : 0 }; - } - } - } - } - - console.warn(`⚠️ No completion status found for album ${albumId}`); - return null; - - } catch (error) { - console.error(`❌ Error getting album completion status for ${albumId}:`, error); - return null; - } -} - -/** - * Handle album/single/EP click to open download missing tracks modal - */ -async function handleArtistAlbumClick(album, albumType) { - console.log(`🎵 Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`); - - if (!artistsPageState.selectedArtist) { - console.error('❌ No selected artist found'); - showToast('Error: No artist selected', 'error'); - return; - } - - showLoadingOverlay('Loading album...'); - - try { - // Check completion status of the album - const completionStatus = getAlbumCompletionStatus(album.id, albumType); - console.log(`📊 Album completion status: ${completionStatus?.status || 'unknown'} (${completionStatus?.completion_percentage || 0}%)`); - - // For Artists page, always use Download Missing Tracks modal to analyze and download - console.log(`🔄 Opening download missing tracks modal for album analysis`); - - // Create virtual playlist ID - const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`; - - // Check if modal already exists and show it - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`📱 Reopening existing modal for ${album.name}`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - hideLoadingOverlay(); - return; - } - } - - // Create virtual playlist and open modal - // Note: Don't hide loading overlay here - let the flow continue through to the modal - await createArtistAlbumVirtualPlaylist(album, albumType); - - } catch (error) { - hideLoadingOverlay(); - console.error('❌ Error handling album click:', error); - showToast(`Error opening download modal: ${error.message}`, 'error'); - } -} - -/** - * Create virtual playlist for artist album and open download missing tracks modal - */ -async function createArtistAlbumVirtualPlaylist(album, albumType) { - const artist = artistsPageState.selectedArtist; - const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`; - - console.log(`🎵 Creating virtual playlist for: ${artist.name} - ${album.name}`); - - try { - // Loading overlay already shown by handleArtistAlbumClick - - // Fetch album tracks from backend (pass name/artist for Hydrabase support) - const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' }); - const albumSource = artistsPageState.sourceOverride || album.source || artist.source || artistsPageState.artistDiscography?.source || null; - if (albumSource) { - _aat1.set('source', albumSource); - } - if (artistsPageState.pluginOverride) { - _aat1.set('plugin', artistsPageState.pluginOverride); - } - const response = await fetch(`/api/album/${album.id}/tracks?${_aat1}`); - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Spotify not authenticated. Please check your API settings.'); - } - const errData = await response.json().catch(() => ({})); - throw new Error(errData.error || `Failed to load album tracks: ${response.status}`); - } - - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found for this album'); - } - - console.log(`✅ Loaded ${data.tracks.length} tracks for ${data.album.name}`); - - // Use album data from API response (has complete data including images array) - const fullAlbumData = data.album; - - // Format playlist name with artist and album info - const playlistName = `[${artist.name}] ${fullAlbumData.name}`; - - // Open download missing tracks modal with formatted tracks - // Pass false for showLoadingOverlay since we already have one from handleArtistAlbumClick - // Use fullAlbumData from API response instead of album parameter - await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, fullAlbumData, artist, false); - - // Track this download for artist bubble management - registerArtistDownload(artist, album, virtualPlaylistId, albumType); - - } catch (error) { - console.error('❌ Error creating virtual playlist:', error); - showToast(`Failed to load album: ${error.message}`, 'error'); - throw error; - } -} - -/** - * Open download missing tracks modal specifically for artist albums - * Similar to openDownloadMissingModalForYouTube but for artist albums - */ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') { if (showLoadingOverlayParam) { showLoadingOverlay('Loading album...'); @@ -4627,3 +2759,407 @@ function renderEnrichmentCards(enrichment) { } // =============================== + + +// ---------------------------------------------------------------------------- +// Similar Artists — fetch + render via MusicMap. Source-agnostic (artist name +// based), works for both library and metadata-source artists. Targets DOM IDs +// #similar-artists-loading, #similar-artists-error, #similar-artists-bubbles- +// container that live on the artist-detail page. +// ---------------------------------------------------------------------------- + +// Similar artists section lives on the standalone artist-detail page with the +// 'ad-' prefixed ids. The resolver shape was originally designed for both the +// inline Artists page and the standalone page; the inline page has since been +// retired, so only the standalone candidate remains. +function _resolveSimilarArtistsTargets() { + const sectionEl = document.getElementById('ad-similar-artists-section'); + if (!sectionEl) return null; + return { + section: sectionEl, + loadingEl: document.getElementById('ad-similar-artists-loading'), + errorEl: document.getElementById('ad-similar-artists-error'), + container: document.getElementById('ad-similar-artists-bubbles-container'), + }; +} + +async function loadSimilarArtists(artistName) { + if (!artistName) { + console.warn('⚠️ No artist name provided for similar artists'); + return; + } + + console.log(`🔍 Loading similar artists for: ${artistName}`); + + const targets = _resolveSimilarArtistsTargets(); + if (!targets) { + console.warn('⚠️ Similar artists section elements not found on any active page'); + return; + } + const { section, loadingEl, errorEl, container } = targets; + + // Show loading state + loadingEl.classList.remove('hidden'); + errorEl.classList.add('hidden'); + container.innerHTML = ''; + section.style.display = 'block'; + + try { + // Create new abort controller for this similar artists stream + similarArtistsController = new AbortController(); + + // Use streaming endpoint for real-time bubble creation + const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; + console.log(`📡 Streaming from: ${url}`); + + const response = await fetch(url, { + signal: similarArtistsController.signal + }); + + if (!response.ok) { + throw new Error(`Failed to fetch similar artists: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let artistCount = 0; + + // Read the stream + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log('✅ Stream complete'); + break; + } + + // Decode the chunk and add to buffer + buffer += decoder.decode(value, { stream: true }); + + // Process complete messages (separated by \n\n) + const messages = buffer.split('\n\n'); + buffer = messages.pop() || ''; // Keep incomplete message in buffer + + for (const message of messages) { + if (!message.trim() || !message.startsWith('data: ')) continue; + + try { + const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix + + if (jsonData.error) { + throw new Error(jsonData.error); + } + + if (jsonData.artist) { + // Hide loading on first artist + if (artistCount === 0) { + loadingEl.classList.add('hidden'); + } + + // Create and append bubble immediately + const bubble = createSimilarArtistBubble(jsonData.artist); + container.appendChild(bubble); + artistCount++; + + console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`); + } + + if (jsonData.complete) { + console.log(`🎉 Streaming complete: ${jsonData.total} artists`); + + if (artistCount === 0) { + loadingEl.classList.add('hidden'); + container.innerHTML = ` +
    +
    🎵
    +
    No similar artists found
    +
    + `; + } else { + // Lazy load images for similar artists that don't have them + lazyLoadSimilarArtistImages(container); + } + } + } catch (parseError) { + console.error('❌ Error parsing stream message:', parseError); + } + } + } + + // Clear the controller when done + similarArtistsController = null; + + } catch (error) { + // Don't show error if it was aborted (user navigated away) + if (error.name === 'AbortError') { + console.log('⏹️ Similar artists stream aborted (user navigated to new artist)'); + loadingEl.classList.add('hidden'); + return; + } + + console.error('❌ Error loading similar artists:', error); + + // Hide loading, show error + loadingEl.classList.add('hidden'); + errorEl.classList.remove('hidden'); + + // Also show error message in container + container.innerHTML = ` +
    +
    ⚠️
    +
    ${error.message}
    +
    + `; + } finally { + // Always clear the controller + similarArtistsController = null; + } +} + +/** + * Lazy load images for similar artist bubbles that don't have images + */ +async function lazyLoadSimilarArtistImages(container) { + if (!container) return; + + const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]'); + + if (bubblesNeedingImages.length === 0) { + console.log('✅ All similar artist bubbles have images'); + return; + } + + console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`); + + // Load images in parallel batches + const batchSize = 5; + const bubbles = Array.from(bubblesNeedingImages); + + for (let i = 0; i < bubbles.length; i += batchSize) { + const batch = bubbles.slice(i, i + batchSize); + + await Promise.all(batch.map(async (bubble) => { + const artistId = bubble.getAttribute('data-artist-id'); + const artistSource = bubble.getAttribute('data-artist-source') || ''; + const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; + if (!artistId) return; + + try { + const params = new URLSearchParams(); + if (artistSource) params.set('source', artistSource); + if (artistPlugin) params.set('plugin', artistPlugin); + + const imageUrl = params.toString() + ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` + : `/api/artist/${encodeURIComponent(artistId)}/image`; + + const response = await fetch(imageUrl); + const data = await response.json(); + + if (data.success && data.image_url) { + const imageContainer = bubble.querySelector('.similar-artist-bubble-image'); + if (imageContainer) { + const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist'; + imageContainer.innerHTML = `${artistName}`; + bubble.setAttribute('data-needs-image', 'false'); + console.log(`✅ Loaded image for similar artist ${artistId}`); + } + } + } catch (error) { + console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error); + } + })); + } + + console.log('✅ Finished lazy loading similar artist images'); +} + +/** + * Display similar artist bubble cards progressively (one at a time with delay) + */ +function displaySimilarArtistsProgressively(artists) { + const targets = _resolveSimilarArtistsTargets(); + const container = targets && targets.container; + + if (!container) { + console.warn('⚠️ Similar artists container not found'); + return; + } + + // Clear container + container.innerHTML = ''; + + // Add each bubble with a delay to simulate progressive loading + artists.forEach((artist, index) => { + setTimeout(() => { + const bubble = createSimilarArtistBubble(artist); + container.appendChild(bubble); + }, index * 100); // 100ms delay between each bubble + }); + + console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`); +} + +/** + * Display similar artist bubble cards (all at once - legacy) + */ +function displaySimilarArtists(artists) { + const targets = _resolveSimilarArtistsTargets(); + const container = targets && targets.container; + + if (!container) { + console.warn('⚠️ Similar artists container not found'); + return; + } + + // Clear container + container.innerHTML = ''; + + // Create bubble cards with staggered animation + artists.forEach((artist, index) => { + const bubble = createSimilarArtistBubble(artist); + + // Add staggered animation delay (50ms per bubble) + bubble.style.animationDelay = `${index * 0.05}s`; + + container.appendChild(bubble); + }); + + console.log(`✅ Displayed ${artists.length} similar artist bubbles`); +} + +/** + * Create a similar artist bubble card element + */ +function createSimilarArtistBubble(artist) { + // Create bubble container + const bubble = document.createElement('div'); + bubble.className = 'similar-artist-bubble'; + bubble.setAttribute('data-artist-id', artist.id); + bubble.setAttribute('data-artist-source', artist.source || ''); + if (artist.plugin) { + bubble.setAttribute('data-artist-plugin', artist.plugin); + } + + // Track if image needs lazy loading + const hasImage = artist.image_url && artist.image_url.trim() !== ''; + bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true'); + + // Create image container + const imageContainer = document.createElement('div'); + imageContainer.className = 'similar-artist-bubble-image'; + + if (hasImage) { + const img = document.createElement('img'); + img.src = artist.image_url; + img.alt = artist.name; + + // Handle image load error + img.onerror = () => { + console.log(`Failed to load image for ${artist.name}`); + imageContainer.innerHTML = `
    🎵
    `; + bubble.setAttribute('data-needs-image', 'true'); + }; + + imageContainer.appendChild(img); + } else { + // No image - show fallback (will be lazy loaded) + imageContainer.innerHTML = `
    🎵
    `; + } + + // Create name element + const name = document.createElement('div'); + name.className = 'similar-artist-bubble-name'; + name.textContent = artist.name; + name.title = artist.name; // Tooltip for full name + + // Optional: Create genres element (hidden by default in CSS) + const genres = document.createElement('div'); + genres.className = 'similar-artist-bubble-genres'; + if (artist.genres && artist.genres.length > 0) { + genres.textContent = artist.genres.slice(0, 2).join(', '); + } + + // Assemble bubble + bubble.appendChild(imageContainer); + bubble.appendChild(name); + if (artist.genres && artist.genres.length > 0) { + bubble.appendChild(genres); + } + + // Click → navigate to the standalone artist-detail page. Works for both + // library and source artists thanks to the source-aware backend endpoint. + bubble.addEventListener('click', () => { + console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`); + navigateToArtistDetail(artist.id, artist.name, artist.source || null); + }); + + return bubble; +} + + +// ---------------------------------------------------------------------------- +// Lazy artist-card image loader (used by wishlist-tools.js + the legacy inline +// Artists page search results). Fetches /api/artist//image for each card +// flagged data-needs-image="true" in batches of 5. +// ---------------------------------------------------------------------------- + +async function lazyLoadArtistImages(container) { + if (!container) { + console.error('❌ lazyLoadArtistImages: container is null'); + return; + } + + const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); + if (cardsNeedingImages.length === 0) return; + + const batchSize = 5; + const cards = Array.from(cardsNeedingImages); + + for (let i = 0; i < cards.length; i += batchSize) { + const batch = cards.slice(i, i + batchSize); + await Promise.all(batch.map(async (card) => { + const artistId = card.dataset.artistId; + if (!artistId) return; + try { + const response = await fetch(`/api/artist/${artistId}/image`); + const data = await response.json(); + if (data.success && data.image_url) { + if (card.classList.contains('suggestion-card')) { + card.style.backgroundImage = `url(${data.image_url})`; + card.style.backgroundSize = 'cover'; + card.style.backgroundPosition = 'center'; + } else if (card.classList.contains('artist-card')) { + const bgElement = card.querySelector('.artist-card-background'); + if (bgElement) { + bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; + } + } + card.dataset.needsImage = 'false'; + } + } catch (error) { + console.error(`❌ Failed to load image for artist ${artistId}:`, error); + } + })); + } +} + +// Legacy global alias — wishlist-tools.js falls back to window.lazyLoadArtistImages +window.lazyLoadArtistImages = lazyLoadArtistImages; + + +// ---------------------------------------------------------------------------- +// Album-card completion overlay error state (called from checkDiscographyCompletion +// when the API request fails) +// ---------------------------------------------------------------------------- + +function showCompletionError() { + const allOverlays = document.querySelectorAll('.completion-overlay.checking'); + allOverlays.forEach(overlay => { + overlay.classList.remove('checking'); + overlay.classList.add('error'); + overlay.innerHTML = 'Error'; + overlay.title = 'Failed to check completion status'; + }); +} diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 5d2801bc..612cffe3 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -464,6 +464,7 @@ function _renderDbStorageChart(tables, totalFileSize, method) { } async function playStatsTrack(title, artist, album) { + // 1. Try the library first — fastest and best quality if owned. try { const resp = await fetch('/api/stats/resolve-track', { method: 'POST', @@ -471,22 +472,56 @@ async function playStatsTrack(title, artist, album) { body: JSON.stringify({ title, artist }), }); const data = await resp.json(); - if (!data.success || !data.track) { - showToast(data.error || 'Track not found in library', 'error'); + if (data.success && data.track) { + const t = data.track; + playLibraryTrack({ + id: t.id, + title: t.title, + file_path: t.file_path, + bitrate: t.bitrate, + artist_id: t.artist_id, + album_id: t.album_id, + _stats_image: t.image_url || null, + }, t.album_title || album || '', t.artist_name || artist || ''); return; } - const t = data.track; - playLibraryTrack({ - id: t.id, - title: t.title, - file_path: t.file_path, - bitrate: t.bitrate, - artist_id: t.artist_id, - album_id: t.album_id, - _stats_image: t.image_url || null, - }, t.album_title || album || '', t.artist_name || artist || ''); } catch (e) { + console.debug('Library resolve failed, will try streaming fallback:', e); + } + + // 2. Library miss — fall back to streaming via the enhanced-search streamer + // (Soulseek → YouTube → other configured sources, same pipeline used by + // the search results' play button). + if (typeof showLoadingOverlay === 'function') { + showLoadingOverlay(`Searching for ${title}...`); + } + try { + const streamResp = await fetch('/api/enhanced-search/stream-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_name: title, + artist_name: artist, + album_name: album || '', + duration_ms: 0, + }), + }); + const streamData = await streamResp.json(); + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + + if (streamData.success && streamData.result) { + if (typeof startStream === 'function') { + await startStream(streamData.result); + } else { + showToast('Streaming not available', 'error'); + } + } else { + showToast(streamData.error || 'Track not found in library or any source', 'error'); + } + } catch (e) { + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); showToast('Failed to play track', 'error'); + console.error('Stream fallback failed:', e); } } diff --git a/webui/static/style.css b/webui/static/style.css index f8ae0196..e73755da 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -255,11 +255,15 @@ body { .sidebar-header { min-height: 115px; - background: linear-gradient(180deg, + /* Opaque base layered under the accent gradient so nav items scrolling + past the sticky header don't bleed through the translucent stops. */ + background: + linear-gradient(180deg, rgba(var(--accent-rgb), 0.14) 0%, rgba(var(--accent-rgb), 0.08) 30%, rgba(var(--accent-rgb), 0.03) 70%, - rgba(18, 18, 18, 1) 100%); + rgba(18, 18, 18, 1) 100%), + rgb(18, 18, 18); border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-top-right-radius: 20px; padding: 20px 24px; @@ -269,6 +273,10 @@ body { gap: 8px; position: sticky; top: 0; + /* Explicitly lift above the nav buttons. `.sidebar > *` sets z-index 1 on + every sidebar child, so without this the sticky header paints at the + same stacking level as the nav and loses to it on DOM order. */ + z-index: 2; overflow: hidden; flex-shrink: 0; @@ -4255,8 +4263,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { /* Main Layout: Replicates QSplitter */ .downloads-content { display: grid; - grid-template-columns: 1fr 370px; - /* Left panel is flexible, right is fixed */ + grid-template-columns: 1fr; gap: 24px; height: 100%; /* Fill parent .page content area */ @@ -23314,8 +23321,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .releases-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); + gap: 20px; padding: 8px 0; } @@ -23539,8 +23546,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .releases-grid { - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 18px; } .release-card { @@ -32834,10 +32841,76 @@ div.artist-hero-badge { } /* ========================================= */ -/* SEARCH MODE TOGGLE SYSTEM */ +/* ========================================= */ +/* SEARCH SOURCE PICKER (replaces Enhanced/Basic toggle) */ /* ========================================= */ -/* Toggle Container */ +.search-source-picker-container { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin: 20px 0; +} + +.search-source-picker-label { + color: rgba(255, 255, 255, 0.7); + font-family: 'Segoe UI', sans-serif; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.search-source-picker-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.search-source-picker-select { + appearance: none; + -webkit-appearance: none; + background: rgba(30, 30, 30, 0.8); + border: 1px solid rgba(64, 64, 64, 0.4); + border-radius: 10px; + color: #ffffff; + padding: 10px 40px 10px 16px; + font-family: 'Segoe UI', sans-serif; + font-size: 14px; + font-weight: 500; + cursor: pointer; + min-width: 220px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.search-source-picker-select:hover { + border-color: rgba(138, 43, 226, 0.6); +} + +.search-source-picker-select:focus { + outline: none; + border-color: rgba(138, 43, 226, 0.9); + box-shadow: 0 2px 10px rgba(138, 43, 226, 0.35); +} + +.search-source-picker-select option { + background: #1e1e1e; + color: #ffffff; +} + +.search-source-picker-caret { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; +} + +/* Legacy toggle (kept for any other references, but unused by the Search page) */ .search-mode-toggle-container { display: flex; justify-content: center; @@ -59654,3 +59727,138 @@ body.reduce-effects *::after { #discover-playlist-modal .mirrored-track-row { grid-template-columns: 40px 40px 1.4fr 1.2fr 1fr 56px; } + +/* ========================================================================= + Source-artist visibility rules + ========================================================================= + When the artist-detail page is rendering a source artist (Spotify / Deezer / + iTunes / etc. — not in the local library), hide library-only UI. The body + data-artist-source attribute is set by library.js populateArtistDetailPage + before rendering. +*/ + +/* Library-only: status filter (owned/missing only makes sense when you own things) */ +body[data-artist-source="source"] #artist-detail-page .filter-group:has(.filter-label:only-child), +body[data-artist-source="source"] #artist-detail-page .discography-filter-btn[data-filter="ownership"] { + display: none !important; +} + +/* Library-only: Enhanced view toggle + container (per-track editing requires owned tracks) */ +body[data-artist-source="source"] #artist-detail-page .enhanced-view-toggle-btn, +body[data-artist-source="source"] #artist-detail-page #enhanced-view-container { + display: none !important; +} + +/* Library-only buttons: Radio + Enhance Quality both require owned tracks. + Enrichment coverage is a per-track DB enrichment percentage that doesn't + apply to artists you don't own. + .collection-overview and #artist-hero-sidebar both render usefully for + source artists (collection shows 0/N missing, Top Tracks pulls from + Last.fm by name) so they stay visible. */ +body[data-artist-source="source"] #artist-detail-page .artist-enrichment-coverage, +body[data-artist-source="source"] #artist-detail-page #library-artist-radio-btn, +body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-btn { + display: none !important; +} + + +/* ========================================================================= + Standalone /artist-detail page hero — blurred-background treatment + ========================================================================= */ + +#artist-detail-page .artist-hero-section { + position: relative; + overflow: hidden; +} + +#artist-detail-page .artist-detail-hero-bg { + position: absolute; + inset: -20px; + background-size: cover; + background-position: center top; + background-repeat: no-repeat; + filter: blur(50px) brightness(0.35) saturate(1.4); + transform: scale(1.3); + z-index: 0; + pointer-events: none; +} + +#artist-detail-page .artist-detail-hero-overlay { + position: absolute; + inset: 0; + background: linear-gradient(180deg, + rgba(8, 8, 8, 0.5) 0%, + rgba(12, 12, 12, 0.7) 60%, + rgba(12, 12, 12, 0.9) 100%); + z-index: 1; + pointer-events: none; +} + +#artist-detail-page .artist-hero-content { + position: relative; + z-index: 2; +} + + +/* ========================================================================= + Album cards on /artist-detail — big-photo treatment + ========================================================================= + .release-card keeps the existing JS filter/state hooks; .album-card + layers on the visual treatment from the retired inline Artists page + (full-bleed image, gradient overlay, info pinned at bottom). + + The base .release-card rule still applies (background gradient, padding, + fixed 300px height, flex column). These overrides switch off the parts + that fight with the .album-card layout. */ + +#artist-detail-page .release-card.album-card { + background: rgba(18, 18, 18, 1); + backdrop-filter: none; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 0; + display: block; + height: auto; + aspect-ratio: 1; + overflow: hidden; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1), + border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +#artist-detail-page .release-card.album-card:hover { + background: rgba(18, 18, 18, 1); + transform: translateY(-5px) scale(1.02); + border-color: rgba(var(--accent-rgb), 0.25); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), + 0 0 24px rgba(var(--accent-rgb), 0.12); +} + +#artist-detail-page .release-card.album-card.missing { + opacity: 1; /* let the completion-overlay convey state, not the whole card */ +} + +/* The fit-content override on the discography page also conflicts with + aspect-ratio:1 — clear it for album-card variants. */ +#artist-detail-page .discography-sections .release-card.album-card { + height: auto; +} + +/* MusicBrainz icon position (was tied to the old card's padding) */ +#artist-detail-page .release-card.album-card .mb-card-icon { + position: absolute; + top: 8px; + left: 8px; + z-index: 3; + background: rgba(0, 0, 0, 0.65); + border-radius: 6px; + padding: 4px 6px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.2s ease; +} + +#artist-detail-page .release-card.album-card .mb-card-icon:hover { + opacity: 1; +}