soulsync/core/repair_jobs/base.py
Broque Thomas 2003e58358 Add Spotify rate limit guards to all repair jobs
Repair jobs were making Spotify API calls without checking the global
rate limit ban, causing them to churn through every item getting rejected
one by one during a ban. Now all Spotify calls in repair jobs check
context.is_spotify_rate_limited() first and skip/fallback gracefully.

- Add is_spotify_rate_limited() helper to JobContext (base.py)
- Guard calls in track_number_repair, album_completeness,
  missing_cover_art, and metadata_gap_filler
- Jobs fall through to iTunes/MusicBrainz fallbacks when rate-limited
2026-03-15 16:01:48 -07:00

96 lines
3.1 KiB
Python

"""Base classes for the multi-job Library Maintenance Worker."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
@dataclass
class JobResult:
"""Result of a single job scan run."""
scanned: int = 0
findings_created: int = 0
auto_fixed: int = 0
errors: int = 0
skipped: int = 0
@dataclass
class JobContext:
"""Shared resources passed to every repair job during execution."""
db: Any # MusicDatabase instance
transfer_folder: str # Resolved transfer folder path
config_manager: Any # ConfigManager instance
# API clients (may be None if unavailable)
spotify_client: Any = None
itunes_client: Any = None
mb_client: Any = None
acoustid_client: Any = None
metadata_cache: Any = None
# Callbacks
create_finding: Optional[Callable] = None
should_stop: Optional[Callable[[], bool]] = None
is_paused: Optional[Callable[[], bool]] = None
update_progress: Optional[Callable[[int, int], None]] = None
report_progress: Optional[Callable] = None # Rich progress: (phase, log_line, log_type, scanned, total)
def check_stop(self) -> bool:
"""Return True if the worker should stop."""
return self.should_stop() if self.should_stop else False
def is_spotify_rate_limited(self) -> bool:
"""Check if Spotify is currently under a global rate limit ban.
Jobs should call this before making Spotify API calls in their
scan loops to avoid churning through items uselessly.
"""
try:
from core.spotify_client import SpotifyClient
return SpotifyClient.is_rate_limited()
except Exception:
return False
def wait_if_paused(self):
"""Block until unpaused or stopped. Returns True if should stop."""
import time
while self.is_paused and self.is_paused():
if self.check_stop():
return True
time.sleep(1)
return self.check_stop()
class RepairJob(ABC):
"""Abstract base class for all repair jobs."""
# Subclasses MUST set these class attributes
job_id: str = ''
display_name: str = ''
description: str = ''
help_text: str = '' # Extended explanation shown in the info modal
icon: str = ''
default_enabled: bool = False
default_interval_hours: int = 24
default_settings: Dict[str, Any] = {}
auto_fix: bool = False
@abstractmethod
def scan(self, context: JobContext) -> JobResult:
"""Execute the job scan. Must be implemented by each job.
Should periodically call context.check_stop() and
context.wait_if_paused() to respect worker lifecycle.
"""
...
def estimate_scope(self, context: JobContext) -> int:
"""Optional: return estimated total items for progress bar.
Return 0 if unknown."""
return 0
def get_config_key(self, setting: str) -> str:
"""Get the full config key path for a job setting."""
return f"repair.jobs.{self.job_id}.{setting}"