diff --git a/core/spotify_client.py b/core/spotify_client.py index bbcab7e5..bd715de5 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -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: diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 94e13138..5386ce49 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -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: diff --git a/web_server.py b/web_server.py index da318e60..d389a5f2 100644 --- a/web_server.py +++ b/web_server.py @@ -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", diff --git a/webui/static/helper.js b/webui/static/helper.js index 8c064f86..3c310e8f 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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' },