Show "Running (Spotify Free)" instead of "rate limited" while the worker bridges

#798 follow-up. When the real Spotify API is banned but the worker keeps
matching via the no-creds Spotify Free source, every status surface still
read the literal rate_limited=True flag and showed "Rate Limited /
waiting Nm" — so the dashboard bubble looked paused/stuck even though the
worker (visible in Manage Workers) was actively matching.

- spotify_worker.get_stats() adds a `using_free` flag: rate_limited AND
  is_spotify_metadata_available(). Computed ONLY when rate-limited, where
  is_spotify_authenticated() returns False without an API probe, so the
  2s status loop pays no quota cost.
- Dashboard bubble (enrichment.js): when using_free, the bubble is
  'active', the tooltip says "Running (Spotify Free)" and "Now: X (via
  Spotify Free)" instead of "Rate Limited / Waiting Nm". Clicking it
  pauses (works) rather than hitting the resume-blocked toast.
- Manage Workers (enrichment-manager.js): status pill shows "Running
  (Spotify Free)"; the warning banner is replaced with a calm "matching
  via Spotify Free until the ban lifts" note.

The flag flows through both feeds (the /api/enrichment/spotify/status
poll and the WebSocket enrichment:* push) since both serialize
get_stats(). Genuinely-stuck (no-free) workers still show "Rate Limited".
This commit is contained in:
BoulderBadgeDad 2026-06-05 22:08:07 -07:00
parent 12038bde08
commit 3fcfa900bd
3 changed files with 25 additions and 3 deletions

View file

@ -126,11 +126,19 @@ 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())
except Exception:
authenticated = False
rate_limited = False
rate_limit_info = None
in_cooldown = False
using_free = False
return {
'enabled': True,
@ -139,6 +147,7 @@ class SpotifyWorker:
'idle': is_idle,
'authenticated': authenticated,
'rate_limited': rate_limited,
'using_free': using_free,
'rate_limit': rate_limit_info,
'daily_budget': self._get_daily_budget_info(),
'current_item': self.current_item,

View file

@ -274,6 +274,9 @@ async function _emPollSelected() {
function _emStatusInfo(status) {
if (!status || !status.enabled) return { cls: 'disabled', label: 'Disabled' };
// Rate-limited on the real API but still matching via the no-creds Spotify
// Free source — show as running, not stuck (#798 bridge).
if (status.using_free) return { cls: 'running', label: 'Running (Spotify Free)' };
if (status.rate_limited) return { cls: 'ratelimited', label: 'Rate-limited' };
if (status.paused) return { cls: 'paused', label: 'Paused' };
if (status.idle) return { cls: 'idle', label: 'Idle' };
@ -709,6 +712,9 @@ function _emUpdateHeaderLive() {
cls += ' em-banner--warn';
html = '⚙️ This source isnt configured — add its credentials in Settings. '
+ 'Browsing works, but matches and retries wont run until its set up.';
} else if (status && status.using_free) {
// Real API banned but bridging via the no-creds Spotify Free source.
html = '✓ Spotify is rate-limited — matching via Spotify Free until the ban lifts.';
} else if (status && status.rate_limited) {
cls += ' em-banner--warn';
const rl = status.rate_limit || {};

View file

@ -457,6 +457,10 @@ function updateSpotifyEnrichmentStatusFromData(data) {
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).
const bridgingFree = data.using_free === true;
const rateLimitedStuck = isRateLimited && !bridgingFree;
const budgetExhausted = data.daily_budget && data.daily_budget.exhausted;
button.classList.remove('active', 'paused', 'complete', 'no-auth');
@ -464,7 +468,7 @@ function updateSpotifyEnrichmentStatusFromData(data) {
button.classList.add('paused');
} else if (notAuthenticated) {
button.classList.add('no-auth');
} else if (isRateLimited || budgetExhausted) {
} else if (rateLimitedStuck || budgetExhausted) {
button.classList.add('paused');
} else if (data.idle) {
button.classList.add('complete');
@ -479,7 +483,8 @@ function updateSpotifyEnrichmentStatusFromData(data) {
if (tooltipStatus) {
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
else if (isRateLimited) { tooltipStatus.textContent = 'Rate Limited'; }
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 (data.idle) { tooltipStatus.textContent = 'Complete'; }
else if (data.running) { tooltipStatus.textContent = 'Running'; }
@ -491,10 +496,12 @@ function updateSpotifyEnrichmentStatusFromData(data) {
tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume';
} else if (notAuthenticated) {
tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich';
} else if (isRateLimited) {
} else if (rateLimitedStuck) {
const info = data.rate_limit || {};
const remaining = info.remaining_seconds || 0;
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) {
const resets = data.daily_budget.resets_in_seconds || 0;
const hours = Math.floor(resets / 3600);