From e5e56f3d062603a78aae32f09538e141539ab52f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 6 Jun 2026 08:51:30 -0700 Subject: [PATCH] Bridge the Spotify worker to Free when the daily budget is spent (don't pause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #798 follow-up. The worker's 500/day budget is a REAL-API ban shield, but when it was hit the worker paused outright — even for a Spotify-Free user with the uncapped free source available. So "I'm on Spotify Free" still got capped overnight. The intuition is right: if it's ever using Spotify Free, the budget shouldn't apply. Fix: spent budget now becomes a third "use free" trigger (alongside no-auth and rate-limited). When the real-API budget is exhausted and the free source is available, the worker switches to free (uncapped) for the rest of the day instead of pausing, then reverts to real-first on the daily reset. - should_use_free_fallback gains a budget_exhausted arg (free activates on no-auth OR rate-limited OR spent-budget). - the worker sets _budget_exhausted_use_free on ITS OWN client (a separate instance from the search client — verified, so user searches still use real auth), and clears it when the budget resets; _free_active() honors the flag. - get_stats() using_free reports the budget-bridge too, and the dashboard bubble shows "Running (Spotify Free)" instead of "Daily Limit Reached" (budgetStuck = exhausted AND not bridging). A no-free user still pauses on the budget (nothing to bridge to). A pure free-only worker never increments the budget at all. New gate test pins the budget_exhausted trigger. Full suite clean. --- core/spotify_client.py | 14 ++++++--- core/spotify_free_metadata.py | 14 +++++---- core/spotify_worker.py | 45 ++++++++++++++++++++++------- tests/test_spotify_free_metadata.py | 12 ++++++++ webui/static/enrichment.js | 10 ++++--- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/core/spotify_client.py b/core/spotify_client.py index bb7af518..824112b1 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -612,9 +612,12 @@ class SpotifyClient: term covers the brief window before the auth cache refreshes. When authed + healthy the official path returns first, so this never opens. - Two activations fall out of this: a no-auth user who chose Spotify Free - (free is their source), and a connected user mid-rate-limit (free bridges - the ban) — see _free_wanted().""" + Three activations fall out of this: a no-auth user who chose Spotify + Free (free is their source), a connected user mid-rate-limit (free + bridges the ban), and a connected user who has spent the enrichment + worker's real-API daily budget (``_budget_exhausted_use_free``, set by + the worker) — so a Spotify-Free user is never paused by the budget, it + just switches to the uncapped free source. See _free_wanted().""" from core.spotify_free_metadata import should_use_free_fallback if not self._free_available(): return False @@ -622,7 +625,10 @@ class SpotifyClient: authed = self.is_spotify_authenticated() except Exception: authed = False - return should_use_free_fallback(authed, _is_globally_rate_limited()) + return should_use_free_fallback( + authed, _is_globally_rate_limited(), + getattr(self, '_budget_exhausted_use_free', False), + ) @property def _free_meta(self): diff --git a/core/spotify_free_metadata.py b/core/spotify_free_metadata.py index 5b037183..51e05682 100644 --- a/core/spotify_free_metadata.py +++ b/core/spotify_free_metadata.py @@ -46,13 +46,17 @@ def spotify_free_installed() -> bool: return _installed_cache -def should_use_free_fallback(authenticated: bool, rate_limited: bool) -> bool: +def should_use_free_fallback(authenticated: bool, rate_limited: bool, + budget_exhausted: bool = False) -> bool: """The per-request gate: the no-creds SpotipyFree source may serve a request - ONLY when official Spotify can't — i.e. the user has no Spotify auth, or - we're currently rate-limited. When authed AND healthy the official path - returns before any fallback, so this never opens. + ONLY when official Spotify can't — i.e. the user has no Spotify auth, we're + currently rate-limited, OR the worker's self-imposed real-API daily budget + is spent. The daily budget is a real-API ban protection; once it's hit, the + free source (which isn't subject to it) is the natural place to keep going, + so a Spotify-Free user is never paused by the budget. When authed AND + healthy AND under budget, the official path returns before any fallback. """ - return (not authenticated) or rate_limited + return (not authenticated) or rate_limited or budget_exhausted def should_offer_spotify_metadata(authenticated: bool, free_available: bool) -> bool: diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 95060d97..aa780495 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -126,13 +126,17 @@ class SpotifyWorker: rate_limit_info = self.client.get_rate_limit_info() if rate_limited else None in_cooldown = self.client.get_post_ban_cooldown_remaining() > 0 authenticated = self.client.sp is not None - # Is the worker still serving via the no-creds Spotify Free source - # despite the real-API ban? Only check WHEN rate-limited: during a - # ban is_spotify_authenticated() returns False without an API probe, - # so is_spotify_metadata_available() reduces to "is free available" - # (no quota cost). Lets the UI show "via Spotify Free" instead of a - # misleading "rate limited / waiting" while the worker keeps matching. - using_free = bool(rate_limited and self.client.is_spotify_metadata_available()) + # Is the worker serving via the no-creds Spotify Free source right + # now? Two cases: bridging a rate-limit ban (only checked WHEN + # rate-limited — there is_spotify_authenticated() returns False + # without an API probe, so this is quota-free), OR bridging the spent + # 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". + using_free = bool( + (rate_limited and self.client.is_spotify_metadata_available()) + or getattr(self.client, '_budget_exhausted_use_free', False) + ) except Exception: authenticated = False rate_limited = False @@ -214,14 +218,35 @@ class SpotifyWorker: # 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. + budget_exhausted = self._is_daily_budget_exhausted() + + # Daily budget is a REAL-API ban protection. When it's spent, if + # the no-creds free source is available, BRIDGE to it (uncapped) + # for the rest of the day instead of pausing — so a Spotify-Free + # user is never stopped by the budget. The flag makes the client + # route subsequent calls to free; it clears on the daily reset. + try: + if budget_exhausted and self.client._free_available(): + self.client._budget_exhausted_use_free = True + elif not budget_exhausted and getattr(self.client, '_budget_exhausted_use_free', False): + self.client._budget_exhausted_use_free = False + except Exception: # noqa: S110 — budget→free toggle is best-effort + pass + + # 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). _free_active() now also + # returns True when the budget-bridge flag above is set. Computed + # once and reused below; the loop already probes auth, no extra cost. try: free_serving = self.client._free_active() except Exception: free_serving = False - # Daily budget guard — worker-only cap to avoid saturating the REAL - # Spotify API. Skipped while serving via free (free isn't that API). - if not free_serving and self._is_daily_budget_exhausted(): + # Daily budget guard — pause ONLY when the budget is spent AND we + # can't serve via free (no free available). Otherwise free took over. + if not free_serving and budget_exhausted: budget = self._get_daily_budget_info() resets_in = budget['resets_in_seconds'] logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), " diff --git a/tests/test_spotify_free_metadata.py b/tests/test_spotify_free_metadata.py index 20ddae39..390b3664 100644 --- a/tests/test_spotify_free_metadata.py +++ b/tests/test_spotify_free_metadata.py @@ -60,6 +60,18 @@ def test_gate_open_when_no_auth_and_rate_limited(): assert should_use_free_fallback(authenticated=False, rate_limited=True) is True +def test_gate_open_when_budget_exhausted_even_if_authed_and_healthy(): + # #758-follow-up: the real-API daily budget is spent, but the user has + # Spotify Free — switch to the uncapped free source instead of pausing. + assert should_use_free_fallback(authenticated=True, rate_limited=False, + budget_exhausted=True) is True + + +def test_gate_closed_when_authed_healthy_and_under_budget(): + assert should_use_free_fallback(authenticated=True, rate_limited=False, + budget_exhausted=False) is False + + # --------------------------------------------------------------------------- # should_offer_spotify_metadata — the availability gate the callers use # --------------------------------------------------------------------------- diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js index 649920e5..bb0ea640 100644 --- a/webui/static/enrichment.js +++ b/webui/static/enrichment.js @@ -461,14 +461,16 @@ function updateSpotifyEnrichmentStatusFromData(data) { // Spotify Free source — treat it as running, not stuck (#798 bridge). const bridgingFree = data.using_free === true; const rateLimitedStuck = isRateLimited && !bridgingFree; - const budgetExhausted = data.daily_budget && data.daily_budget.exhausted; + // 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). + const budgetStuck = (data.daily_budget && data.daily_budget.exhausted) && !bridgingFree; button.classList.remove('active', 'paused', 'complete', 'no-auth'); if (data.paused) { button.classList.add('paused'); } else if (notAuthenticated) { button.classList.add('no-auth'); - } else if (rateLimitedStuck || budgetExhausted) { + } else if (rateLimitedStuck || budgetStuck) { button.classList.add('paused'); } else if (data.idle) { button.classList.add('complete'); @@ -485,7 +487,7 @@ function updateSpotifyEnrichmentStatusFromData(data) { else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } else if (rateLimitedStuck) { tooltipStatus.textContent = 'Rate Limited'; } else if (bridgingFree) { tooltipStatus.textContent = 'Running (Spotify Free)'; } - else if (budgetExhausted) { tooltipStatus.textContent = 'Daily Limit Reached'; } + else if (budgetStuck) { tooltipStatus.textContent = 'Daily Limit Reached'; } else if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running) { tooltipStatus.textContent = 'Running'; } else { tooltipStatus.textContent = 'Idle'; } @@ -502,7 +504,7 @@ function updateSpotifyEnrichmentStatusFromData(data) { tooltipCurrent.textContent = remaining > 0 ? `Waiting ${Math.ceil(remaining / 60)}m for rate limit to clear` : 'Waiting for rate limit to clear'; } else if (bridgingFree && data.current_item && data.current_item.name) { tooltipCurrent.textContent = `Now: ${data.current_item.name} (via Spotify Free)`; - } else if (budgetExhausted) { + } else if (budgetStuck) { const resets = data.daily_budget.resets_in_seconds || 0; const hours = Math.floor(resets / 3600); const mins = Math.floor((resets % 3600) / 60);