#887: Spotify enrichment shows 'Running (Spotify Free)' for no-auth users, not 'Not Authenticated'

radoslav-orlov: with no Spotify auth, enrichment runs on the no-creds Spotify Free
source (prefer-free is on by default) and IS working — pending drains, the modal
shows RUNNING — but the dashboard header tooltip said 'Not Authenticated /
Connect Spotify in Settings'. Two causes:

- get_stats() only set using_free for the rate-limit / spent-budget bridges, not
  the plain no-auth-default-free case. The loop already computes the right signal
  (free_serving = _free_active(), True here) but it was a local var. Cache it on
  self each iteration and report it as using_free (no auth API call in the 2s
  status loop).
- The dashboard's Spotify updater checked notAuthenticated BEFORE bridgingFree, so
  even with using_free it showed Not Authenticated. notAuthenticated now excludes
  the bridging-free case; the LastFM/Genius/Tidal/Qobuz updaters (no free path)
  are unchanged.

5 seam tests for get_stats free/auth reporting; 67 enrichment/free tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-18 13:00:33 -07:00
parent a5267ee8cf
commit 56da2e105c
3 changed files with 90 additions and 4 deletions

View file

@ -47,6 +47,12 @@ class SpotifyWorker:
# Current item being processed (for UI tooltip)
self.current_item = None
# Whether the worker is serving via the no-creds Spotify Free source as of
# its last loop iteration. Cached from the loop's _free_active() probe so
# get_stats() can report it without an auth API call (#887: a no-auth user
# whose enrichment runs on Free was shown "Not Authenticated").
self._serving_via_free = False
# Statistics
self.stats = {
'matched': 0,
@ -136,8 +142,14 @@ class SpotifyWorker:
# real-API daily budget (the worker set _budget_exhausted_use_free —
# a cheap attribute read). Lets the UI show "Running (Spotify Free)"
# instead of a misleading "rate limited" / "daily limit reached".
# #887: the loop's cached _free_active() result is the comprehensive
# signal — it's True for a no-auth user enriching via Spotify Free by
# default (prefer-free is on unless disabled), not just the rate-limit
# / budget bridges. The extra terms stay as a fallback for the brief
# window before the loop's first iteration sets the cache.
using_free = bool(
(rate_limited and self.client.is_spotify_metadata_available())
getattr(self, '_serving_via_free', False)
or (rate_limited and self.client.is_spotify_metadata_available())
or getattr(self.client, '_budget_exhausted_use_free', False)
)
except Exception:
@ -264,6 +276,9 @@ class SpotifyWorker:
free_serving = self.client._free_active()
except Exception:
free_serving = False
# Cache for get_stats() so the dashboard status reflects that the
# worker IS enriching via Free even with no official auth (#887).
self._serving_via_free = free_serving
# Daily budget guard — pause ONLY when the budget is spent AND we
# can't serve via free (no free available). Otherwise free took over.

View file

@ -0,0 +1,68 @@
"""Issue #887: the Spotify enrichment worker's get_stats() must report
``using_free`` when it's enriching via the no-creds Spotify Free source — even
with no official auth so the dashboard shows "Running (Spotify Free)" instead
of a misleading "Not Authenticated".
Builds the worker via __new__ (bypassing the real SpotifyClient()) and stubs the
db-querying helpers, so the get_stats() free/auth logic is tested in isolation.
"""
from __future__ import annotations
import types
from core.spotify_worker import SpotifyWorker
def _worker(*, serving_via_free, sp=None, rate_limited=False, budget_free=False):
w = SpotifyWorker.__new__(SpotifyWorker)
w.running = True
w.paused = False
w.thread = types.SimpleNamespace(is_alive=lambda: True)
w.current_item = None
w.stats = {'pending': 0, 'processed': 0}
w._serving_via_free = serving_via_free
w.client = types.SimpleNamespace(
sp=sp,
is_rate_limited=lambda: rate_limited,
get_rate_limit_info=lambda: None,
get_post_ban_cooldown_remaining=lambda: 0,
is_spotify_metadata_available=lambda: True,
_budget_exhausted_use_free=budget_free,
)
# db-backed helpers stubbed — we only exercise the auth/free reporting.
w._count_pending_items = lambda: 100
w._get_progress_breakdown = lambda: {}
w._get_daily_budget_info = lambda: {'exhausted': False}
return w
def test_no_auth_but_serving_via_free_reports_using_free():
# #887: no official auth (sp is None), worker enriching via Spotify Free.
stats = _worker(serving_via_free=True, sp=None).get_stats()
assert stats['authenticated'] is False # no official auth
assert stats['using_free'] is True # ...but Free is carrying it
def test_no_auth_and_not_serving_free_is_not_using_free():
# Genuinely can't enrich (no auth, free not active) -> Not Authenticated stands.
stats = _worker(serving_via_free=False, sp=None).get_stats()
assert stats['authenticated'] is False
assert stats['using_free'] is False
def test_rate_limit_bridge_still_reports_using_free():
# Pre-existing bridge path must still work (cache False, but rate-limited).
stats = _worker(serving_via_free=False, sp=object(), rate_limited=True).get_stats()
assert stats['using_free'] is True
def test_budget_bridge_still_reports_using_free():
stats = _worker(serving_via_free=False, sp=object(), budget_free=True).get_stats()
assert stats['using_free'] is True
def test_authed_and_not_on_free_reports_authenticated_not_free():
stats = _worker(serving_via_free=False, sp=object()).get_stats()
assert stats['authenticated'] is True
assert stats['using_free'] is False

View file

@ -455,11 +455,14 @@ function updateSpotifyEnrichmentStatusFromData(data) {
const button = document.getElementById('spotify-enrich-button');
if (!button) return;
const notAuthenticated = data.authenticated === false;
const isRateLimited = data.rate_limited === true;
// The real API is banned but the worker is still matching via the no-creds
// Spotify Free source — treat it as running, not stuck (#798 bridge).
// The real API is unauthed/banned but the worker is still matching via the
// no-creds Spotify Free source — treat it as running, not stuck (#798/#887).
const bridgingFree = data.using_free === true;
// #887: a no-auth user whose enrichment runs on Spotify Free is NOT "not
// authenticated" for status purposes — the worker IS enriching. Only flag
// Not Authenticated when Free isn't carrying it.
const notAuthenticated = data.authenticated === false && !bridgingFree;
const rateLimitedStuck = isRateLimited && !bridgingFree;
// Budget is a real-API cap; when bridging to free it no longer applies, so
// only treat the budget as a stop when we're NOT serving via free (#798).