From 56da2e105c59305a011672a1e33e66ba3e61ee59 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 18 Jun 2026 13:00:33 -0700 Subject: [PATCH] #887: Spotify enrichment shows 'Running (Spotify Free)' for no-auth users, not 'Not Authenticated' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/spotify_worker.py | 17 +++++++- tests/test_spotify_worker_status.py | 68 +++++++++++++++++++++++++++++ webui/static/enrichment.js | 9 ++-- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 tests/test_spotify_worker_status.py diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 0f2fcd54..4693cd40 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -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. diff --git a/tests/test_spotify_worker_status.py b/tests/test_spotify_worker_status.py new file mode 100644 index 00000000..a75b0382 --- /dev/null +++ b/tests/test_spotify_worker_status.py @@ -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 diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js index 3475b092..9a866172 100644 --- a/webui/static/enrichment.js +++ b/webui/static/enrichment.js @@ -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).