Compare commits

...

10 commits

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
8 changed files with 418 additions and 3 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

@ -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

@ -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;">
@ -6201,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>

View file

@ -22,12 +22,143 @@ let personalizedDiscoveryShuffle = [];
let personalizedListeningMix = []; // #913: the "Your Listening Mix" track playlist
let buildPlaylistSelectedArtists = [];
// ── Adventurousness — living wave dial (Discover page) ──────────────────────────────────────
// A draggable orb rides an animated line. At the left (value 0) the line is green and waves gently
// ("artists you already like"); dragging right shifts the colour through the spectrum to red and the
// wave grows bigger + more erratic. Drawn each frame with rAF. Shares discover.adventurousness with
// the Settings slider, so the two stay in sync.
const _advWave = { value: 0.3, phase: 0, raf: null, dragging: false };
function _advState(v) {
if (v < 0.12) return 'Playing it safe';
if (v < 0.40) return 'Balanced';
if (v < 0.70) return 'Adventurous';
return 'Deep cuts only';
}
// green (120°) → blue (220°) through cyan as the orb moves right. Blue reads "deep / exploratory"
// rather than red's "danger / bad" — safe at the green end, adventurous at the cool blue end.
function _advColor(v, light, alpha) {
const hue = 120 + 100 * Math.max(0, Math.min(1, v));
const l = light || 55;
return alpha != null ? `hsla(${hue.toFixed(0)}, 85%, ${l}%, ${alpha})` : `hsl(${hue.toFixed(0)}, 85%, ${l}%)`;
}
// Wave height (viewBox units, centre 40) at position u (0..1) for adventurousness v.
function _advWaveY(u, v) {
const amp = 2 + v * 12; // gentle ripple at 0, lively (not wild) at 1
const freq = 1.1 + v * 2.0; // a few more cycles as it gets adventurous
let y = 40 + amp * Math.sin(freq * u * Math.PI * 2 + _advWave.phase);
if (v > 0) { // a detuned second harmonic adds a touch of wobble
y += v * amp * 0.38 * Math.sin(freq * 2.2 * u * Math.PI * 2 + _advWave.phase * 1.6 + 1.3);
}
return y;
}
function _advDraw() {
const path = document.getElementById('adv-wave-path');
const track = document.getElementById('adv-wave-track');
if (!path || !track) { _advWave.raf = null; return; }
if (track.offsetParent !== null) { // skip the work while the Discover page is hidden
const v = _advWave.value;
_advWave.phase += 0.022 + v * 0.045; // waves a little faster the more adventurous
let line = '';
const N = 90;
for (let i = 0; i <= N; i++) {
const u = i / N;
line += (i === 0 ? 'M ' : ' L ') + (u * 1000).toFixed(1) + ' ' + _advWaveY(u, v).toFixed(1);
}
path.setAttribute('d', line);
// the luminous filled area = the wave closed down to the baseline
const area = document.getElementById('adv-wave-area');
if (area) area.setAttribute('d', line + ' L 1000 80 L 0 80 Z');
const orb = document.getElementById('adv-wave-orb');
if (orb) orb.style.top = (_advWaveY(v, v) / 80 * 100).toFixed(2) + '%'; // orb rides the wave
}
_advWave.raf = requestAnimationFrame(_advDraw);
}
function _advApply(v) {
v = Math.max(0, Math.min(1, v));
_advWave.value = v;
const c = _advColor(v, 55);
const cBright = _advColor(v, 62);
// Line stroke + its colour glow (set on change, not every frame, so the rAF stays cheap).
const path = document.getElementById('adv-wave-path');
if (path) { path.setAttribute('stroke', c); path.style.filter = `drop-shadow(0 0 7px ${c})`; }
const fillTop = document.getElementById('adv-wave-fill-top');
if (fillTop) fillTop.setAttribute('stop-color', c); // luminous filled area
const orb = document.getElementById('adv-wave-orb');
if (orb) {
orb.style.left = (v * 100).toFixed(2) + '%';
orb.style.color = c; // currentColor for the pulsing ring
orb.style.background = cBright;
orb.style.boxShadow = `0 0 9px 0 ${c}, inset 0 0 0 2px rgba(255,255,255,0.5)`;
}
const aura = document.getElementById('adv-wave-aura'); // colour wash that follows the orb
if (aura) {
aura.style.left = (v * 100).toFixed(2) + '%';
aura.style.background = `radial-gradient(circle, ${_advColor(v, 50, 0.26)} 0%, transparent 70%)`;
}
const stateEl = document.getElementById('adv-wave-state');
if (stateEl) { stateEl.textContent = _advState(v); stateEl.style.color = cBright; }
}
let _advCommitTimer = null;
function _advCommit(v) {
clearTimeout(_advCommitTimer);
_advCommitTimer = setTimeout(async () => {
try {
await fetch('/api/discover/adventurousness', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: v }),
});
} catch (e) { console.debug('adventurousness save failed', e); }
// Re-fetch the two rec rows so the dial is felt immediately (the routes re-rank live).
if (typeof loadListeningRecommendations === 'function') loadListeningRecommendations();
if (typeof loadRecommendedArtistsSection === 'function') loadRecommendedArtistsSection();
}, 220);
}
function _advInitDrag() {
const track = document.getElementById('adv-wave-track');
if (!track || track._advWired) return;
track._advWired = true;
const fromX = (clientX) => {
const r = track.getBoundingClientRect();
return r.width ? (clientX - r.left) / r.width : 0;
};
track.addEventListener('pointerdown', (e) => {
_advWave.dragging = true;
try { track.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
_advApply(fromX(e.clientX));
});
track.addEventListener('pointermove', (e) => { if (_advWave.dragging) _advApply(fromX(e.clientX)); });
const end = (e) => {
if (!_advWave.dragging) return;
_advWave.dragging = false;
_advApply(fromX(e.clientX));
_advCommit(_advWave.value);
};
track.addEventListener('pointerup', end);
track.addEventListener('pointercancel', end);
}
async function loadAdventurousnessDial() {
const track = document.getElementById('adv-wave-track');
if (!track) return;
_advInitDrag();
try {
const resp = await fetch('/api/discover/adventurousness');
if (resp.ok) {
const data = await resp.json();
if (typeof data.value === 'number') _advWave.value = data.value;
}
} catch (e) { /* non-fatal */ }
_advApply(_advWave.value);
if (!_advWave.raf) _advWave.raf = requestAnimationFrame(_advDraw);
}
async function loadDiscoverPage() {
console.log('Loading discover page...');
// Load all sections
await Promise.all([
loadDiscoverHero(),
loadAdventurousnessDial(), // sets the Discover-page dial from config
loadListeningRecommendations(), // #913: play-weighted, consensus-ranked picks
loadPersonalizedListeningMix(), // #913: playable track mix from those picks
loadRecommendedArtistsSection(),

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

@ -35162,6 +35162,60 @@ div.artist-hero-badge {
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 {