From 2667eaec875e04ef9364e4606630794403df505a Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 5 Jun 2026 13:36:15 -0700 Subject: [PATCH] =?UTF-8?q?#798:=20Spotify=20Free=20UI=20=E2=80=94=20enabl?= =?UTF-8?q?e=20toggle=20+=20source=20availability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the opt-in Spotify Free source so it's usable end-to-end: - Settings: 'Enable Spotify Free (no credentials)' toggle that saves metadata.spotify_free (load + save wired). Clear best-effort/limitations note. - config-status: adds spotify.metadata_available (configured OR free-available), keeping the configured flag = has-credentials so the Connections indicator stays honest. Search source picker shows Spotify when metadata_available. - status payload: adds spotify.metadata_available; the Settings primary-source selector now allows picking Spotify when authed OR free-available. Verified gate composition: OFF by default (no surprise scraping); ON + no auth + installed -> available & serving; AUTHED -> official always wins (free never runs); missing package -> gracefully unavailable. JS + integrity + 111 tests green. --- web_server.py | 29 ++++++++++++++++++++++++++--- webui/index.html | 9 ++++++++- webui/static/settings.js | 9 +++++++-- webui/static/shared-helpers.js | 4 ++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/web_server.py b/web_server.py index 77da23ad..0ecd0b03 100644 --- a/web_server.py +++ b/web_server.py @@ -2318,9 +2318,19 @@ def get_status(): if t.get('status') in ('downloading', 'searching', 'post_processing', 'queued', 'pending'): active_dl_count += 1 + # Spotify Free: tell the UI whether Spotify metadata is available even + # without auth (so the Settings source selector can offer it). + spotify_status = dict(metadata_status['spotify']) + try: + spotify_status['metadata_available'] = bool( + spotify_client and spotify_client.is_spotify_metadata_available() + ) + except Exception: + spotify_status['metadata_available'] = bool(spotify_status.get('authenticated')) + status_data = { 'metadata_source': metadata_status['metadata_source'], - 'spotify': metadata_status['spotify'], + 'spotify': spotify_status, 'media_server': _status_cache['media_server'], 'soulseek': soulseek_data, 'active_media_server': active_server, @@ -3665,10 +3675,23 @@ def settings_config_status_endpoint(): Drives the green/yellow header gradient. No API calls — just config reads. """ try: - return jsonify({ + result = { service: {'configured': _is_service_configured(service)} for service in SERVICE_CONFIG_REGISTRY - }) + } + # Spotify Free: Spotify metadata can be available without credentials + # (opt-in no-creds source). Surface that separately so the search source + # picker offers Spotify, while `configured` (the Connections indicator) + # keeps meaning "has client credentials". + if 'spotify' in result: + try: + meta_avail = bool(spotify_client and spotify_client.is_spotify_metadata_available()) + except Exception: + meta_avail = False + result['spotify']['metadata_available'] = ( + result['spotify']['configured'] or meta_avail + ) + return jsonify(result) except Exception as e: logger.error(f"config-status error: {e}") return jsonify({"error": str(e)}), 500 diff --git a/webui/index.html b/webui/index.html index 80dac941..d42a28fc 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3844,8 +3844,15 @@ +
+ +
Fetch Spotify metadata without API credentials, for when you can't or don't want to connect Spotify. Unofficial & best-effort (it can break if Spotify changes), and it can't search albums by name or access your library — only used when Spotify isn't authenticated.
+
-
Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token. MusicBrainz is always available but rate-limited to 1 req/sec.
+
Choose the primary source for artist, album, and track metadata. Spotify needs an active session — or enable Spotify Free above. Discogs requires a personal token. MusicBrainz is always available but rate-limited to 1 req/sec.
diff --git a/webui/static/settings.js b/webui/static/settings.js index 4ef1fb1d..94800cde 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -64,7 +64,9 @@ function syncMetadataSourceSelection(source) { function _isMetadataSourceSelectable(source) { if (source === 'spotify') { - return _lastStatusPayload?.spotify?.authenticated === true; + // Selectable with real auth OR when Spotify Free (no-creds) is available. + return _lastStatusPayload?.spotify?.authenticated === true + || _lastStatusPayload?.spotify?.metadata_available === true; } if (source === 'discogs') { const token = document.getElementById('discogs-token'); @@ -1045,6 +1047,8 @@ async function loadSettingsData() { // Populate Metadata source setting document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'deezer'; + const spotifyFreeToggle = document.getElementById('metadata-spotify-free'); + if (spotifyFreeToggle) spotifyFreeToggle.checked = settings.metadata?.spotify_free === true; // Populate Hydrabase settings const hbConfig = settings.hydrabase || {}; @@ -2842,7 +2846,8 @@ async function saveSettings(quiet = false) { token: document.getElementById('discogs-token').value, }, metadata: { - fallback_source: metadataSource + fallback_source: metadataSource, + spotify_free: document.getElementById('metadata-spotify-free')?.checked === true }, hydrabase: { url: document.getElementById('hydrabase-url').value, diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index a55b7027..7eb1d78e 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -110,6 +110,10 @@ async function fetchSourceConfiguredMap() { for (const src of SOURCE_ORDER) { if (_ALWAYS_CONFIGURED_SOURCES.has(src)) { map[src] = true; + } else if (src === 'spotify') { + // Spotify Free: available without credentials when the + // opt-in no-creds source is on (metadata_available). + map[src] = !!(data[src] && (data[src].configured || data[src].metadata_available)); } else { map[src] = !!(data[src] && data[src].configured); }