Compare commits

..

2 commits
dev ... main

Author SHA1 Message Date
BoulderBadgeDad
3ab9301a18
Merge pull request #951 from Nezreka/dev
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Dev
2026-06-29 11:24:23 -07:00
BoulderBadgeDad
5d5aac9be3
Merge pull request #946 from Nezreka/dev
Dev
2026-06-28 23:35:05 -07:00
10 changed files with 247 additions and 1220 deletions

View file

@ -227,14 +227,10 @@ class DeezerDownloadClient(DownloadSourcePlugin):
self.shutdown_check = check_callable self.shutdown_check = check_callable
def is_configured(self) -> bool: def is_configured(self) -> bool:
# Go through is_authenticated() so the lazy ARL auth fires (post-boot) — otherwise the raw return self._authenticated
# _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: def is_available(self) -> bool:
return self.is_authenticated() return self._authenticated
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
if self._pending_arl and not self._authenticated: if self._pending_arl and not self._authenticated:

View file

@ -39,14 +39,6 @@ def _positive_float(value: object, default: float = 1.0) -> float:
return f if f > 0 else default 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): def _get(row: object, attr: str):
"""Read a field from a dataclass row or a dict row.""" """Read a field from a dataclass row or a dict row."""
if isinstance(row, dict): if isinstance(row, dict):
@ -242,46 +234,6 @@ def rank_recommended_artists(
return out[:limit] 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( def aggregate_candidate_tracks(
recommended_artists: Sequence[RecommendedArtist], recommended_artists: Sequence[RecommendedArtist],
top_tracks_by_artist: Dict[str, Sequence[dict]], top_tracks_by_artist: Dict[str, Sequence[dict]],

View file

@ -10515,32 +10515,6 @@ class MusicDatabase:
logger.error(f"Error checking similar artists freshness: {e}") logger.error(f"Error checking similar artists freshness: {e}")
return False # Default to re-fetching on error 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( def get_top_similar_artists(
self, self,
limit: int = 50, limit: int = 50,

View file

@ -4,7 +4,6 @@ from __future__ import annotations
from core.discovery.listening_recommendations import ( from core.discovery.listening_recommendations import (
aggregate_candidate_tracks, aggregate_candidate_tracks,
apply_adventurousness,
build_recency_weighted_seeds, build_recency_weighted_seeds,
choose_mix_fetch_source, choose_mix_fetch_source,
names_match, names_match,
@ -315,40 +314,3 @@ def test_rank_threading_changes_winner_within_a_seed():
ranked = rank_recommended_artists(seeds, grouped) ranked = rank_recommended_artists(seeds, grouped)
assert [r.name for r in ranked] == ["Close", "Far"] assert [r.name for r in ranked] == ["Close", "Far"]
assert ranked[0].score > ranked[1].score 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

@ -1,66 +0,0 @@
"""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,28 +29565,6 @@ def get_discover_similar_artists():
artist_data["because"] = because artist_data["because"] = because
result_artists.append(artist_data) 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( logger.info(
f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for " f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for "
f"{active_source} after excluding {active_server} library artists" f"{active_source} after excluding {active_server} library artists"
@ -29604,31 +29582,6 @@ def get_discover_similar_artists():
return jsonify({"success": False, "error": str(e)}), 500 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']) @app.route('/api/discover/listening-recommendations', methods=['GET'])
def get_discover_listening_recommendations(): def get_discover_listening_recommendations():
"""#913: artists you'd love based on what you actually LISTEN to (play-weighted). """#913: artists you'd love based on what you actually LISTEN to (play-weighted).
@ -29651,24 +29604,6 @@ def get_discover_listening_recommendations():
except (ValueError, TypeError): except (ValueError, TypeError):
stored = [] 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 = [] result_artists = []
for a in stored: for a in stored:
name = a.get('name') name = a.get('name')

View file

@ -3146,35 +3146,6 @@
<div class="artist-map-search-results" id="artist-map-search-results"></div> <div class="artist-map-search-results" id="artist-map-search-results"></div>
</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) --> <!-- Recommended For You Section (similar-artists graph) -->
<!-- #913: listening-driven recommendations (play-weighted, consensus-ranked) --> <!-- #913: listening-driven recommendations (play-weighted, consensus-ranked) -->
<div class="discover-section" id="listening-recs-section" style="display: none;"> <div class="discover-section" id="listening-recs-section" style="display: none;">
@ -3184,24 +3155,12 @@
<p class="discover-section-subtitle">Artists you'd love — ranked from who you actually play the most</p> <p class="discover-section-subtitle">Artists you'd love — ranked from who you actually play the most</p>
</div> </div>
</div> </div>
<div class="discover-grid" id="listening-recs-carousel"> <div class="discover-carousel" id="listening-recs-carousel">
<!-- Populated by JS --> <!-- Populated by JS -->
</div> </div>
</div> </div>
<!-- #913: Listening Mix — a playable track mix from those recommended artists --> <!-- #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" style="display: none;">
<div class="discover-section-header"> <div class="discover-section-header">
<div> <div>
@ -3248,7 +3207,7 @@
</button> </button>
</div> </div>
</div> </div>
<div class="discover-grid" id="recommended-artists-carousel"> <div class="discover-carousel" id="recommended-artists-carousel">
<!-- Populated by JS --> <!-- Populated by JS -->
</div> </div>
</div> </div>
@ -3312,7 +3271,7 @@
<option value="recent">Date Added</option> <option value="recent">Date Added</option>
</select> </select>
</div> </div>
<div class="discover-grid" id="your-albums-grid"> <div class="spotify-library-grid" id="your-albums-grid">
<div class="discover-loading"> <div class="discover-loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<p>Loading your albums...</p> <p>Loading your albums...</p>
@ -3327,7 +3286,7 @@
<h2 class="discover-section-title">Recent Releases</h2> <h2 class="discover-section-title">Recent Releases</h2>
<p class="discover-section-subtitle">New music from artists you follow</p> <p class="discover-section-subtitle">New music from artists you follow</p>
</div> </div>
<div class="discover-grid" id="recent-releases-carousel"> <div class="discover-carousel" id="recent-releases-carousel">
<!-- Content will be populated dynamically --> <!-- Content will be populated dynamically -->
<div class="discover-loading"> <div class="discover-loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@ -3342,7 +3301,7 @@
<h2 class="discover-section-title" id="seasonal-albums-title">Seasonal</h2> <h2 class="discover-section-title" id="seasonal-albums-title">Seasonal</h2>
<p class="discover-section-subtitle" id="seasonal-albums-subtitle">Seasonal music</p> <p class="discover-section-subtitle" id="seasonal-albums-subtitle">Seasonal music</p>
</div> </div>
<div class="discover-grid" id="seasonal-albums-carousel"> <div class="discover-carousel" id="seasonal-albums-carousel">
<!-- Content will be populated dynamically --> <!-- Content will be populated dynamically -->
</div> </div>
</div> </div>
@ -3810,24 +3769,43 @@
</div> </div>
</div> </div>
<!-- Year Mixes (decade mix cards — same setup as Your Mixes) --> <!-- Time Machine (Tabbed by Decade) -->
<div class="discover-section" id="year-mixes-section"> <div class="discover-section">
<div class="discover-section-header"> <div class="discover-section-header">
<div> <h2 class="discover-section-title">⏰ Time Machine</h2>
<h2 class="discover-section-title">Year Mixes</h2> <p class="discover-section-subtitle">Explore music from different decades</p>
<p class="discover-section-subtitle">Jump into the sound of a decade — open one to see the tracks</p>
</div>
</div> </div>
<!-- Decade Tabs (hidden — replaced by the decade mix-card grid below) --> <!-- Decade Tabs (will be populated dynamically) -->
<div class="decade-tabs" id="decade-tabs" style="display:none"></div> <div class="decade-tabs" id="decade-tabs">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading decades...</p>
</div>
</div>
<!-- Decade Tab Contents (will be populated dynamically) --> <!-- Decade Tab Contents (will be populated dynamically) -->
<div id="decade-tab-contents"></div> <div id="decade-tab-contents"></div>
</div> </div>
<!-- Browse by Genre removed (#discover redesign): it was empty (no data from <!-- Browse by Genre (Tabbed by Genre) -->
/api/discover/genres/available) and redundant with the Genre Explorer pills. --> <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>
</div> </div>
</div> </div>
@ -6230,30 +6208,6 @@
</div> </div>
</div><!-- end Listening Stats body --> </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) --> <!-- 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':''"> <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> <span class="settings-section-arrow">&#9660;</span>

File diff suppressed because it is too large Load diff

View file

@ -724,9 +724,7 @@ const HYBRID_SOURCE_PROBE = {
tidal: () => _ssTestConn('tidal'), tidal: () => _ssTestConn('tidal'),
qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true), qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true),
hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true), hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true),
// POST (the endpoint is POST-only) with an empty body so it tests the SAVED ARL; a GET here deezer_dl: () => _ssJson('/api/deezer-download/test').then(j => j.success === true),
// 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), amazon: () => _ssJson('/api/amazon/test-connection').then(j => j.connected === true),
lidarr: () => _ssTestConn('lidarr'), lidarr: () => _ssTestConn('lidarr'),
soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true), soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true),
@ -1482,13 +1480,6 @@ async function loadSettingsData() {
// Populate Listening Stats settings // Populate Listening Stats settings
document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true; document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true;
document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30; 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 = document.getElementById('lossy-copy-options').style.display =
settings.lossy_copy?.enabled ? 'block' : 'none'; settings.lossy_copy?.enabled ? 'block' : 'none';
@ -3504,13 +3495,6 @@ async function saveSettings(quiet = false) {
enabled: document.getElementById('listening-stats-enabled').checked, enabled: document.getElementById('listening-stats-enabled').checked,
poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30, 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: { m3u_export: {
enabled: document.getElementById('m3u-export-enabled').checked, enabled: document.getElementById('m3u-export-enabled').checked,
entry_base_path: document.getElementById('m3u-entry-base-path').value || '' entry_base_path: document.getElementById('m3u-entry-base-path').value || ''

View file

@ -35006,236 +35006,6 @@ div.artist-hero-badge {
} }
.ya-card-name:hover { color: var(--accent); } .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 ── */ /* ── Artist Info Modal ── */
.ya-info-modal { .ya-info-modal {
background: #141420; border-radius: 18px; background: #141420; border-radius: 18px;