#798: Spotify Free UI — enable toggle + source availability

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.
This commit is contained in:
BoulderBadgeDad 2026-06-05 13:36:15 -07:00
parent 0eff9e3708
commit 2667eaec87
4 changed files with 45 additions and 6 deletions

View file

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

View file

@ -3844,8 +3844,15 @@
<option value="musicbrainz">MusicBrainz</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="metadata-spotify-free">
<span>Enable Spotify Free (no credentials)</span>
</label>
<div class="callback-help">Fetch Spotify metadata without API credentials, for when you can't or don't want to connect Spotify. Unofficial &amp; 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.</div>
</div>
<div class="callback-info">
<div class="callback-help">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.</div>
<div class="callback-help">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.</div>
</div>
</div>

View file

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

View file

@ -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);
}