Compare commits
10 commits
f0a6d5e696
...
91912681e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91912681e5 | ||
|
|
598a480a0b | ||
|
|
fcd768ffee | ||
|
|
d9bd5c1d7f | ||
|
|
c50465984f | ||
|
|
43fbab5704 | ||
|
|
71a48dd344 | ||
|
|
c47b36cd6f | ||
|
|
29f84b5601 | ||
|
|
ce8faf0ecc |
8 changed files with 418 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
66
tests/test_deezer_download_is_configured.py
Normal file
66
tests/test_deezer_download_is_configured.py
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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">▼</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">▼</span>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 || ''
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue