Merge pull request #361 from Nezreka/feature/unify-search-and-artist-detail
Feature/unify search and artist detail
This commit is contained in:
commit
8f1e1666f2
23 changed files with 2368 additions and 3068 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
186
core/artist_source_detail.py
Normal file
186
core/artist_source_detail.py
Normal file
|
|
@ -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/<id>`` 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/<id>/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
|
||||
88
core/artist_source_lookup.py
Normal file
88
core/artist_source_lookup.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
274
tests/test_artist_source_detail.py
Normal file
274
tests/test_artist_source_detail.py
Normal file
|
|
@ -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"]
|
||||
239
tests/test_artist_source_lookup.py
Normal file
239
tests/test_artist_source_lookup.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 <script> context the last-loaded declaration wins. These are NOT
|
||||
# regressions from the split — they should be deduplicated in a follow-up.
|
||||
KNOWN_CROSS_FILE_DUPES = {
|
||||
"escapeHtml", # downloads.js, artists.js, discover.js
|
||||
"escapeHtml", # downloads.js, shared-helpers.js, discover.js
|
||||
"formatDuration", # sync-spotify.js, wishlist-tools.js, sync-services.js
|
||||
"matchedDownloadTrack", # downloads.js, wishlist-tools.js
|
||||
"matchedDownloadAlbum", # downloads.js, wishlist-tools.js
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ class TestSpaRoutes:
|
|||
"""Deep-link paths for valid client pages should serve index.html."""
|
||||
|
||||
@pytest.mark.parametrize("page", [
|
||||
'dashboard', 'sync', 'downloads', 'discover', 'artists',
|
||||
'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists',
|
||||
'automations', 'library', 'import', 'settings', 'help',
|
||||
'issues', 'stats', 'watchlist', 'wishlist', 'active-downloads',
|
||||
'artist-detail', 'playlist-explorer', 'hydrabase', 'tools',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import sys
|
||||
import types
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
_RECENT_RELEASE_DATE = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
if "spotipy" not in sys.modules:
|
||||
|
|
@ -889,7 +893,7 @@ def test_cache_discovery_recent_albums_uses_primary_source_first(monkeypatch):
|
|||
id="dz-album-1",
|
||||
name="Recent Deezer Album",
|
||||
album_type="album",
|
||||
release_date="2026-04-01",
|
||||
release_date=_RECENT_RELEASE_DATE,
|
||||
image_url="https://example.com/deezer-album.jpg",
|
||||
)
|
||||
|
||||
|
|
@ -941,7 +945,7 @@ def test_cache_discovery_recent_albums_falls_back_to_spotify_when_primary_has_no
|
|||
id="sp-album-1",
|
||||
name="Spotify Recent Album",
|
||||
album_type="album",
|
||||
release_date="2026-04-01",
|
||||
release_date=_RECENT_RELEASE_DATE,
|
||||
image_url="https://example.com/spotify-album.jpg",
|
||||
)
|
||||
spotify_client = _FakeSourceClient(
|
||||
|
|
@ -952,7 +956,7 @@ def test_cache_discovery_recent_albums_falls_back_to_spotify_when_primary_has_no
|
|||
"id": "sp-album-1",
|
||||
"name": "Spotify Recent Album",
|
||||
"images": [{"url": "https://example.com/spotify-album.jpg"}],
|
||||
"release_date": "2026-04-01",
|
||||
"release_date": _RECENT_RELEASE_DATE,
|
||||
"popularity": 50,
|
||||
"tracks": {"items": [{"id": "sp-track-1", "name": "Spotify Track", "artists": [{"name": "Fallback Artist"}]}]},
|
||||
"artists": [{"id": "sp-artist"}],
|
||||
|
|
@ -992,7 +996,7 @@ def test_update_discovery_pool_incremental_uses_source_priority(monkeypatch):
|
|||
release = types.SimpleNamespace(
|
||||
id="dz-release-1",
|
||||
name="Incremental Release",
|
||||
release_date="2026-04-16",
|
||||
release_date=_RECENT_RELEASE_DATE,
|
||||
album_type="album",
|
||||
image_url="https://example.com/deezer-release.jpg",
|
||||
)
|
||||
|
|
@ -1005,7 +1009,7 @@ def test_update_discovery_pool_incremental_uses_source_priority(monkeypatch):
|
|||
"id": "dz-release-1",
|
||||
"name": "Incremental Release",
|
||||
"images": [{"url": "https://example.com/deezer-release.jpg"}],
|
||||
"release_date": "2026-04-16",
|
||||
"release_date": _RECENT_RELEASE_DATE,
|
||||
"popularity": 10,
|
||||
"tracks": {"items": [{"id": "dz-track-1", "name": "Incremental Track", "artists": [{"name": "Incremental Artist"}], "duration_ms": 180000}]},
|
||||
"artists": [{"id": "dz-artist"}],
|
||||
|
|
@ -1019,7 +1023,7 @@ def test_update_discovery_pool_incremental_uses_source_priority(monkeypatch):
|
|||
"id": "sp-release-1",
|
||||
"name": "Spotify Incremental Release",
|
||||
"images": [{"url": "https://example.com/spotify-release.jpg"}],
|
||||
"release_date": "2026-04-16",
|
||||
"release_date": _RECENT_RELEASE_DATE,
|
||||
"popularity": 50,
|
||||
"tracks": {"items": [{"id": "sp-track-1", "name": "Spotify Incremental Track", "artists": [{"name": "Incremental Artist"}], "duration_ms": 180000}]},
|
||||
"artists": [{"id": "sp-artist"}],
|
||||
|
|
|
|||
240
web_server.py
240
web_server.py
|
|
@ -320,7 +320,7 @@ def get_spotify_client_for_profile(profile_id=None):
|
|||
return spotify_client # Fall back to global
|
||||
|
||||
# Valid page IDs for profile permission validation
|
||||
VALID_PAGE_IDS = {'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'}
|
||||
VALID_PAGE_IDS = {'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'}
|
||||
|
||||
def check_download_permission():
|
||||
"""Check if current profile has download permission. Returns error response or None if allowed."""
|
||||
|
|
@ -2803,8 +2803,11 @@ _ENHANCED_SEARCH_CACHE_TTL = 600
|
|||
_ENHANCED_SEARCH_CACHE_MAX_ENTRIES = 100
|
||||
|
||||
|
||||
def _get_enhanced_search_cache_key(query):
|
||||
"""Build a cache key that follows the current metadata/search configuration."""
|
||||
def _get_enhanced_search_cache_key(query, requested_source=None):
|
||||
"""Build a cache key that follows the current metadata/search configuration.
|
||||
|
||||
When an explicit `requested_source` is provided (single-source search), it is
|
||||
included in the key so that results for different sources don't collide."""
|
||||
normalized_query = (query or '').strip().lower()
|
||||
|
||||
try:
|
||||
|
|
@ -2822,7 +2825,45 @@ def _get_enhanced_search_cache_key(query):
|
|||
except Exception:
|
||||
hydrabase_active = False
|
||||
|
||||
return (normalized_query, active_server, fallback_source, hydrabase_active)
|
||||
source_tag = (requested_source or '').strip().lower() or 'auto'
|
||||
return (normalized_query, active_server, fallback_source, hydrabase_active, source_tag)
|
||||
|
||||
|
||||
ENHANCED_SEARCH_VALID_SOURCES = (
|
||||
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
|
||||
)
|
||||
|
||||
|
||||
def _resolve_enhanced_search_client(source_name):
|
||||
"""Return (client, is_available) for a single requested metadata source.
|
||||
|
||||
Mirrors the client-resolution logic used by the /source/<source> endpoint so
|
||||
that the `source` param on the main endpoint behaves consistently."""
|
||||
if source_name == 'spotify':
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
return spotify_client, True
|
||||
return None, False
|
||||
if source_name == 'itunes':
|
||||
return _get_itunes_client(), True
|
||||
if source_name == 'deezer':
|
||||
return _get_deezer_client(), True
|
||||
if source_name == 'discogs':
|
||||
token = config_manager.get('discogs.token', '')
|
||||
if not token:
|
||||
return None, False
|
||||
return _get_discogs_client(token), True
|
||||
if source_name == 'hydrabase':
|
||||
if hydrabase_client and hydrabase_client.is_connected():
|
||||
return hydrabase_client, True
|
||||
return None, False
|
||||
if source_name == 'musicbrainz':
|
||||
try:
|
||||
from core.musicbrainz_search import MusicBrainzSearchClient
|
||||
return MusicBrainzSearchClient(), True
|
||||
except Exception as e:
|
||||
logger.warning(f"MusicBrainz search client init failed: {e}")
|
||||
return None, False
|
||||
return None, False
|
||||
|
||||
|
||||
def _get_cached_enhanced_search_response(cache_key):
|
||||
|
|
@ -9215,10 +9256,22 @@ def enhanced_search():
|
|||
Fires parallel queries against all available sources (Spotify, iTunes, Deezer)
|
||||
and returns results keyed by source, plus backward-compatible top-level keys
|
||||
mapped from the primary source.
|
||||
|
||||
Optional JSON body param `source` (one of: auto, spotify, itunes, deezer,
|
||||
discogs, hydrabase, musicbrainz). When set to a specific source, the endpoint
|
||||
bypasses the primary-source fan-out and returns just that source's results
|
||||
(plus the usual db_artists). `auto` and omitted behave identically — current
|
||||
multi-source fan-out.
|
||||
"""
|
||||
data = request.get_json()
|
||||
query = data.get('query', '').strip()
|
||||
cache_key = _get_enhanced_search_cache_key(query)
|
||||
requested_source = (data.get('source') or '').strip().lower()
|
||||
if requested_source == 'auto':
|
||||
requested_source = ''
|
||||
if requested_source and requested_source not in ENHANCED_SEARCH_VALID_SOURCES:
|
||||
return jsonify({"error": f"Unknown source: {requested_source}"}), 400
|
||||
|
||||
cache_key = _get_enhanced_search_cache_key(query, requested_source)
|
||||
|
||||
empty_source = {"artists": [], "albums": [], "tracks": [], "available": False}
|
||||
|
||||
|
|
@ -9238,7 +9291,10 @@ def enhanced_search():
|
|||
logger.info(f"Enhanced search cache hit for: '{query}'")
|
||||
return jsonify(cached_response)
|
||||
|
||||
logger.info(f"Enhanced search initiated for: '{query}'")
|
||||
logger.info(
|
||||
f"Enhanced search initiated for: '{query}' "
|
||||
f"(source={requested_source or 'auto'})"
|
||||
)
|
||||
|
||||
try:
|
||||
# Search local database for artists (always)
|
||||
|
|
@ -9260,20 +9316,62 @@ def enhanced_search():
|
|||
# Very short queries are usually broad enough that remote metadata searches
|
||||
# just add latency without improving the result quality much. Keep them local.
|
||||
if len(query) < 3:
|
||||
fb_source = _get_metadata_fallback_source()
|
||||
short_source = requested_source or _get_metadata_fallback_source()
|
||||
response_data = {
|
||||
"db_artists": db_artists,
|
||||
"spotify_artists": [],
|
||||
"spotify_albums": [],
|
||||
"spotify_tracks": [],
|
||||
"metadata_source": fb_source,
|
||||
"primary_source": fb_source,
|
||||
"metadata_source": short_source,
|
||||
"primary_source": short_source,
|
||||
"alternate_sources": [],
|
||||
"sources": {},
|
||||
}
|
||||
_set_cached_enhanced_search_response(cache_key, response_data)
|
||||
return jsonify(response_data)
|
||||
|
||||
# Explicit single-source search — bypass primary-source fan-out entirely.
|
||||
if requested_source:
|
||||
client, available = _resolve_enhanced_search_client(requested_source)
|
||||
if not client:
|
||||
response_data = {
|
||||
"db_artists": db_artists,
|
||||
"spotify_artists": [],
|
||||
"spotify_albums": [],
|
||||
"spotify_tracks": [],
|
||||
"metadata_source": requested_source,
|
||||
"primary_source": requested_source,
|
||||
"alternate_sources": [],
|
||||
"source_available": False,
|
||||
}
|
||||
_set_cached_enhanced_search_response(cache_key, response_data)
|
||||
return jsonify(response_data)
|
||||
|
||||
try:
|
||||
source_results = _enhanced_search_source(query, client, requested_source)
|
||||
except Exception as e:
|
||||
logger.warning(f"Single-source search ({requested_source}) failed: {e}")
|
||||
source_results = {"artists": [], "albums": [], "tracks": [], "available": False}
|
||||
|
||||
logger.info(
|
||||
f"Enhanced search [source={requested_source}] results: "
|
||||
f"{len(db_artists)} DB, {len(source_results['artists'])} artists, "
|
||||
f"{len(source_results['albums'])} albums, {len(source_results['tracks'])} tracks"
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"db_artists": db_artists,
|
||||
"spotify_artists": source_results["artists"],
|
||||
"spotify_albums": source_results["albums"],
|
||||
"spotify_tracks": source_results["tracks"],
|
||||
"metadata_source": requested_source,
|
||||
"primary_source": requested_source,
|
||||
"alternate_sources": [],
|
||||
"source_available": True,
|
||||
}
|
||||
_set_cached_enhanced_search_response(cache_key, response_data)
|
||||
return jsonify(response_data)
|
||||
|
||||
# ── Determine primary source and search it synchronously ──
|
||||
primary_source = "spotify"
|
||||
primary_results = empty_source
|
||||
|
|
@ -11218,11 +11316,101 @@ def test_artist_endpoint(artist_id):
|
|||
"message": f"Test endpoint working for artist ID: {artist_id}"
|
||||
})
|
||||
|
||||
from core.artist_source_lookup import (
|
||||
SOURCE_ID_FIELD as _SOURCE_ID_FIELD,
|
||||
SOURCE_ONLY_ARTIST_SOURCES as _SOURCE_ONLY_ARTIST_SOURCES,
|
||||
find_library_artist_for_source as _core_find_library_artist_for_source,
|
||||
)
|
||||
|
||||
|
||||
def _find_library_artist_for_source(database, source, source_artist_id, artist_name=None):
|
||||
"""Thin wrapper that injects the active-server context for the core lookup."""
|
||||
try:
|
||||
active_server = config_manager.get_active_media_server()
|
||||
except Exception:
|
||||
active_server = None
|
||||
return _core_find_library_artist_for_source(
|
||||
database, source, source_artist_id, artist_name, active_server=active_server
|
||||
)
|
||||
|
||||
|
||||
def _build_source_only_artist_detail(artist_id, artist_name, source):
|
||||
"""Thin wrapper around ``core.artist_source_detail.build_source_only_artist_detail``.
|
||||
|
||||
Builds the per-source client bag from web_server's module globals (each
|
||||
source's module-level client + Last.fm api key), forwards to the pure
|
||||
implementation in ``core/``, and wraps the (dict, status) return in
|
||||
``jsonify``.
|
||||
"""
|
||||
from core.artist_source_detail import build_source_only_artist_detail
|
||||
|
||||
# Resolve the per-source clients defensively — the original inline code
|
||||
# wrapped the whole source-side lookup in try/except so a failing
|
||||
# client helper (e.g. Spotify auth probe during a rate-limit ban,
|
||||
# Discogs client init error) would degrade gracefully to empty
|
||||
# enrichment instead of 500-ing the request. Preserve that.
|
||||
sp = None
|
||||
dz = None
|
||||
it = None
|
||||
dc = None
|
||||
try:
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
sp = spotify_client
|
||||
except Exception as e:
|
||||
logger.debug(f"Spotify client resolution failed: {e}")
|
||||
try:
|
||||
dz = _get_deezer_client()
|
||||
except Exception as e:
|
||||
logger.debug(f"Deezer client resolution failed: {e}")
|
||||
try:
|
||||
it = _get_itunes_client()
|
||||
except Exception as e:
|
||||
logger.debug(f"iTunes client resolution failed: {e}")
|
||||
try:
|
||||
discogs_token = config_manager.get('discogs.token', '') or ''
|
||||
if discogs_token:
|
||||
dc = _get_discogs_client(discogs_token)
|
||||
except Exception as e:
|
||||
logger.debug(f"Discogs client resolution failed: {e}")
|
||||
|
||||
try:
|
||||
lastfm_api_key = config_manager.get('lastfm.api_key', '') or None
|
||||
except Exception:
|
||||
lastfm_api_key = None
|
||||
|
||||
payload, status = build_source_only_artist_detail(
|
||||
artist_id,
|
||||
artist_name,
|
||||
source,
|
||||
spotify_client=sp,
|
||||
deezer_client=dz,
|
||||
itunes_client=it,
|
||||
discogs_client=dc,
|
||||
lastfm_api_key=lastfm_api_key,
|
||||
)
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
@app.route('/api/artist-detail/<artist_id>')
|
||||
def get_artist_detail(artist_id):
|
||||
"""Get artist detail data"""
|
||||
"""Get artist detail data.
|
||||
|
||||
For library artists, `artist_id` is the local DB primary key and the full
|
||||
library-aware path runs (owned releases + merged source discography + per-
|
||||
service enrichment coverage).
|
||||
|
||||
For source artists (Spotify/Deezer/iTunes/etc. that aren't in the library
|
||||
yet), pass `?source=<source>&name=<artist_name>` and the endpoint synthesizes
|
||||
a response directly from the metadata source — no owned releases, just name +
|
||||
image + discography so the artist-detail page can still render.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Getting artist detail for ID: {artist_id}")
|
||||
source_param = (request.args.get('source', '') or '').strip().lower()
|
||||
artist_name_arg = (request.args.get('name', '') or '').strip()
|
||||
logger.info(
|
||||
f"Getting artist detail for ID: {artist_id} "
|
||||
f"(source={source_param or 'library'})"
|
||||
)
|
||||
|
||||
# Get database instance
|
||||
database = get_database()
|
||||
|
|
@ -11230,7 +11418,31 @@ def get_artist_detail(artist_id):
|
|||
# Get artist discography from database
|
||||
db_result = database.get_artist_discography(artist_id)
|
||||
|
||||
# Library upgrade: if direct ID lookup missed AND we have a source hint,
|
||||
# check whether the user already owns this artist in the library under
|
||||
# a different ID (e.g. clicking a Deezer search result for an artist
|
||||
# they have indexed in Plex). Prefer the library record so they get
|
||||
# all their owned releases + enrichment instead of a bare source view.
|
||||
if not db_result.get('success') and source_param in _SOURCE_ONLY_ARTIST_SOURCES:
|
||||
library_pk = _find_library_artist_for_source(
|
||||
database, source_param, artist_id, artist_name_arg
|
||||
)
|
||||
if library_pk:
|
||||
logger.info(
|
||||
f"Source-id {source_param}:{artist_id} matched library artist "
|
||||
f"PK={library_pk} — upgrading to library response"
|
||||
)
|
||||
db_result = database.get_artist_discography(library_pk)
|
||||
|
||||
if not db_result.get('success'):
|
||||
# Library lookup still failed. If a metadata source was specified,
|
||||
# fall back to a source-only response so the page can render a
|
||||
# non-library artist.
|
||||
if source_param in _SOURCE_ONLY_ARTIST_SOURCES:
|
||||
return _build_source_only_artist_detail(
|
||||
artist_id, artist_name_arg, source_param
|
||||
)
|
||||
|
||||
logger.error(f"Database returned error: {db_result}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
|
|
@ -41837,10 +42049,14 @@ def watchlist_artist_config(artist_id):
|
|||
try:
|
||||
conn2 = sqlite3.connect(str(database.database_path))
|
||||
cur2 = conn2.cursor()
|
||||
# The library `artists` table uses `deezer_id` / `discogs_id` for
|
||||
# those columns; only the `watchlist_artists` table uses the
|
||||
# `_artist_id` suffix for them. Mixing them was producing a
|
||||
# 'no such column' on every watchlist-config GET.
|
||||
cur2.execute("""
|
||||
SELECT banner_url, summary, style, mood, label, genres
|
||||
FROM artists
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_id = ? OR discogs_id = ?
|
||||
LIMIT 1
|
||||
""", (artist_id, artist_id, artist_id, artist_id))
|
||||
lib_row = cur2.fetchone()
|
||||
|
|
|
|||
262
webui/index.html
262
webui/index.html
|
|
@ -98,9 +98,8 @@
|
|||
<option value="">Default (Discover)</option>
|
||||
<option value="dashboard">Dashboard</option>
|
||||
<option value="sync">Sync</option>
|
||||
<option value="downloads">Search</option>
|
||||
<option value="search">Search</option>
|
||||
<option value="discover">Discover</option>
|
||||
<option value="artists">Artists</option>
|
||||
<option value="automations">Automations</option>
|
||||
<option value="active-downloads">Downloads</option>
|
||||
<option value="library">Library</option>
|
||||
|
|
@ -113,9 +112,8 @@
|
|||
<div id="new-profile-allowed-pages" class="profile-page-checkboxes">
|
||||
<label><input type="checkbox" value="dashboard" checked> Dashboard</label>
|
||||
<label><input type="checkbox" value="sync" checked> Sync</label>
|
||||
<label><input type="checkbox" value="downloads" checked> Search</label>
|
||||
<label><input type="checkbox" value="search" checked> Search</label>
|
||||
<label><input type="checkbox" value="discover" checked> Discover</label>
|
||||
<label><input type="checkbox" value="artists" checked> Artists</label>
|
||||
<label><input type="checkbox" value="automations" checked> Automations</label>
|
||||
<label><input type="checkbox" value="active-downloads" checked> Downloads</label>
|
||||
<label><input type="checkbox" value="library" checked> Library</label>
|
||||
|
|
@ -196,7 +194,7 @@
|
|||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg></span>
|
||||
<span class="nav-text">Sync</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="downloads">
|
||||
<button class="nav-button" data-page="search">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><polyline points="8 11 11 14 14 11"/></svg></span>
|
||||
<span class="nav-text">Search</span>
|
||||
</button>
|
||||
|
|
@ -208,10 +206,6 @@
|
|||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="3"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="12" x2="5" y2="18"/><line x1="12" y1="12" x2="19" y2="18"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><line x1="12" y1="12" x2="12" y2="18"/><circle cx="12" cy="19" r="2"/></svg></span>
|
||||
<span class="nav-text">Explorer</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="artists">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
|
||||
<span class="nav-text">Artists</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="watchlist">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
||||
<span class="nav-text">Watchlist</span>
|
||||
|
|
@ -1843,15 +1837,15 @@
|
|||
</div>
|
||||
|
||||
<!-- Downloads Page -->
|
||||
<div class="page" id="downloads-page">
|
||||
<div class="page" id="search-page">
|
||||
<!--
|
||||
This top-level container replicates the QSplitter from downloads.py,
|
||||
creating the two-panel layout for the page.
|
||||
-->
|
||||
<div class="downloads-content manager-hidden">
|
||||
<div class="downloads-content">
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- == LEFT PANEL: Search, Filters, and Results == -->
|
||||
<!-- == Search page main panel == -->
|
||||
<!-- ======================================================= -->
|
||||
<div class="downloads-main-panel">
|
||||
|
||||
|
|
@ -1859,28 +1853,27 @@
|
|||
<div class="downloads-header">
|
||||
<div class="downloads-header-content">
|
||||
<div class="downloads-header-text">
|
||||
<h2 class="downloads-title"><img src="/static/search.png" class="page-header-icon" alt=""><span>Music Downloads</span></h2>
|
||||
<p class="downloads-subtitle">Search, discover, and download high-quality music</p>
|
||||
<h2 class="downloads-title"><img src="/static/search.png" class="page-header-icon" alt=""><span>Search</span></h2>
|
||||
<p class="downloads-subtitle">Find artists, albums, and tracks from any metadata source</p>
|
||||
</div>
|
||||
<button id="toggle-download-manager-btn" class="toggle-manager-btn"
|
||||
title="Toggle Download Manager">
|
||||
<span class="toggle-icon">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Mode Toggle -->
|
||||
<div class="search-mode-toggle-container">
|
||||
<div class="search-mode-toggle" data-active="enhanced">
|
||||
<button class="search-mode-btn active" data-mode="enhanced">
|
||||
<span class="mode-icon">✨</span>
|
||||
<span class="mode-label">Enhanced Search</span>
|
||||
</button>
|
||||
<button class="search-mode-btn" data-mode="basic">
|
||||
<span class="mode-icon">🔍</span>
|
||||
<span class="mode-label">Basic Search</span>
|
||||
</button>
|
||||
<div class="toggle-slider"></div>
|
||||
<!-- Search Source Picker (replaces Enhanced/Basic toggle) -->
|
||||
<div class="search-source-picker-container">
|
||||
<label for="search-source-select" class="search-source-picker-label">Search from</label>
|
||||
<div class="search-source-picker-wrapper">
|
||||
<select id="search-source-select" class="search-source-picker-select">
|
||||
<option value="auto" selected>All sources (Auto)</option>
|
||||
<option value="spotify">Spotify</option>
|
||||
<option value="itunes">Apple Music</option>
|
||||
<option value="deezer">Deezer</option>
|
||||
<option value="discogs">Discogs</option>
|
||||
<option value="hydrabase">Hydrabase</option>
|
||||
<option value="musicbrainz">MusicBrainz</option>
|
||||
<option value="soulseek">Soulseek (raw files)</option>
|
||||
</select>
|
||||
<span class="search-source-picker-caret">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -2104,187 +2097,9 @@
|
|||
<!-- End Enhanced Search Section -->
|
||||
</div>
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- == RIGHT PANEL: Controls and Download Queue == -->
|
||||
<!-- ======================================================= -->
|
||||
<div class="downloads-side-panel">
|
||||
|
||||
<!-- Controls Panel: Replicates create_collapsible_controls_panel() -->
|
||||
<div class="controls-panel">
|
||||
<h3 class="controls-panel__header">Download Manager</h3>
|
||||
<div class="controls-panel__stats">
|
||||
<p id="active-downloads-label">• Active Downloads: 0</p>
|
||||
<p id="finished-downloads-label">• Finished Downloads: 0</p>
|
||||
</div>
|
||||
<div class="controls-panel__actions">
|
||||
<button class="controls-panel__clear-btn">🗑️ Clear Completed</button>
|
||||
<button class="controls-panel__cancel-all-btn">⛔ Clear Current</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Queue: Replicates TabbedDownloadManager -->
|
||||
<div class="download-manager">
|
||||
<div class="download-manager__tabs">
|
||||
<button class="tab-btn active" data-tab="active-queue">Download Queue (0)</button>
|
||||
<button class="tab-btn" data-tab="finished-queue">Finished (0)</button>
|
||||
</div>
|
||||
<div class="download-manager__content">
|
||||
|
||||
<!-- Active Queue -->
|
||||
<div class="download-queue active" id="active-queue">
|
||||
<div class="download-queue__empty-message">No active downloads.</div>
|
||||
</div>
|
||||
|
||||
<!-- Finished Queue -->
|
||||
<div class="download-queue" id="finished-queue">
|
||||
<div class="download-queue__empty-message">No finished downloads.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artists Page -->
|
||||
<div class="page" id="artists-page">
|
||||
<!-- Initial Search State -->
|
||||
<div class="artists-search-state" id="artists-search-state">
|
||||
<div class="artists-search-container">
|
||||
<div class="artists-welcome-section">
|
||||
<h2 class="artists-welcome-title"><img src="/static/discover.png" class="page-header-icon" alt=""><span>Discover Artists</span></h2>
|
||||
<p class="artists-welcome-subtitle">Search for your favorite artists and explore their
|
||||
complete discography</p>
|
||||
</div>
|
||||
|
||||
<div class="artists-search-input-container">
|
||||
<input type="text" id="artists-search-input" class="artists-search-input"
|
||||
placeholder="Search for an artist...">
|
||||
<div class="artists-search-icon">🔍</div>
|
||||
</div>
|
||||
|
||||
<div class="artists-search-status" id="artists-search-status">
|
||||
Start typing to search for artists
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results State -->
|
||||
<div class="artists-results-state hidden" id="artists-results-state">
|
||||
<div class="artists-results-header">
|
||||
<button class="artists-back-button" id="artists-back-button">
|
||||
<span class="back-icon">←</span>
|
||||
<span>Back to Search</span>
|
||||
</button>
|
||||
|
||||
<div class="artists-search-header">
|
||||
<input type="text" id="artists-header-search-input" class="artists-header-search-input"
|
||||
placeholder="Search for an artist...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artists-results-content">
|
||||
<div class="artists-results-title">Search Results</div>
|
||||
<div class="artists-cards-container" id="artists-cards-container">
|
||||
<!-- Artist cards will be dynamically populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artist Detail State -->
|
||||
<div class="artist-detail-state hidden" id="artist-detail-state">
|
||||
<!-- Hero Section -->
|
||||
<div class="artists-hero-section" id="artists-hero-section">
|
||||
<div class="artists-hero-bg" id="artists-hero-bg"></div>
|
||||
<div class="artists-hero-overlay"></div>
|
||||
<div class="artists-hero-content">
|
||||
<button class="artists-hero-back" id="artist-detail-back-button">
|
||||
<span>←</span> Back
|
||||
</button>
|
||||
<div class="artists-hero-main">
|
||||
<div class="artists-hero-image" id="artists-hero-image"></div>
|
||||
<div class="artists-hero-info">
|
||||
<h1 class="artists-hero-name" id="artists-hero-name">Artist Name</h1>
|
||||
<div class="artists-hero-badges" id="artists-hero-badges"></div>
|
||||
<div class="artists-hero-genres" id="artists-hero-genres"></div>
|
||||
<div class="artists-hero-bio" id="artists-hero-bio"></div>
|
||||
<div class="artists-hero-stats" id="artists-hero-stats"></div>
|
||||
<button class="discog-download-btn discog-btn-compact" id="discog-download-btn-artists" onclick="openDiscographyModal()" style="display:none;">
|
||||
<span class="discog-btn-icon">⬇</span>
|
||||
<span class="discog-btn-text">Download Discography</span>
|
||||
<span class="discog-btn-shimmer"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="artists-hero-actions">
|
||||
<button class="artist-detail-watchlist-btn" id="artist-detail-watchlist-btn">
|
||||
<span class="watchlist-icon">👁️</span>
|
||||
<span class="watchlist-text">Add to Watchlist</span>
|
||||
</button>
|
||||
<button class="artist-detail-watchlist-settings-btn hidden" id="artist-detail-watchlist-settings-btn" title="Watchlist Settings">
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keep old hidden elements for backward compatibility with JS refs -->
|
||||
<div id="search-artist-detail-image" style="display:none"></div>
|
||||
<div id="search-artist-detail-name" style="display:none"></div>
|
||||
<div id="search-artist-detail-genres" style="display:none"></div>
|
||||
|
||||
<div class="artist-detail-content">
|
||||
<div class="artist-detail-tabs">
|
||||
<button class="artist-tab active" data-tab="albums" id="albums-tab">
|
||||
<span class="tab-icon">💿</span>
|
||||
<span>Albums</span>
|
||||
</button>
|
||||
<button class="artist-tab" data-tab="singles" id="singles-tab">
|
||||
<span class="tab-icon">🎵</span>
|
||||
<span>Singles & EPs</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="artist-detail-discography">
|
||||
<div class="tab-content active" id="albums-content">
|
||||
<div class="album-cards-container" id="album-cards-container">
|
||||
<!-- Album cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="singles-content">
|
||||
<div class="singles-cards-container" id="singles-cards-container">
|
||||
<!-- Singles cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Similar Artists Section -->
|
||||
<div class="similar-artists-section" id="similar-artists-section">
|
||||
<div class="similar-artists-header">
|
||||
<h3 class="similar-artists-title">Similar Artists</h3>
|
||||
<p class="similar-artists-subtitle">Discover artists with a similar sound</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="similar-artists-loading hidden" id="similar-artists-loading">
|
||||
<div class="loading-spinner-small"></div>
|
||||
<span>Finding similar artists...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div class="similar-artists-error hidden" id="similar-artists-error">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span class="error-text">Unable to load similar artists</span>
|
||||
</div>
|
||||
|
||||
<!-- Similar Artists Bubbles Container -->
|
||||
<div class="similar-artists-bubbles-container" id="similar-artists-bubbles-container">
|
||||
<!-- Artist bubble cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Automations Page -->
|
||||
<div class="page" id="automations-page">
|
||||
|
|
@ -2542,6 +2357,10 @@
|
|||
|
||||
<!-- Artist Hero Section -->
|
||||
<div class="artist-hero-section" id="artist-hero-section">
|
||||
<!-- Blurred background image + dark overlay (inline-Artists hero treatment) -->
|
||||
<div class="artist-detail-hero-bg" id="artist-detail-hero-bg"></div>
|
||||
<div class="artist-detail-hero-overlay"></div>
|
||||
|
||||
<div class="artist-hero-content">
|
||||
<!-- Left: Image -->
|
||||
<div class="artist-image-container">
|
||||
|
|
@ -2712,6 +2531,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Similar Artists Section (works for both library and source artists).
|
||||
Uses its own scoped IDs because the inline Artists page has a section
|
||||
with the same base IDs and both elements live in the DOM at once. -->
|
||||
<div class="similar-artists-section" id="ad-similar-artists-section">
|
||||
<div class="similar-artists-header">
|
||||
<h3 class="similar-artists-title">Similar Artists</h3>
|
||||
<p class="similar-artists-subtitle">Discover artists with a similar sound</p>
|
||||
</div>
|
||||
<div class="similar-artists-loading hidden" id="ad-similar-artists-loading">
|
||||
<div class="loading-spinner-small"></div>
|
||||
<span>Finding similar artists...</span>
|
||||
</div>
|
||||
<div class="similar-artists-error hidden" id="ad-similar-artists-error">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span class="error-text">Unable to load similar artists</span>
|
||||
</div>
|
||||
<div class="similar-artists-bubbles-container" id="ad-similar-artists-bubbles-container">
|
||||
<!-- Artist bubble cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Library Management View -->
|
||||
<div class="enhanced-view-container hidden" id="enhanced-view-container">
|
||||
<!-- Populated dynamically by renderEnhancedView() -->
|
||||
|
|
@ -6980,8 +6820,8 @@
|
|||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1.5"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</div>
|
||||
<h3>Your watchlist is empty</h3>
|
||||
<p>Add artists from the Artists page to monitor them for new releases.</p>
|
||||
<button class="watchlist-action-btn watchlist-action-primary" onclick="navigateToPage('artists')">Browse Artists</button>
|
||||
<p>Use Search to find an artist, then add them to your watchlist from the artist page.</p>
|
||||
<button class="watchlist-action-btn watchlist-action-primary" onclick="navigateToPage('search')">Open Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -8033,6 +7873,7 @@
|
|||
<script src="{{ url_for('static', filename='setup-wizard.js') }}"></script>
|
||||
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
|
||||
<script src="{{ url_for('static', filename='core.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='shared-helpers.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='media-player.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='settings.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='search.js') }}"></script>
|
||||
|
|
@ -8040,7 +7881,6 @@
|
|||
<script src="{{ url_for('static', filename='downloads.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='wishlist-tools.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-services.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='artists.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='api-monitor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='library.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='beatport-ui.js') }}"></script>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -4639,9 +4613,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
|
||||
|
|
@ -4820,7 +4794,7 @@ async function openYourArtistInfoModal(poolId) {
|
|||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<span>Explore</span>
|
||||
</button>
|
||||
<button class="ya-header-btn ya-viewall-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artistId)}', name:'${escapeForInlineJs(artistName)}', image_url:'${escapeForInlineJs(imageUrl)}'}, {source:'${escapeForInlineJs(pool.active_source || '')}'}), 200)">
|
||||
<button class="ya-header-btn ya-viewall-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); navigateToArtistDetail('${escapeForInlineJs(artistId)}', '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(pool.active_source || '')}' || null)">
|
||||
<span>View Discography</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
|
|
@ -6720,7 +6694,7 @@ function _artMapSetupInteraction(canvas) {
|
|||
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); ${hasId ? `openYourArtistInfoModal_direct(${JSON.stringify(node).replace(/"/g, '"')})` : ''}">
|
||||
<span>ⓘ</span> Artist Info
|
||||
</div>
|
||||
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(bestId)}',name:'${escapeForInlineJs(node.name)}',image_url:'${escapeForInlineJs(node.image_url || '')}'},{source:'${bestSource}'}), 200)">
|
||||
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); navigateToArtistDetail('${escapeForInlineJs(bestId)}', '${escapeForInlineJs(node.name)}', '${bestSource}' || null)">
|
||||
<span>💿</span> View Discography
|
||||
</div>
|
||||
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); toggleYourArtistWatchlist(0,'${escapeForInlineJs(node.name)}','${escapeForInlineJs(bestId)}','${bestSource}',null)">
|
||||
|
|
@ -7408,7 +7382,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 `<div class="genre-dive-artist" ${clickAction}>
|
||||
<div class="genre-dive-artist-img" style="${a.image_url ? `background-image:url('${_esc(a.image_url)}')` : ''}">
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ const DOCS_SECTIONS = [
|
|||
<h3 class="docs-subsection-title">How to: Set Up Auto-Downloads</h3>
|
||||
<p class="docs-text"><strong>Goal:</strong> Automatically download new releases from your favorite artists without manual intervention.</p>
|
||||
<ol class="docs-steps">
|
||||
<li><strong>Add artists to your Watchlist</strong> — Search for artists on the Artists page and click the Watch button on each one</li>
|
||||
<li><strong>Add artists to your Watchlist</strong> — Find artists via the Search page (or click an artist anywhere in the app), then click the Watch button on the artist detail page</li>
|
||||
<li><strong>Go to Automations</strong> — The built-in "Auto-Scan Watchlist" automation checks for new releases every 24 hours</li>
|
||||
<li><strong>Enable "Auto-Process Wishlist"</strong> — This automation picks up new releases found by the scan and downloads them every 30 minutes</li>
|
||||
<li><strong>Done!</strong> — New releases from watched artists are automatically found, queued, downloaded, tagged, and added to your library</li>
|
||||
|
|
|
|||
|
|
@ -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 = `<div class="download-queue__empty-message">${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}</div>`;
|
||||
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 = `
|
||||
<div class="download-item__progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%;"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
${item.state} - ${progress.toFixed(1)}%
|
||||
${speed > 0 ? `• ${formatSpeed(speed)}` : ''}
|
||||
${totalBytes > 0 ? `• ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="download-item__cancel-btn" onclick="cancelDownloadItem('${item.id}', '${item.username}')">✕ Cancel</button>
|
||||
`;
|
||||
} 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 = `
|
||||
<div class="download-item__status-container">
|
||||
<span class="download-item__status-text ${statusClass}">${item.state}</span>
|
||||
</div>
|
||||
<button class="download-item__open-btn" title="Cannot open folder from web browser" disabled>📁 Open</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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 += `
|
||||
<div class="download-item" data-id="${item.id}">
|
||||
<div class="download-item__art">
|
||||
${meta.artwork_url
|
||||
? `<img src="${meta.artwork_url}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'download-item__art-placeholder\\'>♫</div>'">`
|
||||
: '<div class="download-item__art-placeholder">♫</div>'}
|
||||
</div>
|
||||
<div class="download-item__info">
|
||||
<div class="download-item__title" title="${title}">${title}</div>
|
||||
${meta.artist || meta.album ? `
|
||||
<div class="download-item__meta">
|
||||
${meta.artist ? `<span>${escapeHtml(meta.artist)}</span>` : ''}
|
||||
${meta.artist && meta.album ? '<span class="download-item__sep">·</span>' : ''}
|
||||
${meta.album ? `<span class="download-item__album">${escapeHtml(meta.album)}</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="download-item__badges">
|
||||
<span class="download-item__source">${sourceBadge}</span>
|
||||
${meta.quality ? `<span class="download-item__quality">${escapeHtml(meta.quality)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item__content">
|
||||
${actionButtonHTML}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
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')); }
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/<id>/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 = `<span class="completion-status">${overlayLabel}</span>`;
|
||||
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, '<').replace(/>/g, '>');
|
||||
content.innerHTML = `
|
||||
<div class="album-card-name" title="${_esc(release.title)}">${_esc(release.title)}</div>
|
||||
${yearText ? `<div class="album-card-year">${_esc(yearText)}</div>` : ''}
|
||||
`;
|
||||
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 = `<img src="${MUSICBRAINZ_LOGO_URL}" style="width: 20px; height: auto; display: block;">`;
|
||||
|
|
@ -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 = `<div class="release-image-fallback">💿</div>`;
|
||||
};
|
||||
imageContainer.appendChild(img);
|
||||
} else {
|
||||
imageContainer.innerHTML = `<div class="release-image-fallback">💿</div>`;
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
@ -23312,8 +23319,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;
|
||||
}
|
||||
|
||||
|
|
@ -23537,8 +23544,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 {
|
||||
|
|
@ -32832,10 +32839,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;
|
||||
|
|
@ -59312,3 +59385,139 @@ body.reduce-effects *::after {
|
|||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =========================================================================
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue