Spotify: rename "Spotify Free" → "Spotify (no auth)", default enrichment to it

Per Boulder's calls on the new enrichment toggle:

- Naming: "Spotify Free" was misleading (it's a hybrid — pick it, connect an
  account, and sync still uses your official playlists). Relabel the user-facing
  strings to "Spotify (no auth)" — the real distinction is needs-credentials vs
  not. Internal value/key (spotify_free, _free_*) unchanged, so no migration.

- Default ON: metadata.spotify_free_enrichment now defaults True (worker + UI
  load both treat unset as on). So bulk enrichment runs on the no-auth path by
  default and the official account is reserved for interactive search/sync; turn
  the toggle off to enrich through the connected account. The toggle overrides
  auth for the worker (authed users still enrich via no-auth) — matching the
  intended model.

- Worker runs on the toggle alone: is_spotify_metadata_available() now honors
  _prefer_free (+ package installed), so the worker enriches via no-auth even
  with no account connected and no 'no-auth' source selected. Only fires on a
  client carrying the flag (the worker's own), so interactive/watchlist
  availability is unchanged.

- UI: moved the toggle from "Metadata Source" to the Spotify section next to the
  auth fields, always visible, on by default. Help notes the genre trade-off.

Tests: prefer_free makes metadata available without auth/source (and is inert
without the package); interactive availability unaffected. 218 Spotify tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-09 12:55:19 -07:00
parent fd3ce8ba6e
commit 90174de4b2
5 changed files with 71 additions and 28 deletions

View file

@ -633,6 +633,13 @@ class SpotifyClient:
watchlist) use THIS instead of ``is_spotify_authenticated()`` so the
free source is reachable. Does NOT change auth semantics."""
from core.spotify_free_metadata import should_offer_spotify_metadata
# The enrichment worker's prefer-free opt-in (set on its own client)
# makes the no-auth source the active path even without auth or the
# 'no-auth Spotify' source choice — so metadata IS available to it. This
# only fires on a client carrying _prefer_free (the worker's), so
# interactive/watchlist availability is unchanged.
if getattr(self, '_prefer_free', False) and self._free_installed():
return True
try:
authed = self.is_spotify_authenticated()
except Exception:

View file

@ -213,26 +213,29 @@ class SpotifyWorker:
interruptible_sleep(self._stop_event, min(remaining, 60)) # Check again every 60s max
continue
# Is the worker serving via the no-creds Spotify Free source this
# iteration? The daily budget and post-ban cooldown both exist to
# protect the REAL authenticated API from bans — they don't apply
# to free (a different, anonymous path). Computed once and reused
# below; the loop already probes auth, so no extra quota cost.
# Worker opt-in: when the user enables Spotify Free for enrichment
# (metadata.spotify_free_enrichment), prefer the no-creds source
# even while authed + under budget — bulk enrichment is the work
# that bans the real API, so this spares the official quota for
# interactive use. _free_active() honors this flag (and still falls
# back to official only if free can't serve); set only on the
# worker's own client, so interactive paths stay official-first.
# Cheap config read; harmless when Spotify Free isn't installed.
# Enrichment runs on the no-auth Spotify source by DEFAULT
# (metadata.spotify_free_enrichment, ON unless turned off): bulk
# enrichment is the workload that bans the real API, so we keep it
# off your connected account's quota and reserve official Spotify
# for interactive search + playlist sync. The flag overrides auth
# for the worker (authed users still enrich via the no-auth path)
# and also lets the worker run with no auth at all. _free_active()
# and is_spotify_metadata_available() both honor it; set only on
# the worker's OWN client, so interactive paths stay official-first.
# Harmless when the no-auth package isn't installed (the methods
# fall back to official, then iTunes/Deezer).
try:
from config.settings import config_manager as _cfg
self.client._prefer_free = bool(
_cfg.get('metadata.spotify_free_enrichment', False))
_cfg.get('metadata.spotify_free_enrichment', True))
except Exception: # noqa: S110 — prefer-free toggle is best-effort
self.client._prefer_free = False
self.client._prefer_free = True
# Is the worker serving via the no-auth Spotify source this
# iteration? The daily budget and post-ban cooldown both exist to
# protect the REAL authenticated API from bans — they don't apply
# to the no-auth path. Computed once and reused below; the loop
# already probes auth, so no extra quota cost.
budget_exhausted = self._is_daily_budget_exhausted()
# Daily budget is a REAL-API ban protection. When it's spent, if

View file

@ -393,3 +393,34 @@ def test_search_albums_diverts_to_free_when_budget_exhausted_and_authed():
results = c.search_albums('Kendrick Lamar GNX', limit=5,
artist='Kendrick Lamar', album='GNX')
assert len(results) == 1 and results[0].id == 'al2'
# ── default-ON enrichment: prefer_free makes metadata available to the worker ──
def _metadata_available(prefer_free, installed, authed=False, selected=False):
c = SpotifyClient.__new__(SpotifyClient)
if prefer_free:
c._prefer_free = True
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=authed), \
patch('core.spotify_client.config_manager') as cm, \
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
patch.object(_sfm, 'spotify_free_installed', return_value=installed):
cm.get.side_effect = lambda k, d=None: selected if k == 'metadata.spotify_free' else d
return c.is_spotify_metadata_available()
def test_prefer_free_makes_metadata_available_without_auth_or_source():
# Default-ON enrichment: the worker runs via the no-auth path on the toggle
# alone — no account connected, no 'no-auth Spotify' source selected.
assert _metadata_available(prefer_free=True, installed=True) is True
def test_prefer_free_metadata_unavailable_without_package():
assert _metadata_available(prefer_free=True, installed=False) is False
def test_interactive_metadata_availability_unaffected_by_prefer_free():
# A client WITHOUT _prefer_free (interactive/global): no auth + no source -> unavailable.
assert _metadata_available(prefer_free=False, installed=True) is False
# ...and authed is available as before.
assert _metadata_available(prefer_free=False, installed=True, authed=True) is True

View file

@ -3839,7 +3839,7 @@
<label>Primary metadata source:</label>
<select id="metadata-fallback-source">
<option value="spotify">Spotify</option>
<option value="spotify_free">Spotify Free (no credentials)</option>
<option value="spotify_free">Spotify (no auth)</option>
<option value="itunes">iTunes / Apple Music</option>
<option value="deezer">Deezer</option>
<option value="discogs">Discogs</option>
@ -3849,20 +3849,11 @@
<div class="callback-info">
<div class="callback-help">Where artist, album, and track metadata comes from:<br>
<strong>Spotify</strong> — official Spotify; connect your account in the Spotify section below.<br>
<strong>Spotify Free</strong> — the same Spotify catalog with no account needed. It's unofficial and best-effort (may break if Spotify changes its site), can't search albums by name (album results come from iTunes/Deezer instead), and can't read your personal library or playlists.<br>
<strong>Spotify (no auth)</strong> — the same Spotify catalog with no account needed. It's unofficial and best-effort (may break if Spotify changes its site) and can't read your personal library or playlists.<br>
<strong>Deezer</strong> / <strong>iTunes</strong> — free, no account.<br>
<strong>MusicBrainz</strong> — free, but limited to 1 request/sec.<br>
<strong>Discogs</strong> — needs a personal access token.<br><br>
Tip: if you pick <strong>Spotify Free</strong> and later connect a real Spotify account, it uses the official account normally and only falls back to free while Spotify is rate-limited — then switches back automatically.</div>
</div>
<div class="form-group" style="margin-top: 12px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: normal;">
<input type="checkbox" id="metadata-spotify-free-enrichment" style="width: auto; margin: 0;">
<span>Use Spotify Free for background enrichment</span>
</label>
<div class="callback-info">
<div class="callback-help">Runs the background enrichment worker on the no-credentials Spotify Free source even when your Spotify account is connected — sparing your official API quota (and avoiding rate-limit bans) for interactive search and playlist sync, since bulk enrichment is what usually trips Spotify's limits. Needs the bundled Spotify Free package. Trade-off: Spotify Free can't provide artist genres. Leave off to enrich via your official account as usual.</div>
</div>
Tip: if you pick <strong>Spotify (no auth)</strong> and later connect a real Spotify account, it uses the official account normally and only falls back to no-auth while Spotify is rate-limited — then switches back automatically.</div>
</div>
</div>
@ -3902,6 +3893,15 @@
onclick="disconnectSpotify()" style="display: none;">🔌
Disconnect</button>
</div>
<div class="form-group" style="margin-top: 14px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 14px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: normal;">
<input type="checkbox" id="metadata-spotify-free-enrichment" style="width: auto; margin: 0;">
<span>Use Spotify (no auth) for background enrichment</span>
</label>
<div class="callback-info">
<div class="callback-help">Runs the background metadata enrichment worker on the no-auth Spotify source instead of this connected account — keeping bulk enrichment off your official API quota (and dodging rate-limit bans), so your account is reserved for interactive search and playlist sync. <strong>On by default.</strong> Turn off to enrich through your connected account instead. Works even with no account connected. Note: the no-auth source can't supply artist genres.</div>
</div>
</div>
</div>
</div>

View file

@ -1062,7 +1062,9 @@ async function loadSettingsData() {
? 'spotify_free' : _fbSrc;
document.getElementById('metadata-fallback-source').value = _metaSel;
const _efEl = document.getElementById('metadata-spotify-free-enrichment');
if (_efEl) _efEl.checked = settings.metadata?.spotify_free_enrichment === true;
// Default ON: unset (undefined) reads as enabled, matching the worker's
// config default (metadata.spotify_free_enrichment defaults True).
if (_efEl) _efEl.checked = settings.metadata?.spotify_free_enrichment !== false;
// Populate Hydrabase settings
const hbConfig = settings.hydrabase || {};