Add daily budget to Spotify enrichment worker to prevent rate limit bans

The background enrichment worker now caps itself at 3,000 processed items
per calendar day. Counter resets at midnight automatically. When exhausted,
the worker sleeps and checks every 5 minutes for a new day.

This is scoped entirely to the enrichment worker — user-initiated Spotify
API calls (searches, playlist ops, album lookups, etc.) are completely
unaffected. Budget status is exposed in the worker's get_stats() response
for the dashboard widget.
This commit is contained in:
Broque Thomas 2026-03-29 15:34:41 -07:00
parent 0ffef39853
commit 3f866ebf5e
4 changed files with 59 additions and 2 deletions

View file

@ -46,7 +46,6 @@ _ESCALATION_MAX = 14400 # 4 hours max ban
_BASE_UNKNOWN_BAN = 1800 # 30 min default when Retry-After header is missing
_BASE_MAX_RETRIES_BAN = 3600 # 1 hour default when spotipy exhausted all retries
class SpotifyRateLimitError(Exception):
"""Raised when Spotify API calls are blocked due to active global rate limit ban."""
def __init__(self, retry_after, endpoint=None):
@ -619,6 +618,7 @@ class SpotifyClient:
"""Get remaining seconds in post-ban cooldown, or 0 if not in cooldown."""
return _get_post_ban_cooldown_remaining()
def _ensure_user_id(self) -> bool:
"""Ensure user_id is loaded (may make API call)"""
if self.user_id is None and self.sp is not None:

View file

@ -4,7 +4,7 @@ import threading
import time
from difflib import SequenceMatcher
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from datetime import datetime, date, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.spotify_client import SpotifyClient, SpotifyRateLimitError
@ -53,6 +53,11 @@ class SpotifyWorker:
self.inter_item_sleep = 0.5 # Between top-level items
self.batch_inter_item_sleep = 0.1 # Between local matches within a batch (no API calls)
# Daily budget — caps how many items this worker processes per calendar day
self.daily_budget = 3000
self._daily_items_processed = 0
self._daily_date = date.today()
logger.info("Spotify background worker initialized")
def start(self):
@ -116,11 +121,42 @@ class SpotifyWorker:
'idle': is_idle,
'authenticated': authenticated,
'rate_limited': rate_limited,
'daily_budget': self._get_daily_budget_info(),
'current_item': self.current_item,
'stats': self.stats.copy(),
'progress': progress
}
# ── Daily budget ──────────────────────────────────────────────────
def _increment_daily_budget(self):
"""Increment the daily processed counter, resetting on a new calendar day."""
today = date.today()
if self._daily_date != today:
self._daily_items_processed = 0
self._daily_date = today
self._daily_items_processed += 1
def _is_daily_budget_exhausted(self) -> bool:
today = date.today()
if self._daily_date != today:
return False
return self._daily_items_processed >= self.daily_budget
def _get_daily_budget_info(self) -> dict:
today = date.today()
used = self._daily_items_processed if self._daily_date == today else 0
now = datetime.now()
midnight = datetime.combine(today + timedelta(days=1), datetime.min.time())
resets_in = int((midnight - now).total_seconds())
return {
'used': used,
'limit': self.daily_budget,
'remaining': max(0, self.daily_budget - used),
'exhausted': used >= self.daily_budget,
'resets_in_seconds': resets_in
}
# ── Main loop ──────────────────────────────────────────────────────
def _run(self):
@ -139,6 +175,15 @@ class SpotifyWorker:
time.sleep(min(remaining, 60)) # Check again every 60s max
continue
# Daily budget guard — worker-only cap to avoid saturating Spotify rate limits
if self._is_daily_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']}), "
f"resets in {resets_in // 3600}h {(resets_in % 3600) // 60}m")
time.sleep(min(resets_in, 300)) # Check every 5 min max
continue
# Post-ban cooldown guard — after ban expires, wait before resuming
# to avoid immediately re-triggering the rate limit
cooldown = self.client.get_post_ban_cooldown_remaining()
@ -180,6 +225,7 @@ class SpotifyWorker:
continue
self._process_item(item)
self._increment_daily_budget()
time.sleep(self.inter_item_sleep)
except SpotifyRateLimitError:

View file

@ -18981,6 +18981,16 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "🛡️ Spotify Enrichment Daily Budget",
"description": "The background enrichment worker now caps itself at 3,000 items per day to prevent rate limit bans",
"features": [
"• Worker-only daily budget — user-initiated searches, playlist operations, etc. are unaffected",
"• Counter resets automatically at midnight each day",
"• Worker sleeps when budget is exhausted and resumes the next day",
"• Budget status exposed in the enrichment worker dashboard widget"
]
},
{
"title": "🎧 Deezer Download Source",
"description": "Download music directly from Deezer with ARL authentication",

View file

@ -3403,6 +3403,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.1': [
// Newest features first
{ title: 'Spotify Enrichment Budget', desc: 'Background enrichment worker caps at 3,000 items/day to prevent rate limit bans — resets at midnight' },
{ title: 'Per-Artist Library Sync', desc: 'Validate files and clean stale entries per artist from the enhanced library view', page: 'library', selector: '.library-controls' },
{ title: 'Collaborative Album Handling', desc: 'Smart folder naming for multi-artist albums — uses first listed artist', page: 'settings', selector: '#collab-artist-mode' },
{ title: 'Accurate Completion Badges', desc: 'Exact track counts, deduplication, multi-artist and censored title matching', page: 'artists', selector: '#artists-search-input' },