soulsync/core/repair_jobs/base.py
BoulderBadgeDad dfa5204e0a Repair settings: dropdown for fixed-choice settings (canonical source_selection)
The canonical source_selection setting was rendering as a free-text box — easy
to typo an invalid mode. Added a generic choice mechanism so it's a dropdown:

- RepairJob.setting_options: {key: [allowed values]} (default {} — opt-in).
- CanonicalVersionResolveJob declares source_selection's three modes.
- repair_worker.get_all_job_info() includes setting_options in the job payload.
- enrichment.js renders a <select> (options prettified, current value selected)
  for any key listed in setting_options; everything else renders by value type
  as before. The save path already reads <select>.value as a string, so no
  change needed there.

Generic — any future job can get dropdowns the same way. Jobs that don't
declare setting_options are untouched (empty dict -> existing input rendering).

Tests: source_selection exposes the 3 options and its default is one of them.
23 repair-job/worker + canonical tests pass (other jobs unaffected).
2026-06-02 15:33:13 -07:00

124 lines
4.2 KiB
Python

"""Base classes for the multi-job Library Maintenance Worker."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import threading
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
findings_skipped_dedup: int = 0 # Findings the worker already had a row for
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
stop_event: Optional[threading.Event] = 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."""
if self.stop_event and self.stop_event.is_set():
return True
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."""
while self.is_paused and self.is_paused():
if self.check_stop():
return True
if self.stop_event:
self.stop_event.wait(0.2)
else:
import time
time.sleep(0.2)
return self.check_stop()
def sleep_or_stop(self, seconds: float, step: float = 0.2) -> bool:
"""Sleep in small increments so stop requests can interrupt quickly."""
if seconds <= 0:
return self.check_stop()
remaining = seconds
while remaining > 0:
if self.check_stop():
return True
chunk = min(step, remaining)
if self.stop_event:
self.stop_event.wait(chunk)
else:
import time
time.sleep(chunk)
remaining -= chunk
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] = {}
# Optional {setting_key: [allowed values]} — the UI renders a dropdown for
# these instead of a free-text box. Keys not listed render by value type.
setting_options: Dict[str, list] = {}
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}"