Compare commits

..

No commits in common. "91912681e5edaa7c0062b542e605af9f209bab32" and "f0a6d5e6964ead7c01a17566fb79007327569efe" have entirely different histories.

8 changed files with 3 additions and 418 deletions

View file

@ -227,14 +227,10 @@ class DeezerDownloadClient(DownloadSourcePlugin):
self.shutdown_check = check_callable
def is_configured(self) -> bool:
# 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()
return self._authenticated
def is_available(self) -> bool:
return self.is_authenticated()
return self._authenticated
def is_authenticated(self) -> bool:
if self._pending_arl and not self._authenticated:

View file

@ -10515,32 +10515,6 @@ 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

@ -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
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"
@ -29604,31 +29582,6 @@ 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).
@ -29651,24 +29604,6 @@ 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,35 +3146,6 @@
<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;">
@ -6230,30 +6201,6 @@
</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,143 +22,12 @@ 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,9 +724,7 @@ 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),
// 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),
deezer_dl: () => _ssJson('/api/deezer-download/test').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),
@ -1482,13 +1480,6 @@ 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';
@ -3504,13 +3495,6 @@ 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,60 +35162,6 @@ 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 {