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:
parent
0ffef39853
commit
3f866ebf5e
4 changed files with 59 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue