Compare commits

..

21 commits
main ... dev

Author SHA1 Message Date
BoulderBadgeDad
91912681e5 fix: Deezer source dot red — probe GET-ed a POST-only test endpoint
Some checks failed
Dev Nightly Build / nightly (push) Has been cancelled
The "Test all sources" Deezer probe called _ssJson("/api/deezer-download/test") with no opts, i.e. a
GET — but that route is POST-only, so it 405s, the probe throws, and the dot goes red even though Deezer
downloads fine. POST it with an empty body (the endpoint then tests the SAVED ARL). The other probe
endpoints (qobuz/hifi/amazon/soundcloud status) are all GET, so this was the only offender.
2026-06-29 17:12:42 -07:00
BoulderBadgeDad
598a480a0b fix: Deezer dropped from hybrid + no green light despite working as primary
Deezer download is_configured()/is_available() returned the raw _authenticated flag, but the ARL auth
is LAZY — only is_authenticated() triggers it (post-boot). The primary-source download path calls
is_authenticated() so Deezer auths and works; the hybrid-mode source gate (web_server _resolve sources)
and the green-light status (orchestrator.get_source_status) both read is_configured(), which never fired
the auth — so Deezer looked unauthenticated, got pruned from the hybrid chain, and showed no green dot.

Route is_configured()/is_available() through is_authenticated() so all three agree (and the auth still
defers cleanly during boot). 4 seam tests: lazy-auth fires post-boot, defers during boot, stays True
when already authed, False with no ARL.
2026-06-29 17:07:03 -07:00
BoulderBadgeDad
fcd768ffee discovery: tune the wave dial — green->blue spectrum, calmer waves, subtler orb, refined header
Feedback pass: red read as "bad", so the spectrum is now green -> blue through cyan (deep/exploratory,
not alarming). The orb glow was way too hot — cut it right down and softened the pulse ring + the colour
aura. Dialed the wiggle back (lower amplitude, fewer cycles, slower) so the adventurous end is lively,
not chaotic. Header is now a small uppercase "ADVENTUROUSNESS" overline above the big colour-shifting
state word, instead of the plain bold title.
2026-06-29 16:54:53 -07:00
BoulderBadgeDad
d9bd5c1d7f discovery: elevate the wave dial — full width + glow, luminous fill, pulsing orb, colour aura 2026-06-29 16:48:34 -07:00
BoulderBadgeDad
c50465984f discovery: replace the slider with a living wave dial (animated)
Scrapped the basic slider for a custom animated control matching the vision: a draggable orb rides an
SVG line that is redrawn every frame (rAF). At the left (value 0) the line is green and waves gently —
"artists you already like", alive but calm; drag the orb right and the colour shifts through the warm
spectrum (green -> yellow -> orange -> red) while the wave grows taller and more erratic (a detuned
second harmonic). The orb glows the current colour and rides the wave; the state word recolours too.
Releasing saves (POST /api/discover/adventurousness) and re-fetches both rec rows. The loop skips work
while the page is hidden. Still one source of truth with the Settings slider.

64 script-integrity tests green.
2026-06-29 16:41:12 -07:00
BoulderBadgeDad
43fbab5704 discovery: rebuild the Adventurousness dial — visible track + compact layout
The first cut had two problems (screenshot): the gradient track was invisible (Chromium does not render
the range inputs own background as the track when appearance:none — must style ::-webkit-slider-runnable-
track / ::-moz-range-track) and the grid layout stretched it full-width into a sparse void.

Now: a contained (max-width 880) single-row flex control — icon | title+state | Safe | gradient track |
Adventurous | live %. The gradient (green->accent->orange) renders on the track pseudo-elements, the
thumb is centred on it, and a % readout anchors the right edge. Mobile wraps the slider to its own row.
2026-06-29 16:33:45 -07:00
BoulderBadgeDad
71a48dd344 discovery: fancy Adventurousness dial on the Discover page (synced with Settings)
A prominent control above the rec rows: gradient slider (Safe green -> accent -> hot orange), glowing
grab-thumb, a compass icon that swaps with the level (lifebuoy/compass/dice/crystal-ball) and a live
state word ("Playing it safe" -> "Deep cuts only"). Dragging updates the label live; releasing saves
and immediately re-fetches both rec rows so the effect is instant.

Shares the config key discover.adventurousness with the Settings -> Discovery slider via a new
GET/POST /api/discover/adventurousness endpoint, so the two controls stay in sync (change one, the
other reflects it on next load) — one source of truth, no divergence.

64 script-integrity tests green; route ruff-clean + compiles.
2026-06-29 16:26:50 -07:00
BoulderBadgeDad
c47b36cd6f discovery: apply the adventurousness dial to "Recommended For You" too
The similar-artists route now re-ranks by the same popularity penalty: it derives a score from the
SQL signals (occurrence primary + a small similarity tiebreak) and runs apply_adventurousness with the
discover.adventurousness level. At level 0 the function returns the list unchanged, so the featured-
rotation order is fully preserved — the dial only reshuffles once raised. Popularity is already on the
rows; the temp _adv_score key is stripped before returning. Fail-soft.

Both Discover rec rows now respond to the one global dial.
2026-06-29 16:15:13 -07:00
BoulderBadgeDad
29f84b5601 discovery: add Settings -> Discovery -> Adventurousness slider + align config key
A 0..1 range slider (Safe <-> Adventurous) in Settings -> Discovery with a live value readout. It
auto-saves via the settings page existing range-input listener, persists to config under
discover.adventurousness (matching the /api/settings "discover" section), and re-populates on load.
The listening-recs route reads that key (fixed from discovery. -> discover. to match the settings
section). Drag it up and "Based On Your Listening" leans harder toward obscure picks on the next load.

64 script-integrity tests green.
2026-06-29 16:13:51 -07:00
BoulderBadgeDad
ce8faf0ecc discovery: wire adventurousness into "Based On Your Listening" (live, on existing data)
The listening-recs route now reads discovery.adventurousness (default 0.3) and re-ranks the stored recs
through apply_adventurousness before reshaping — so globally-popular picks sink and obscure ones rise.
Popularity is enriched at REQUEST time via a new MusicDatabase.get_similar_artist_popularities(names)
lookup (the stored recs do not carry popularity inline), so it works on existing data with no re-scan.
Both the lookup and the re-rank are fail-soft — any error leaves the original order. Default 0.3 is a
gentle nudge; the slider to tune it is the next increment.

Core pure function already has 5 seam tests; DB lookup smoke-verified against a temp DB.
2026-06-29 16:08:20 -07:00
BoulderBadgeDad
f0a6d5e696 discovery: add apply_adventurousness — pure popularity-penalty re-rank (aurral-style)
Both Discover rec rows already exclude what you own/watch, so novelty is baked in; the missing lever
is a popularity penalty. apply_adventurousness(items, level) re-ranks dicts (score + optional 0-100
popularity) so globally-popular candidates sink as the dial rises. Pure + reusable across both rec
rows. level<=0 returns the input order unchanged (a copy) — fully additive, no regression; 1.0 applies
the full penalty (a popularity-100 pick loses 70% of its score). Missing popularity is never penalised.

5 seam tests (no-op+copy, demotion, proportional penalty, missing-pop, clamping). 37 pass. Wiring
(scan stores popularity -> routes re-rank live -> Settings slider) is the next increment.
2026-06-29 16:03:16 -07:00
BoulderBadgeDad
052547c04a discover: reorder sections into Spotify-style grouping + blank optional track fields
_reorderDiscoverSections() runs after all loaders and moves each section (incl. ones nested in the
mid-page #discover-bylt-sections) into the intended top-to-bottom order: Genre Explorer -> Your/Year
Mixes -> for-you (listening/recommended/BYLT) -> new (recent/genres/seasonal) -> library (artists/
albums) -> deeper digs (undiscovered/labels/deep cuts) -> stations & tools (Last.fm/ListenBrainz/
Build). Hero + Artist Map stay near the top; hidden collapsed-mix sections are left invisible.

Also: ListenBrainz/Last.fm recording playlists carry no album or duration, so _normalizeTrack now
leaves album blank (not "Unknown Album") and renderCompactPlaylist blanks the duration when 0 (not
"0:00"), matching the original displayListenBrainzTracks behaviour.

64 script-integrity tests green.
2026-06-29 15:51:43 -07:00
BoulderBadgeDad
a236974e43 discover: fix decade/LB tracks showing Unknown — normalizer must fall back to the track itself
_normalizeTrack fell back to {} when there was no track_data_json, so it never read the top-level
name/artists[]/album that decade (and Spotify-shaped) rows carry — every field defaulted to Unknown.
Fall back to the track object itself (|| track), matching _renderTabbedTrackLists trackData = track
behaviour. Flat (track_name) + nested (track_data_json) shapes still resolve.
2026-06-29 15:39:09 -07:00
BoulderBadgeDad
391fc9ae78 discover: fix undefined tracks + give Year/Last.fm/ListenBrainz cards real mosaic covers
Two bugs in the new playlist-card sections:
- "undefined" track rows: those sources nest fields under track.track_data_json (name/artists[]/
  album/duration_ms), but renderCompactPlaylist read flat track.track_name/etc. Added _normalizeTrack
  (handles both shapes) and routed renderCompactPlaylist + the mix-card mosaic through it, so decade /
  Last.fm / ListenBrainz tracks render real titles instead of undefined.
- amateur covers: cards showed a single flat emoji. Now _hydrateMixCovers background-fetches each
  cards tracks, upgrades the placeholder to a real 2x2 album-art mosaic (matching Your Mixes), and
  caches the tracks on the mix so the modal opens instantly. Wired into _renderMixGrid (decades) and
  the ListenBrainz/Last.fm render. Dropped the emoji coverHtml.

64 script-integrity tests green.
2026-06-29 15:26:42 -07:00
BoulderBadgeDad
ec4439da00 discover: Last.fm Radio + ListenBrainz -> mix cards; remove Browse-by-Genre
buildListenBrainzPlaylistsHtml (shared by both sections) now emits a grid of mix cards instead of
full-width track-table subsections — each playlist is a card that opens its tracks (lazy-loaded from
/api/discover/listenbrainz/playlist/{id}) + Download/Sync in the shared modal. ListenBrainz sync uses
its own -sync-total/-sync-matched status spans, so the mix carries a matching statusHtml; the sync
button id (discover-lb-playlist-{id}-sync-btn) lines up so startListenBrainzPlaylistSync drives it.
The old eager loadTracksForPlaylists calls are now harmless no-ops (their -playlist containers are
gone, so loadListenBrainzPlaylistTracks early-returns). Wing-It is omitted from the card modal for now
(its dropdown needs modal-aware positioning).

Browse by Genre removed (section markup + the loadGenreBrowserTabs call): it was empty
(/api/discover/genres/available returns nothing) and redundant with the Genre Explorer pills.

64 script-integrity tests green.
2026-06-29 15:17:07 -07:00
BoulderBadgeDad
153fad152c discover: generalize the mix modal + convert Time Machine to "Year Mixes" cards
Foundation for folding the remaining playlist sections into the mix-card system:
- openMixModal now takes a custom `actions` list (not just the built-in Download/Sync), a `statusBase`
  so any sections live sync lands in the modal, a `pollerKey` for in-flight re-show, and `fetchTracks`
  for lazy sections that load tracks on open.
- _buildMixCard supports a non-mosaic `coverHtml` + `trackCount` (for art-less cards like decades).
- new _renderMixGrid(container, mixes) renders cards into any sections own grid (not the shared shelf).

Time Machine -> "Year Mixes": a shelf of decade cards (gradient cover + "1980s" label); opening one
lazy-loads /api/discover/decade/{year} into the modal (populating decadeTracksCache so Sync works)
with Download/Sync. Tabs removed.

Fix: _upsertMixCard rendered the WHOLE shared registry into Your Mixes, so decades (added by
_renderMixGrid for the modal lookup) leaked in. Your Mixes now tracks its own _yourMixKeys and renders
only those. 64 script-integrity tests green.
2026-06-29 15:09:26 -07:00
BoulderBadgeDad
880ad74a0a discover: convert the last stragglers (bylt + genre tiles) to .ya-card
Because You Listen To rows now render .ya-card grids (with the Show-all clamp) instead of the old
discover-card carousels. The genre-browser tiles (_renderGenreCard) are converted too for consistency
— that loader is currently dead code (loadGenreBrowser is never called; only the tabbed Browse-by-Genre
modal runs), so this just future-proofs it. With this, no discover-card markup remains in discover.js
— the whole Discover page is one card language (.ya-card + the mix cards), leaving only the Genre
Explorer pills + Artist Map as the intentional splash elements.

64 script-integrity tests green.
2026-06-29 14:39:28 -07:00
BoulderBadgeDad
bf8e8beeb7 discover: convert the two artist sections to the .ya-card grid
Based On Your Listening + Recommended For You now use the same .ya-card as the rest (square cover,
name overlaid, recommendation reason as the subtitle) in wrapping grids with the Show-all clamp,
replacing the bespoke recommended-artist-card layout. The watchlist toggle and live image enrichment
are preserved by keeping their hooks on the new markup — .recommended-artist-card[data-artist-id],
.recommended-card-image, and .recommended-card-watchlist-btn (restyled as a hover-reveal corner pill
that stays lit when watching). _clampGrid wired into both onRendered callbacks.

64 script-integrity tests green.
2026-06-29 14:32:16 -07:00
BoulderBadgeDad
4cab44889c discover: unify album sections onto the .ya-card grid + Show-all clamp
Step 2 of the makeover — the album/release sections now use the same .ya-card as Your Artists
(square cover so art is not cropped, name overlaid, artist beneath) in responsive wrapping grids,
replacing the old spotify-library-card / discover-card / carousels:
- Recent Releases, Your Albums (owned/missing badge kept), Undiscovered Albums, New In Your Genres,
  From Your Labels (one shared _cacheDiscoverCard), Seasonal Albums.
- _insertCacheSection gained a wrapGrid flag; Genre Explorer opts out (wrapGrid=false) so its pill
  layout is not squashed into the card grid (fixes the regression where it broke under the hero).
- new _clampGrid caps a grid at ~12 cards with a Show all (N) / Show less toggle, so a 30-item
  section is not a wall; wired into the cache sections + Recent Releases + Seasonal Albums.

64 script-integrity tests green. Next: the two artist sections (Based On Your Listening / Recommended
For You) need their watchlist + live-enrichment hooks moved before they can switch to .ya-card.
2026-06-29 14:21:52 -07:00
BoulderBadgeDad
9c832ab3c3 discover: fold the remaining mixes into the "Your Mixes" shelf
Fresh Tape (release_radar), The Archives (discovery_weekly), and the Seasonal/Summer Mix
(seasonal_playlist) move out of their old full-width compact-track-table sections into the unified
shelf as mix cards (set the global in the controller renderItems, then _upsertMixCard + collapse the
old section after load). Daily Mixes loader is converted the same way (each mix → a card) for if/when
that HIDDEN feature is re-enabled. So every active mix now lives in one consistent shelf.

64 script-integrity tests green.
2026-06-29 13:56:27 -07:00
BoulderBadgeDad
9d5e58c4b8 discover: "Your Mixes" shelf — mixes are cards that open to a track list (Spotify-style)
Reworks the Discover page mixes from full-width compact-track tables into a consistent shelf of
playlist cards, the way Spotify Home does it. Each mix is ONE card (a 2x2 mosaic cover from its top
tracks + name/count, hover play button); clicking it opens a modal with the track list + the mix
actions — the track table now lives where a track list belongs (inside the opened mix).

- new "Your Mixes" section + .discover-mix-card / .discover-mixes-grid + .mix-modal styles.
- _buildMixCard / _upsertMixCard / openMixModal + a mix registry; the four personalized track-mix
  loaders (Listening Mix, Popular Picks, Hidden Gems, Discovery Shuffle) collapse their old table
  section into a card via _collapseOldMixSection (which also strips the old section's duplicate
  sync ids so the modal owns the live status).
- modal actions use the standard btn--secondary/btn--primary; Download closes the mix modal first
  so its own modal is interactable; Sync shows live progress IN the modal and survives close/re-open
  (detects an in-flight sync via discoverSyncPollers and re-reveals the status).

64 script-integrity tests green. Next: fold the remaining mixes (Fresh Tape / The Archives /
Seasonal / Daily Mixes) into the same shelf, then unify the album/genre sections onto the card.
2026-06-29 13:40:34 -07:00
10 changed files with 1220 additions and 247 deletions

View file

@ -227,10 +227,14 @@ class DeezerDownloadClient(DownloadSourcePlugin):
self.shutdown_check = check_callable
def is_configured(self) -> bool:
return self._authenticated
# Go through is_authenticated() so the lazy ARL auth fires (post-boot) — otherwise the raw
# _authenticated flag is still False until something else triggers it, and the hybrid-mode
# gate + the green-light status (both read is_configured) silently drop Deezer even though it
# downloads fine as a primary source (that path calls is_authenticated). Keeps the three in sync.
return self.is_authenticated()
def is_available(self) -> bool:
return self._authenticated
return self.is_authenticated()
def is_authenticated(self) -> bool:
if self._pending_arl and not self._authenticated:

View file

@ -39,6 +39,14 @@ def _positive_float(value: object, default: float = 1.0) -> float:
return f if f > 0 else default
def _coerce_float(value: object, default: float = 0.0) -> float:
"""Plain float coercion that keeps 0 / negatives (unlike _positive_float) — popularity can be 0."""
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
def _get(row: object, attr: str):
"""Read a field from a dataclass row or a dict row."""
if isinstance(row, dict):
@ -234,6 +242,46 @@ def rank_recommended_artists(
return out[:limit]
# ── Adventurousness re-rank (aurral-style popularity penalty) ────────────────────────────────
# Both Discover rec rows already EXCLUDE what you own / watch, so "novelty" is baked in; the lever we
# were missing is a POPULARITY PENALTY. At higher adventurousness, globally-popular candidates are
# pushed down so the obscure / non-obvious picks surface. Pure + reusable across both rec rows.
_MAX_POP_PENALTY = 0.7 # at level 1.0 a popularity-100 candidate loses 70% of its score
def apply_adventurousness(
items: Sequence[dict],
level: object,
*,
score_key: str = "score",
pop_key: str = "popularity",
tiebreak_key: str = "seed_count",
) -> List[dict]:
"""Re-rank ``items`` (dicts with a numeric score + an optional 0100 popularity) by an
adventurousness-scaled popularity penalty. Returns a NEW list, most-adventurous first.
``level`` is clamped to 0..1. At ``level <= 0`` the input order is returned **unchanged** (a
copy), so the feature is fully additive / no-regression. Items missing a popularity are never
penalised. Adjusted score = ``score × (1 level × MAX_POP_PENALTY × popularity/100)``.
"""
lvl = max(0.0, min(1.0, _coerce_float(level, 0.0)))
if lvl <= 0.0:
return list(items)
def _adjusted(it: object) -> float:
score = _coerce_float(_get(it, score_key), 0.0)
pop = _get(it, pop_key)
if pop is None:
return score
pop_norm = max(0.0, min(1.0, _coerce_float(pop, 0.0) / 100.0))
return score * (1.0 - lvl * _MAX_POP_PENALTY * pop_norm)
return sorted(
items,
key=lambda it: (-_adjusted(it), -_coerce_float(_get(it, tiebreak_key), 0.0), _norm(_get(it, "name"))),
)
def aggregate_candidate_tracks(
recommended_artists: Sequence[RecommendedArtist],
top_tracks_by_artist: Dict[str, Sequence[dict]],

View file

@ -10515,6 +10515,32 @@ class MusicDatabase:
logger.error(f"Error checking similar artists freshness: {e}")
return False # Default to re-fetching on error
def get_similar_artist_popularities(self, names, profile_id: int = 1):
"""Map lowercased artist name -> max stored popularity (0-100) from ``similar_artists`` for
the given profile. Lets the Discover routes apply the adventurousness popularity-penalty at
request time (the stored listening-recs don't carry popularity inline). Fail-soft -> {}."""
out = {}
clean = [str(n).strip().lower() for n in (names or []) if str(n or '').strip()]
if not clean:
return out
try:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholders = ','.join('?' for _ in clean)
cursor.execute(
f"""SELECT LOWER(similar_artist_name) AS n, MAX(popularity) AS pop
FROM similar_artists
WHERE profile_id = ? AND LOWER(similar_artist_name) IN ({placeholders})
GROUP BY LOWER(similar_artist_name)""",
[profile_id] + clean,
)
for row in cursor.fetchall():
if row['pop'] is not None:
out[row['n']] = row['pop']
except Exception as e:
logger.debug(f"get_similar_artist_popularities failed: {e}")
return out
def get_top_similar_artists(
self,
limit: int = 50,

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from core.discovery.listening_recommendations import (
aggregate_candidate_tracks,
apply_adventurousness,
build_recency_weighted_seeds,
choose_mix_fetch_source,
names_match,
@ -314,3 +315,40 @@ def test_rank_threading_changes_winner_within_a_seed():
ranked = rank_recommended_artists(seeds, grouped)
assert [r.name for r in ranked] == ["Close", "Far"]
assert ranked[0].score > ranked[1].score
# ── apply_adventurousness (aurral-style popularity-penalty re-rank) ───────────
def test_adventurousness_zero_is_noop_but_copies():
items = [{"name": "A", "score": 5.0, "popularity": 90},
{"name": "B", "score": 4.0, "popularity": 10}]
out = apply_adventurousness(items, 0.0)
assert [i["name"] for i in out] == ["A", "B"] # order unchanged
assert out == items # same content
assert out is not items # but a fresh list (additive)
def test_adventurousness_demotes_the_popular_one():
# Same score; at full adventurousness the obscure pick (pop 10) overtakes the giant (pop 95).
items = [{"name": "Giant", "score": 5.0, "popularity": 95},
{"name": "Obscure", "score": 5.0, "popularity": 10}]
assert [i["name"] for i in apply_adventurousness(items, 1.0)] == ["Obscure", "Giant"]
def test_adventurousness_penalty_is_proportional_not_absolute():
# A much stronger score still wins despite being more popular — the penalty scales the score.
items = [{"name": "StrongPopular", "score": 10.0, "popularity": 80},
{"name": "WeakObscure", "score": 1.0, "popularity": 0}]
assert apply_adventurousness(items, 0.5)[0]["name"] == "StrongPopular"
def test_adventurousness_missing_popularity_is_unpenalized():
items = [{"name": "Popular", "score": 5.0, "popularity": 100},
{"name": "NoPop", "score": 5.0}]
assert apply_adventurousness(items, 1.0)[0]["name"] == "NoPop"
def test_adventurousness_clamps_level():
items = [{"name": "A", "score": 5.0, "popularity": 100},
{"name": "B", "score": 5.0, "popularity": 0}]
assert [i["name"] for i in apply_adventurousness(items, 5.0)] == ["B", "A"] # >1 clamps to 1
assert [i["name"] for i in apply_adventurousness(items, -2.0)] == ["A", "B"] # <0 clamps to 0 (no-op)

View file

@ -0,0 +1,66 @@
"""Regression: Deezer download client `is_configured()` must fire the lazy ARL auth.
The hybrid-mode source gate + the green-light status both read `is_configured()`. It used to return
the raw `_authenticated` flag, which stays False until something calls `is_authenticated()` (the lazy
ARL auth). So Deezer downloaded fine as a *primary* source (that path auths) but was silently dropped
from a hybrid chain and showed no green light. The fix routes is_configured()/is_available() through
is_authenticated() so the three can't drift.
"""
from __future__ import annotations
import core.boot_phase as boot
from core.deezer_download_client import DeezerDownloadClient
def _bare_client(authenticated=False, pending_arl=None):
"""A client without __init__ (no config/network) — just the auth-state seam we're testing."""
c = DeezerDownloadClient.__new__(DeezerDownloadClient)
c._authenticated = authenticated
c._pending_arl = pending_arl
return c
def test_is_configured_fires_lazy_arl_auth_post_boot(monkeypatch):
monkeypatch.setattr(boot, "is_boot_phase", lambda: False)
c = _bare_client(authenticated=False, pending_arl="fake-arl")
calls = []
def fake_auth(arl):
calls.append(arl)
c._authenticated = True
c._authenticate = fake_auth
# The hybrid gate / green light call this — it must trigger the deferred auth, not report False.
assert c.is_configured() is True
assert c.is_available() is True
assert calls == ["fake-arl"]
assert c._pending_arl is None
# Idempotent: once authed, no repeat network auth.
assert c.is_configured() is True
assert calls == ["fake-arl"]
def test_is_configured_defers_during_boot(monkeypatch):
monkeypatch.setattr(boot, "is_boot_phase", lambda: True)
c = _bare_client(authenticated=False, pending_arl="fake-arl")
def boom(arl):
raise AssertionError("must not authenticate (network) during boot")
c._authenticate = boom
assert c.is_configured() is False # deferred — stays unauthenticated, but never auths mid-boot
def test_is_configured_true_when_already_authed():
c = _bare_client(authenticated=True, pending_arl=None)
assert c.is_configured() is True
assert c.is_available() is True
def test_is_configured_false_with_no_arl(monkeypatch):
monkeypatch.setattr(boot, "is_boot_phase", lambda: False)
c = _bare_client(authenticated=False, pending_arl=None) # no ARL configured at all
assert c.is_configured() is False

View file

@ -29565,6 +29565,28 @@ def get_discover_similar_artists():
artist_data["because"] = because
result_artists.append(artist_data)
# Adventurousness re-rank (aurral-style): push globally-popular picks down per the user's dial.
# Derive a score from the SQL signals (occurrence primary, similarity a minor tiebreak); at
# level 0 apply_adventurousness returns the list unchanged, so the featured-rotation order is
# fully preserved. Fail-soft. The frontend shows the top slice, so the obscure ones surface.
try:
_adv_level = float(config_manager.get('discover.adventurousness', 0.3) or 0)
except (TypeError, ValueError):
_adv_level = 0.0
if _adv_level > 0 and result_artists:
try:
for a in result_artists:
_oc = float(a.get('occurrence_count') or 0)
_rank = min(float(a.get('similarity_rank') or 10), 10.0)
a['_adv_score'] = _oc + (10.0 - _rank) * 0.1
from core.discovery.listening_recommendations import apply_adventurousness
result_artists = apply_adventurousness(
result_artists, _adv_level, score_key='_adv_score', tiebreak_key='occurrence_count')
for a in result_artists:
a.pop('_adv_score', None)
except Exception as _adv_err:
logger.debug(f"similar-artists adventurousness re-rank skipped: {_adv_err}")
logger.info(
f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for "
f"{active_source} after excluding {active_server} library artists"
@ -29582,6 +29604,31 @@ def get_discover_similar_artists():
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/adventurousness', methods=['GET', 'POST'])
def discover_adventurousness():
"""Get/set the global Discover adventurousness dial (0..1). Shares the config key
``discover.adventurousness`` with the Settings -> Discovery slider, so the two controls stay in
sync (change one, the other reflects it on next load). Read-only-safe; clamps to [0, 1]."""
try:
if request.method == 'POST':
data = request.get_json(silent=True) or {}
try:
v = float(data.get('value'))
except (TypeError, ValueError):
return jsonify({"success": False, "error": "value must be a number"}), 400
v = max(0.0, min(1.0, v))
config_manager.set('discover.adventurousness', v)
return jsonify({"success": True, "value": v})
try:
v = float(config_manager.get('discover.adventurousness', 0.3) or 0)
except (TypeError, ValueError):
v = 0.3
return jsonify({"success": True, "value": max(0.0, min(1.0, v))})
except Exception as e:
logger.error(f"adventurousness endpoint error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/listening-recommendations', methods=['GET'])
def get_discover_listening_recommendations():
"""#913: artists you'd love based on what you actually LISTEN to (play-weighted).
@ -29604,6 +29651,24 @@ def get_discover_listening_recommendations():
except (ValueError, TypeError):
stored = []
# Adventurousness re-rank (aurral-style): enrich popularity at request time (the stored recs
# don't carry it inline, so this works on existing data), then push globally-popular picks
# down per the user's dial. level 0 -> unchanged. Fail-soft: any hiccup leaves the order.
try:
level = float(config_manager.get('discover.adventurousness', 0.3) or 0)
except (TypeError, ValueError):
level = 0.0
if level > 0 and stored:
try:
pops = database.get_similar_artist_popularities([a.get('name') for a in stored])
for a in stored:
if a.get('popularity') is None:
a['popularity'] = pops.get((a.get('name') or '').strip().lower())
from core.discovery.listening_recommendations import apply_adventurousness
stored = apply_adventurousness(stored, level)
except Exception as _adv_err:
logger.debug(f"adventurousness re-rank skipped: {_adv_err}")
result_artists = []
for a in stored:
name = a.get('name')

View file

@ -3146,6 +3146,35 @@
<div class="artist-map-search-results" id="artist-map-search-results"></div>
</div>
<!-- Discovery Adventurousness — living wave dial. Drag the orb; the line waves
calmer/greener at left (safe) and wilder/redder at right (obscure). Syncs with
Settings → Discovery. -->
<div class="adv-wave" id="adv-wave"
title="Pushes globally-popular artists down so more obscure picks surface in your recommendations">
<div class="adv-wave-head">
<span class="adv-wave-label">Adventurousness</span>
<span class="adv-wave-state" id="adv-wave-state">Balanced</span>
</div>
<div class="adv-wave-track" id="adv-wave-track">
<div class="adv-wave-aura" id="adv-wave-aura"></div>
<svg class="adv-wave-svg" id="adv-wave-svg" viewBox="0 0 1000 80" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="adv-wave-fill" x1="0" y1="0" x2="0" y2="1">
<stop id="adv-wave-fill-top" offset="0" stop-color="#1DB954" stop-opacity="0.32"/>
<stop offset="1" stop-color="#1DB954" stop-opacity="0"/>
</linearGradient>
</defs>
<path id="adv-wave-area" fill="url(#adv-wave-fill)" stroke="none"></path>
<path id="adv-wave-path" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<div class="adv-wave-orb" id="adv-wave-orb"></div>
</div>
<div class="adv-wave-ends">
<span>Safe — artists you already like</span>
<span>Adventurous — deep cuts</span>
</div>
</div>
<!-- Recommended For You Section (similar-artists graph) -->
<!-- #913: listening-driven recommendations (play-weighted, consensus-ranked) -->
<div class="discover-section" id="listening-recs-section" style="display: none;">
@ -3155,12 +3184,24 @@
<p class="discover-section-subtitle">Artists you'd love — ranked from who you actually play the most</p>
</div>
</div>
<div class="discover-carousel" id="listening-recs-carousel">
<div class="discover-grid" id="listening-recs-carousel">
<!-- Populated by JS -->
</div>
</div>
<!-- #913: Listening Mix — a playable track mix from those recommended artists -->
<!-- Your Mixes — Spotify-style shelf: each mix is ONE card (mosaic cover),
click opens the track list + actions in a modal (#discover redesign). -->
<div class="discover-section" id="your-mixes-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Your Mixes</h2>
<p class="discover-section-subtitle">Fresh playlists built from your listening — open one to see the tracks</p>
</div>
</div>
<div class="discover-mixes-grid" id="your-mixes-grid"></div>
</div>
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<div>
@ -3207,7 +3248,7 @@
</button>
</div>
</div>
<div class="discover-carousel" id="recommended-artists-carousel">
<div class="discover-grid" id="recommended-artists-carousel">
<!-- Populated by JS -->
</div>
</div>
@ -3271,7 +3312,7 @@
<option value="recent">Date Added</option>
</select>
</div>
<div class="spotify-library-grid" id="your-albums-grid">
<div class="discover-grid" id="your-albums-grid">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading your albums...</p>
@ -3286,7 +3327,7 @@
<h2 class="discover-section-title">Recent Releases</h2>
<p class="discover-section-subtitle">New music from artists you follow</p>
</div>
<div class="discover-carousel" id="recent-releases-carousel">
<div class="discover-grid" id="recent-releases-carousel">
<!-- Content will be populated dynamically -->
<div class="discover-loading">
<div class="loading-spinner"></div>
@ -3301,7 +3342,7 @@
<h2 class="discover-section-title" id="seasonal-albums-title">Seasonal</h2>
<p class="discover-section-subtitle" id="seasonal-albums-subtitle">Seasonal music</p>
</div>
<div class="discover-carousel" id="seasonal-albums-carousel">
<div class="discover-grid" id="seasonal-albums-carousel">
<!-- Content will be populated dynamically -->
</div>
</div>
@ -3769,43 +3810,24 @@
</div>
</div>
<!-- Time Machine (Tabbed by Decade) -->
<div class="discover-section">
<!-- Year Mixes (decade mix cards — same setup as Your Mixes) -->
<div class="discover-section" id="year-mixes-section">
<div class="discover-section-header">
<h2 class="discover-section-title">⏰ Time Machine</h2>
<p class="discover-section-subtitle">Explore music from different decades</p>
</div>
<!-- Decade Tabs (will be populated dynamically) -->
<div class="decade-tabs" id="decade-tabs">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading decades...</p>
<div>
<h2 class="discover-section-title">Year Mixes</h2>
<p class="discover-section-subtitle">Jump into the sound of a decade — open one to see the tracks</p>
</div>
</div>
<!-- Decade Tabs (hidden — replaced by the decade mix-card grid below) -->
<div class="decade-tabs" id="decade-tabs" style="display:none"></div>
<!-- Decade Tab Contents (will be populated dynamically) -->
<div id="decade-tab-contents"></div>
</div>
<!-- Browse by Genre (Tabbed by Genre) -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">🎵 Browse by Genre</h2>
<p class="discover-section-subtitle">Discover music by your favorite genres</p>
</div>
<!-- Genre Tabs (will be populated dynamically) -->
<div class="genre-tabs" id="genre-tabs">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading genres...</p>
</div>
</div>
<!-- Genre Tab Contents (will be populated dynamically) -->
<div id="genre-tab-contents"></div>
</div>
<!-- Browse by Genre removed (#discover redesign): it was empty (no data from
/api/discover/genres/available) and redundant with the Genre Explorer pills. -->
</div>
</div>
@ -6208,6 +6230,30 @@
</div>
</div><!-- end Listening Stats body -->
<!-- Discovery (collapsible tile) -->
<div class="settings-section-header collapsed" data-stg="library" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
<span class="settings-section-arrow">&#9660;</span>
<h3>Discovery</h3>
<span class="settings-section-hint">How adventurous your recommendations are</span>
</div>
<div class="settings-section-body collapsed" data-stg="library" style="display:none">
<div class="settings-group" data-stg="library">
<div class="form-group">
<label>Adventurousness</label>
<div style="display:flex; align-items:center; gap:12px; margin-top:8px">
<span style="font-size:12px; color:#999">Safe</span>
<input type="range" id="discover-adventurousness" min="0" max="1" step="0.05" value="0.3" style="flex:1"
oninput="var v=document.getElementById('discover-adventurousness-val'); if(v) v.textContent=parseFloat(this.value).toFixed(2)">
<span style="font-size:12px; color:#999">Adventurous</span>
<span id="discover-adventurousness-val" style="min-width:36px; text-align:right; font-weight:700; color:var(--accent)">0.30</span>
</div>
<div class="help-text setting-help-body" style="margin-top:10px">
Higher pushes globally-popular artists down so more obscure, non-obvious picks surface; lower keeps recommendations safe and close to what you already know. Affects <strong>Based On Your Listening</strong>.
</div>
</div>
</div>
</div><!-- end Discovery body -->
<!-- Security & Access (collapsible tile) -->
<div class="settings-section-header collapsed" data-stg="advanced" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
<span class="settings-section-arrow">&#9660;</span>

File diff suppressed because it is too large Load diff

View file

@ -724,7 +724,9 @@ const HYBRID_SOURCE_PROBE = {
tidal: () => _ssTestConn('tidal'),
qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true),
hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true),
deezer_dl: () => _ssJson('/api/deezer-download/test').then(j => j.success === true),
// POST (the endpoint is POST-only) with an empty body so it tests the SAVED ARL; a GET here
// 405s and the probe throws -> the dot goes red even though Deezer downloads fine.
deezer_dl: () => _ssJson('/api/deezer-download/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(j => j.success === true),
amazon: () => _ssJson('/api/amazon/test-connection').then(j => j.connected === true),
lidarr: () => _ssTestConn('lidarr'),
soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true),
@ -1480,6 +1482,13 @@ async function loadSettingsData() {
// Populate Listening Stats settings
document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true;
document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30;
const _advEl = document.getElementById('discover-adventurousness');
if (_advEl) {
const _adv = settings.discover?.adventurousness;
_advEl.value = (typeof _adv === 'number') ? _adv : 0.3;
const _advVal = document.getElementById('discover-adventurousness-val');
if (_advVal) _advVal.textContent = parseFloat(_advEl.value).toFixed(2);
}
document.getElementById('lossy-copy-options').style.display =
settings.lossy_copy?.enabled ? 'block' : 'none';
@ -3495,6 +3504,13 @@ async function saveSettings(quiet = false) {
enabled: document.getElementById('listening-stats-enabled').checked,
poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30,
},
discover: {
// Adventurousness dial (0 safe .. 1 obscure) — drives the Discover popularity penalty.
// Use the slider's value directly (0 is valid; don't `|| 0.3` it away).
adventurousness: document.getElementById('discover-adventurousness')
? parseFloat(document.getElementById('discover-adventurousness').value)
: 0.3,
},
m3u_export: {
enabled: document.getElementById('m3u-export-enabled').checked,
entry_base_path: document.getElementById('m3u-entry-base-path').value || ''

View file

@ -35006,6 +35006,236 @@ div.artist-hero-badge {
}
.ya-card-name:hover { color: var(--accent); }
/* Your Mixes (Spotify-style playlist cards, #discover redesign)
Each mix is ONE card: a 2x2 mosaic cover from its top tracks + name below, with
a hover play button. Click opens the track list in a modal. Replaces the old
full-width compact-track tables. */
.discover-mixes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 18px;
}
.discover-mix-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px;
padding: 12px;
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.4,0,0.2,1), border-color 0.3s, box-shadow 0.3s, background 0.3s;
}
.discover-mix-card:hover {
transform: translateY(-5px);
background: rgba(255,255,255,0.06);
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);
}
.mix-card-cover {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
box-shadow: 0 6px 20px rgba(0,0,0,0.4);
}
.mix-card-tile {
background-size: cover;
background-position: center;
background-color: #1a1a1a;
}
.mix-card-play {
position: absolute;
bottom: 10px; right: 10px;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--accent);
color: #000;
display: flex; align-items: center; justify-content: center;
font-size: 15px; padding-left: 2px;
box-shadow: 0 6px 16px rgba(0,0,0,0.45);
opacity: 0; transform: translateY(8px);
transition: opacity 0.25s, transform 0.25s;
z-index: 2;
}
.discover-mix-card:hover .mix-card-play {
opacity: 1; transform: translateY(0);
}
.mix-card-name {
font-size: 14px; font-weight: 700; color: #fff;
margin-top: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mix-card-meta {
font-size: 12px; color: rgba(255,255,255,0.5);
margin-top: 3px;
}
/* Solid/gradient cover for mixes with no per-track art (e.g. decades). */
.mix-card-cover--solid {
background: linear-gradient(135deg, rgba(var(--accent-rgb),0.42), rgba(var(--accent-rgb),0.10));
display: flex; align-items: center; justify-content: center;
}
.mix-card-decade {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 4px; color: #fff; text-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.mix-card-decade-icon { font-size: 34px; line-height: 1; }
.mix-card-decade-label { font-size: 22px; font-weight: 800; letter-spacing: 0.5px; }
/* The mix modal — opened from a mix card; holds the track list + per-mix actions. */
.mix-modal {
background: #14141c; border-radius: 18px;
width: 760px; max-width: 94vw; max-height: 86vh;
display: flex; flex-direction: column;
box-shadow: 0 24px 70px rgba(0,0,0,0.6);
border: 1px solid rgba(255,255,255,0.08);
}
.mix-modal-header {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 22px 24px; gap: 16px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.mix-modal-subtitle { font-size: 12px; color: #999; }
.mix-modal-title { font-size: 24px; font-weight: 700; color: #fff; margin: 4px 0; }
.mix-modal-meta { font-size: 12px; color: rgba(255,255,255,0.5); }
.mix-modal-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.mix-modal-close {
background: none; border: none; color: rgba(255,255,255,0.5);
font-size: 16px; cursor: pointer; padding: 6px; line-height: 1;
}
.mix-modal-close:hover { color: #fff; }
.mix-modal-body { overflow-y: auto; padding: 16px 24px 24px; }
/* Unified Discover card grid (#discover redesign)
One responsive wrapping grid (fills the section width, wraps to rows no
horizontal scroll) holding the same .ya-card the Your Artists section uses, so
every album/artist section reads as one design. */
.discover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
.discover-grid .ya-card { width: 100%; }
/* Albums are square, so don't crop the cover with the artist card's 0.85 portrait ratio. */
.discover-grid .discover-album-card.ya-card { aspect-ratio: 1; }
/* Genre cards: no cover art — square gradient tile with the genre emoji centered. */
.discover-grid .discover-genre-card.ya-card { aspect-ratio: 1; }
.discover-genre-card .genre-card-art {
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, rgba(var(--accent-rgb),0.38), rgba(var(--accent-rgb),0.07));
}
.discover-genre-card .genre-icon-large { font-size: 46px; line-height: 1; }
/* Secondary line under the title (album → artist). */
.ya-card-sub {
font-size: 11px; font-weight: 500;
color: rgba(255,255,255,0.72);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
margin-top: 2px;
}
/* Owned / missing indicator on album cards (corner badge). */
.discover-album-badge {
width: 20px; height: 20px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700;
box-shadow: 0 2px 6px rgba(0,0,0,0.45);
}
.discover-album-badge.owned { background: #1DB954; color: #000; }
.discover-album-badge.missing {
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85);
border: 1px solid rgba(255,255,255,0.2);
}
/* Show all / Show less toggle under a clamped discover-grid. */
.discover-show-all {
margin: 14px auto 0;
display: block;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
color: rgba(255,255,255,0.85);
font-size: 13px; font-weight: 600;
padding: 8px 20px; border-radius: 20px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.discover-show-all:hover {
background: rgba(var(--accent-rgb),0.15);
border-color: rgba(var(--accent-rgb),0.4);
color: #fff;
}
/* Adventurousness living wave dial (Discover page)
A draggable orb rides an animated line that waves calmer + greener at the left (safe)
and wilder + redder at the right (obscure). The path + colours are drawn each frame in
JS (rAF). Syncs with Settings Discovery. */
.adv-wave {
position: relative;
margin: 0 0 28px;
padding: 18px 26px 16px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.012));
border: 1px solid rgba(255,255,255,0.07);
box-shadow: 0 10px 34px rgba(0,0,0,0.30);
overflow: hidden; /* contain the colour aura wash */
}
.adv-wave-head { display: flex; flex-direction: column; gap: 1px; position: relative; z-index: 2; }
.adv-wave-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 2.5px; color: rgba(255,255,255,0.38); }
.adv-wave-state { font-size: 21px; font-weight: 800; letter-spacing: -0.3px; line-height: 1.15; transition: color 0.25s; }
.adv-wave-track {
position: relative; height: 104px; margin: 6px 0 2px;
cursor: grab; touch-action: none;
}
.adv-wave-track:active { cursor: grabbing; }
.adv-wave-aura {
position: absolute; top: 50%; left: 30%;
width: 360px; height: 230px; transform: translate(-50%, -50%);
pointer-events: none; filter: blur(14px); z-index: 0;
will-change: left, background;
}
.adv-wave-svg { width: 100%; height: 100%; display: block; overflow: visible; position: relative; z-index: 1; }
.adv-wave-orb {
position: absolute; top: 50%; left: 30%; z-index: 2;
width: 22px; height: 22px; border-radius: 50%;
transform: translate(-50%, -50%);
background: #1DB954; color: #1DB954;
box-shadow: 0 0 9px 0 #1DB954, inset 0 0 0 2px rgba(255,255,255,0.5);
pointer-events: none; /* the track captures the pointer; the orb is purely visual */
will-change: left, top;
}
.adv-wave-orb::after {
content: ''; position: absolute; inset: -5px; border-radius: 50%;
border: 1.5px solid currentColor; opacity: 0.4;
animation: adv-orb-pulse 2.8s ease-out infinite;
}
@keyframes adv-orb-pulse {
0% { transform: scale(0.75); opacity: 0.4; }
100% { transform: scale(1.5); opacity: 0; }
}
.adv-wave-ends {
display: flex; justify-content: space-between;
font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.35);
position: relative; z-index: 2;
}
/* Watchlist toggle on recommended-artist .ya-cards corner pill, appears on hover,
stays lit when already watching. Keeps the .recommended-card-watchlist-btn hook. */
.recommended-artist-card .recommended-card-watchlist-btn {
position: absolute; top: 8px; right: 8px; z-index: 3;
background: rgba(0,0,0,0.72); color: #fff;
border: 1px solid rgba(255,255,255,0.25);
border-radius: 14px; padding: 4px 10px;
font-size: 10px; font-weight: 600; cursor: pointer;
opacity: 0; transform: translateY(-4px);
transition: opacity 0.2s, transform 0.2s, background 0.2s, border-color 0.2s;
}
.recommended-artist-card:hover .recommended-card-watchlist-btn { opacity: 1; transform: none; }
.recommended-artist-card .recommended-card-watchlist-btn:hover {
background: var(--accent); color: #000; border-color: transparent;
}
.recommended-artist-card .recommended-card-watchlist-btn.watching {
opacity: 1; transform: none;
background: var(--accent); color: #000; border-color: transparent;
}
/* ── Artist Info Modal ── */
.ya-info-modal {
background: #141420; border-radius: 18px;