From ce474749d546a7fc1a0a385ffbda76ab0d7a680d Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sat, 21 Feb 2026 15:00:23 -0800 Subject: [PATCH] Add library repair worker and UI Introduce a RepairWorker to scan the transfer folder and automatically detect/repair broken album track numbers (e.g. the "all tracks = 01" bug). The worker uses mutagen to read/write tags, fuzzy-matches titles against an album tracklist (Spotify/iTunes via a SpotifyClient), updates filenames and the tracks DB file_path when renamed, and caches album tracklists. It also adds DB schema support (repair_status, repair_last_checked, and an index). Integrates the worker into the web server: initializes and starts the worker, and exposes /api/repair/status, /api/repair/pause and /api/repair/resume endpoints. Adds UI elements (button, tooltip), client-side JS to poll and control the worker, CSS for visuals/animations, and a new image asset (whisoul.png). --- core/repair_worker.py | 710 +++++++++++++++++++++++++++++++++++++ database/music_database.py | 12 + web_server.py | 73 ++++ webui/index.html | 22 ++ webui/static/script.js | 111 ++++++ webui/static/style.css | 267 ++++++++++++++ webui/static/whisoul.png | Bin 0 -> 609912 bytes 7 files changed, 1195 insertions(+) create mode 100644 core/repair_worker.py create mode 100644 webui/static/whisoul.png diff --git a/core/repair_worker.py b/core/repair_worker.py new file mode 100644 index 00000000..04e3c172 --- /dev/null +++ b/core/repair_worker.py @@ -0,0 +1,710 @@ +import os +import re +import threading +import time +from difflib import SequenceMatcher +from typing import Optional, Dict, Any, List, Tuple +from datetime import datetime, timedelta +from utils.logging_config import get_logger +from database.music_database import MusicDatabase +from config.settings import config_manager + +logger = get_logger("repair_worker") + +AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'} + + +class RepairWorker: + """Background worker for scanning and repairing library files. + + Currently supports: + - Track number repair: fixes embedded tracknumber tags and filename + prefixes using the album tracklist from Spotify/iTunes as the + authoritative source. + + Designed to be extended with additional repair types over time. + """ + + def __init__(self, database: MusicDatabase, transfer_folder: str = None): + self.db = database + + # Resolve transfer folder + if transfer_folder: + self.transfer_folder = transfer_folder + else: + raw = config_manager.get('soulseek.transfer_path', './Transfer') + # docker_resolve_path is in web_server — keep it simple here + self.transfer_folder = raw + + # Worker state + self.running = False + self.paused = False + self.should_stop = False + self.thread = None + + # Current item being processed (for UI tooltip) + self.current_item = None + + # Statistics + self.stats = { + 'scanned': 0, + 'repaired': 0, + 'skipped': 0, + 'errors': 0, + 'pending': 0 + } + + # How often to re-scan the full library (hours) + self.rescan_interval_hours = 24 + + # Album tracks cache: album_id -> list of track dicts + self._album_tracks_cache: Dict[str, List[Dict]] = {} + + # Title matching threshold + self.title_similarity_threshold = 0.80 + + # SpotifyClient (lazy-init to avoid circular imports) + self._spotify_client = None + + logger.info("Repair worker initialized (transfer_folder=%s)", self.transfer_folder) + + # ------------------------------------------------------------------ + # Lazy SpotifyClient accessor + # ------------------------------------------------------------------ + @property + def spotify_client(self): + if self._spotify_client is None: + try: + from core.spotify_client import SpotifyClient + self._spotify_client = SpotifyClient() + except Exception as e: + logger.error("Failed to initialize SpotifyClient: %s", e) + return self._spotify_client + + # ------------------------------------------------------------------ + # Lifecycle (identical to AudioDB worker) + # ------------------------------------------------------------------ + def start(self): + if self.running: + logger.warning("Repair worker already running") + return + self.running = True + self.should_stop = False + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + logger.info("Repair worker started") + + def stop(self): + if not self.running: + return + logger.info("Stopping repair worker...") + self.should_stop = True + self.running = False + if self.thread: + self.thread.join(timeout=5) + logger.info("Repair worker stopped") + + def pause(self): + if not self.running: + logger.warning("Repair worker not running, cannot pause") + return + self.paused = True + logger.info("Repair worker paused") + + def resume(self): + if not self.running: + logger.warning("Repair worker not running, start it first") + return + self.paused = False + logger.info("Repair worker resumed") + + def get_stats(self) -> Dict[str, Any]: + is_actually_running = self.running and (self.thread is not None and self.thread.is_alive()) + is_idle = ( + is_actually_running + and not self.paused + and self.stats['pending'] == 0 + and self.current_item is None + ) + return { + 'enabled': True, + 'running': is_actually_running and not self.paused, + 'paused': self.paused, + 'idle': is_idle, + 'current_item': self.current_item, + 'stats': self.stats.copy(), + 'progress': self._get_progress() + } + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + def _run(self): + logger.info("Repair worker thread started") + + while not self.should_stop: + try: + if self.paused: + time.sleep(1) + continue + + self.current_item = None + + # Reset stats for a new scan pass + self.stats = { + 'scanned': 0, + 'repaired': 0, + 'skipped': 0, + 'errors': 0, + 'pending': 0 + } + self._album_tracks_cache.clear() + + self._scan_library() + + # Done scanning — go idle until next interval + self.current_item = None + self.stats['pending'] = 0 + logger.info( + "Repair scan complete. Scanned=%d Repaired=%d Skipped=%d Errors=%d", + self.stats['scanned'], self.stats['repaired'], + self.stats['skipped'], self.stats['errors'] + ) + + # Sleep until next scan (check should_stop / paused periodically) + sleep_until = time.time() + self.rescan_interval_hours * 3600 + while time.time() < sleep_until and not self.should_stop: + if self.paused: + time.sleep(1) + continue + time.sleep(10) + + except Exception as e: + logger.error("Error in repair worker loop: %s", e, exc_info=True) + time.sleep(30) + + logger.info("Repair worker thread finished") + + # ------------------------------------------------------------------ + # Library scanning + # ------------------------------------------------------------------ + def _scan_library(self): + """Walk the transfer folder and process album folders.""" + transfer = self.transfer_folder + if not os.path.isdir(transfer): + logger.warning("Transfer folder does not exist: %s", transfer) + return + + # Collect album folders (directories containing audio files) + album_folders: Dict[str, List[str]] = {} + + for root, _dirs, files in os.walk(transfer): + if self.should_stop: + return + for fname in files: + ext = os.path.splitext(fname)[1].lower() + if ext in AUDIO_EXTENSIONS: + album_folders.setdefault(root, []).append(fname) + + self.stats['pending'] = len(album_folders) + logger.info("Found %d album folders to scan", len(album_folders)) + + for folder_path, filenames in album_folders.items(): + if self.should_stop: + return + if self.paused: + while self.paused and not self.should_stop: + time.sleep(1) + if self.should_stop: + return + + folder_name = os.path.basename(folder_path) + self.current_item = {'type': 'album', 'name': folder_name} + + try: + self._repair_album_track_numbers(folder_path, filenames) + except Exception as e: + logger.error("Error processing album folder %s: %s", folder_path, e, exc_info=True) + self.stats['errors'] += 1 + + self.stats['pending'] -= 1 + time.sleep(1) # Rate limit for API calls + + # ------------------------------------------------------------------ + # Album-level track number repair + # ------------------------------------------------------------------ + def _repair_album_track_numbers(self, folder_path: str, filenames: List[str]): + """Repair track numbers for all files in an album folder. + + Targeting logic: + 1. Read the track number tag from every file in the folder. + 2. Count how many files share the same track number. + 3. If 3+ files have the same track number, the album is flagged + as broken (the "all tracks = 01" bug pattern). + Threshold is 3 (not 2) because lossy-copy mode can legitimately + produce two files with the same track number in different qualities. + 4. Only then do we spend an API call to get the correct tracklist + and repair each file. + """ + from mutagen import File as MutagenFile + + # --- Step 0: Anomaly detection — are track numbers broken? --- + track_num_counts: Dict[int, int] = {} # track_number -> count of files with that number + file_track_data: List[Tuple[str, str, Optional[int]]] = [] # (path, filename, track_num) + + for fname in filenames: + fpath = os.path.join(folder_path, fname) + try: + audio = MutagenFile(fpath) + if audio is None: + file_track_data.append((fpath, fname, None)) + continue + track_num, _ = self._read_track_number_tag(audio) + file_track_data.append((fpath, fname, track_num)) + if track_num is not None: + track_num_counts[track_num] = track_num_counts.get(track_num, 0) + 1 + except Exception: + file_track_data.append((fpath, fname, None)) + + # Check if any single track number appears on 3+ files + has_anomaly = any(count >= 3 for count in track_num_counts.values()) + + if not has_anomaly: + # Album looks fine — count as scanned, no repair needed + self.stats['scanned'] += len(filenames) + return + + # Log which track number(s) are duplicated + duped = {num: cnt for num, cnt in track_num_counts.items() if cnt >= 3} + logger.info( + "Anomaly detected in %s — %d files share track number(s): %s", + os.path.basename(folder_path), sum(duped.values()), duped + ) + + # --- Step 1: Read album ID from file tags --- + album_id = None + id_source = None + for fpath, fname, _ in file_track_data: + album_id, id_source = self._read_album_id_from_file(fpath) + if album_id: + break + + if not album_id: + logger.debug("No album ID found in any file in %s — skipping", folder_path) + self.stats['skipped'] += len(filenames) + self.stats['scanned'] += len(filenames) + return + + # --- Step 2: Fetch album tracklist from API (with cache) --- + api_tracks = self._get_album_tracklist(album_id) + if not api_tracks: + logger.debug("API returned no tracks for album %s — skipping %s", album_id, folder_path) + self.stats['skipped'] += len(filenames) + self.stats['scanned'] += len(filenames) + return + + # --- Step 3-5: Process each file --- + for fpath, fname, _ in file_track_data: + if self.should_stop: + return + + self.stats['scanned'] += 1 + + try: + self._repair_single_track(fpath, fname, api_tracks) + except Exception as e: + logger.error("Error repairing %s: %s", fpath, e, exc_info=True) + self.stats['errors'] += 1 + + def _repair_single_track(self, file_path: str, filename: str, api_tracks: List[Dict]): + """Check and repair a single track's track number.""" + from mutagen import File as MutagenFile + from mutagen.id3 import ID3, TRCK + from mutagen.flac import FLAC + from mutagen.oggvorbis import OggVorbis + from mutagen.mp4 import MP4 + + # Rigid check: file must exist + if not os.path.isfile(file_path): + logger.debug("File missing: %s", file_path) + self.stats['skipped'] += 1 + return + + # Rigid check: must be audio + ext = os.path.splitext(filename)[1].lower() + if ext not in AUDIO_EXTENSIONS: + self.stats['skipped'] += 1 + return + + # Read current metadata + audio = MutagenFile(file_path) + if audio is None: + logger.debug("Mutagen cannot open: %s", file_path) + self.stats['skipped'] += 1 + return + + current_title = self._read_title_tag(audio) + current_track_num, current_total = self._read_track_number_tag(audio) + filename_track_num = self._extract_track_number_from_filename(filename) + + if not current_title: + logger.debug("No title tag in %s — skipping", filename) + self.stats['skipped'] += 1 + return + + # Match against API tracklist + matched_track = self._match_title_to_api_track(current_title, api_tracks) + if not matched_track: + logger.debug("No API match for title '%s' in %s — skipping", current_title, filename) + self.stats['skipped'] += 1 + return + + correct_track_num = matched_track.get('track_number') + if correct_track_num is None: + logger.debug("API track has no track_number for '%s' — skipping", current_title) + self.stats['skipped'] += 1 + return + + # Compare + metadata_wrong = (current_track_num != correct_track_num) + filename_wrong = (filename_track_num is not None and filename_track_num != correct_track_num) + + if not metadata_wrong and not filename_wrong: + # Everything correct + return + + # Determine total_tracks for the tag + total_tracks = current_total or len(api_tracks) + + logger.info( + "Repairing track: %s — correct=#%d, current_tag=#%s, current_filename=#%s", + filename, correct_track_num, current_track_num, filename_track_num + ) + + # Step 5a: Fix metadata tag + if metadata_wrong: + self._fix_track_number_tag(file_path, audio, correct_track_num, total_tracks) + + # Step 5b: Fix filename + if filename_wrong: + new_path = self._fix_filename_track_number(file_path, filename, correct_track_num) + if new_path: + # Update DB file_path if tracked + self._update_db_file_path(file_path, new_path) + + self.stats['repaired'] += 1 + + # ------------------------------------------------------------------ + # Tag reading helpers + # ------------------------------------------------------------------ + def _read_album_id_from_file(self, file_path: str) -> Tuple[Optional[str], Optional[str]]: + """Read SPOTIFY_ALBUM_ID or ITUNES_ALBUM_ID from embedded tags. + Returns (album_id, source) where source is 'spotify' or 'itunes'.""" + try: + from mutagen import File as MutagenFile + from mutagen.id3 import ID3 + from mutagen.flac import FLAC + from mutagen.oggvorbis import OggVorbis + from mutagen.mp4 import MP4 + + audio = MutagenFile(file_path) + if audio is None: + return None, None + + # MP3 (ID3) — TXXX frames + if hasattr(audio, 'tags') and audio.tags is not None: + if isinstance(audio.tags, ID3): + for key in ['TXXX:SPOTIFY_ALBUM_ID', 'TXXX:spotify_album_id']: + frame = audio.tags.getall(key) + if frame and frame[0].text: + return str(frame[0].text[0]), 'spotify' + for key in ['TXXX:ITUNES_ALBUM_ID', 'TXXX:itunes_album_id']: + frame = audio.tags.getall(key) + if frame and frame[0].text: + return str(frame[0].text[0]), 'itunes' + + # FLAC / OggVorbis — VorbisComment (lowercase keys) + elif isinstance(audio, (FLAC, OggVorbis)): + for key in ['spotify_album_id', 'SPOTIFY_ALBUM_ID']: + val = audio.get(key) + if val: + return str(val[0]), 'spotify' + for key in ['itunes_album_id', 'ITUNES_ALBUM_ID']: + val = audio.get(key) + if val: + return str(val[0]), 'itunes' + + # MP4/M4A — freeform tags + elif isinstance(audio, MP4): + for key in ['----:com.apple.iTunes:SPOTIFY_ALBUM_ID', + '----:com.apple.iTunes:spotify_album_id']: + val = audio.tags.get(key) + if val: + raw = val[0] + return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw), 'spotify' + for key in ['----:com.apple.iTunes:ITUNES_ALBUM_ID', + '----:com.apple.iTunes:itunes_album_id']: + val = audio.tags.get(key) + if val: + raw = val[0] + return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw), 'itunes' + + except Exception as e: + logger.debug("Error reading album ID from %s: %s", file_path, e) + + return None, None + + def _read_title_tag(self, audio) -> Optional[str]: + """Read the title tag from an already-opened Mutagen file.""" + from mutagen.id3 import ID3 + from mutagen.flac import FLAC + from mutagen.oggvorbis import OggVorbis + from mutagen.mp4 import MP4 + + try: + if hasattr(audio, 'tags') and audio.tags is not None: + if isinstance(audio.tags, ID3): + frames = audio.tags.getall('TIT2') + if frames and frames[0].text: + return str(frames[0].text[0]) + elif isinstance(audio, (FLAC, OggVorbis)): + val = audio.get('title') + if val: + return str(val[0]) + elif isinstance(audio, MP4): + val = audio.tags.get('\xa9nam') + if val: + return str(val[0]) + except Exception as e: + logger.debug("Error reading title tag: %s", e) + return None + + def _read_track_number_tag(self, audio) -> Tuple[Optional[int], Optional[int]]: + """Read track number and total from tags. Returns (track_num, total).""" + from mutagen.id3 import ID3 + from mutagen.flac import FLAC + from mutagen.oggvorbis import OggVorbis + from mutagen.mp4 import MP4 + + try: + if hasattr(audio, 'tags') and audio.tags is not None: + if isinstance(audio.tags, ID3): + frames = audio.tags.getall('TRCK') + if frames and frames[0].text: + return self._parse_track_str(str(frames[0].text[0])) + elif isinstance(audio, (FLAC, OggVorbis)): + val = audio.get('tracknumber') + if val: + return self._parse_track_str(str(val[0])) + elif isinstance(audio, MP4): + val = audio.tags.get('trkn') + if val and val[0]: + t = val[0] + return (int(t[0]), int(t[1]) if t[1] else None) + except Exception as e: + logger.debug("Error reading track number tag: %s", e) + return None, None + + @staticmethod + def _parse_track_str(s: str) -> Tuple[Optional[int], Optional[int]]: + """Parse '5/12' or '5' into (track_num, total).""" + try: + if '/' in s: + parts = s.split('/') + return int(parts[0]), int(parts[1]) + return int(s), None + except (ValueError, IndexError): + return None, None + + @staticmethod + def _extract_track_number_from_filename(filename: str) -> Optional[int]: + """Extract leading track number from filename like '01 - Song.flac'.""" + basename = os.path.splitext(filename)[0] + match = re.match(r'^(\d{1,3})', basename.strip()) + if match: + return int(match.group(1)) + return None + + # ------------------------------------------------------------------ + # API lookup + # ------------------------------------------------------------------ + def _get_album_tracklist(self, album_id: str) -> Optional[List[Dict]]: + """Fetch album tracks from Spotify/iTunes API with caching.""" + if album_id in self._album_tracks_cache: + return self._album_tracks_cache[album_id] + + client = self.spotify_client + if not client: + logger.warning("No SpotifyClient available for album lookup") + return None + + try: + result = client.get_album_tracks(album_id) + if not result or 'items' not in result: + logger.debug("No tracks returned for album %s", album_id) + self._album_tracks_cache[album_id] = None + return None + + tracks = [] + for item in result['items']: + tracks.append({ + 'name': item.get('name', ''), + 'track_number': item.get('track_number'), + 'disc_number': item.get('disc_number', 1), + }) + + self._album_tracks_cache[album_id] = tracks + return tracks + + except Exception as e: + logger.error("Error fetching album tracks for %s: %s", album_id, e) + return None + + # ------------------------------------------------------------------ + # Title matching + # ------------------------------------------------------------------ + def _match_title_to_api_track(self, file_title: str, api_tracks: List[Dict]) -> Optional[Dict]: + """Fuzzy-match a file title to an API track. Returns the best match or None.""" + norm_file = self._normalize_title(file_title) + best_match = None + best_score = 0.0 + + for track in api_tracks: + api_name = track.get('name', '') + norm_api = self._normalize_title(api_name) + score = SequenceMatcher(None, norm_file, norm_api).ratio() + if score > best_score: + best_score = score + best_match = track + + if best_score >= self.title_similarity_threshold: + return best_match + return None + + @staticmethod + def _normalize_title(title: str) -> str: + """Normalize a title for comparison: lowercase, strip parentheticals, punctuation.""" + t = title.lower() + t = re.sub(r'\(.*?\)', '', t) + t = re.sub(r'\[.*?\]', '', t) + t = re.sub(r'[^a-z0-9 ]', '', t) + return t.strip() + + # ------------------------------------------------------------------ + # Repair actions + # ------------------------------------------------------------------ + def _fix_track_number_tag(self, file_path: str, audio, correct_num: int, total: int): + """Update ONLY the track number tag in the file. Touches nothing else.""" + from mutagen import File as MutagenFile + from mutagen.id3 import ID3, TRCK + from mutagen.flac import FLAC + from mutagen.oggvorbis import OggVorbis + from mutagen.mp4 import MP4 + + try: + # Re-open file fresh to avoid stale state + audio = MutagenFile(file_path) + if audio is None: + logger.error("Cannot re-open file for tag fix: %s", file_path) + return + + track_str = f"{correct_num}/{total}" + + if isinstance(audio.tags, ID3): + # Remove existing TRCK, add new one + audio.tags.delall('TRCK') + audio.tags.add(TRCK(encoding=3, text=[track_str])) + audio.save(v1=0, v2_version=4) + + elif isinstance(audio, (FLAC, OggVorbis)): + audio['tracknumber'] = [track_str] + if isinstance(audio, FLAC): + audio.save(deleteid3=True) + else: + audio.save() + + elif isinstance(audio, MP4): + audio['trkn'] = [(correct_num, total)] + audio.save() + + logger.info("Fixed track tag: %s → %s", os.path.basename(file_path), track_str) + + except Exception as e: + logger.error("Error fixing track tag in %s: %s", file_path, e, exc_info=True) + self.stats['errors'] += 1 + + def _fix_filename_track_number(self, file_path: str, filename: str, correct_num: int) -> Optional[str]: + """Fix the track number prefix in a filename. Returns new path or None.""" + try: + basename = os.path.splitext(filename)[0] + ext = os.path.splitext(filename)[1] + + # Replace leading digits + new_basename = re.sub(r'^\d{1,3}', f'{correct_num:02d}', basename) + if new_basename == basename: + # No change needed (shouldn't happen if filename_wrong was True) + return None + + new_filename = new_basename + ext + parent_dir = os.path.dirname(file_path) + new_path = os.path.join(parent_dir, new_filename) + + # Rigid checks + if not os.path.isfile(file_path): + logger.error("Source file disappeared before rename: %s", file_path) + return None + + if os.path.exists(new_path): + logger.warning("Target path already exists, skipping rename: %s", new_path) + self.stats['skipped'] += 1 + return None + + os.rename(file_path, new_path) + logger.info("Renamed: %s → %s", filename, new_filename) + return new_path + + except Exception as e: + logger.error("Error renaming %s: %s", file_path, e, exc_info=True) + self.stats['errors'] += 1 + return None + + # ------------------------------------------------------------------ + # DB helpers + # ------------------------------------------------------------------ + def _update_db_file_path(self, old_path: str, new_path: str): + """Update file_path in tracks table if this track is tracked.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE file_path = ?", + (new_path, old_path) + ) + if cursor.rowcount > 0: + conn.commit() + logger.debug("Updated DB file_path: %s → %s", old_path, new_path) + else: + conn.commit() + except Exception as e: + logger.debug("Error updating DB file_path: %s", e) + finally: + if conn: + conn.close() + + # ------------------------------------------------------------------ + # Progress + # ------------------------------------------------------------------ + def _get_progress(self) -> Dict[str, Any]: + total = self.stats['scanned'] + self.stats['pending'] + percent = round(self.stats['scanned'] / total * 100) if total > 0 else 0 + return { + 'tracks': { + 'total': total, + 'checked': self.stats['scanned'], + 'repaired': self.stats['repaired'], + 'ok': self.stats['scanned'] - self.stats['repaired'] - self.stats['skipped'] - self.stats['errors'], + 'skipped': self.stats['skipped'], + 'percent': percent + } + } diff --git a/database/music_database.py b/database/music_database.py index f091441d..e594cff4 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -1202,6 +1202,18 @@ class MusicDatabase: logger.error(f"Error adding Deezer columns: {e}") # Don't raise - this is a migration, database can still function + # --- Repair worker columns --- + try: + if 'repair_status' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN repair_status TEXT") + if 'repair_last_checked' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN repair_last_checked TIMESTAMP") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_repair_status ON tracks (repair_status)") + + except Exception as e: + logger.error(f"Error adding repair columns: {e}") + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here diff --git a/web_server.py b/web_server.py index 2616e04c..b4ce98ca 100644 --- a/web_server.py +++ b/web_server.py @@ -26357,6 +26357,79 @@ def hydrabase_worker_resume(): # ================================================================================================ +# ================================================================================================ +# LIBRARY REPAIR WORKER +# ================================================================================================ + +from core.repair_worker import RepairWorker + +repair_worker = None +try: + from database.music_database import MusicDatabase + repair_db = MusicDatabase() + transfer_path = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) + repair_worker = RepairWorker(database=repair_db, transfer_folder=transfer_path) + repair_worker.start() + print("✅ Repair worker initialized and started") +except Exception as e: + print(f"⚠️ Repair worker initialization failed: {e}") + repair_worker = None + +# --- Repair Worker API Endpoints --- + +@app.route('/api/repair/status', methods=['GET']) +def repair_status(): + """Get repair worker status for UI polling""" + try: + if repair_worker is None: + return jsonify({ + 'enabled': False, + 'running': False, + 'paused': False, + 'current_item': None, + 'stats': {'scanned': 0, 'repaired': 0, 'skipped': 0, 'errors': 0, 'pending': 0}, + 'progress': {} + }), 200 + + status = repair_worker.get_stats() + return jsonify(status), 200 + except Exception as e: + logger.error(f"Error getting repair status: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/pause', methods=['POST']) +def repair_pause(): + """Pause repair worker""" + try: + if repair_worker is None: + return jsonify({'error': 'Repair worker not initialized'}), 400 + + repair_worker.pause() + logger.info("Repair worker paused via UI") + return jsonify({'status': 'paused'}), 200 + except Exception as e: + logger.error(f"Error pausing repair worker: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/resume', methods=['POST']) +def repair_resume(): + """Resume repair worker""" + try: + if repair_worker is None: + return jsonify({'error': 'Repair worker not initialized'}), 400 + + repair_worker.resume() + logger.info("Repair worker resumed via UI") + return jsonify({'status': 'running'}), 200 + except Exception as e: + logger.error(f"Error resuming repair worker: {e}") + return jsonify({'error': str(e)}), 500 + +# ================================================================================================ +# END LIBRARY REPAIR WORKER +# ================================================================================================ + + # ================================================================================================ # IMPORT / STAGING SYSTEM # ================================================================================================ diff --git a/webui/index.html b/webui/index.html index 91b456a3..55ae5de8 100644 --- a/webui/index.html +++ b/webui/index.html @@ -262,6 +262,28 @@ + +
+ + +
+
+
🔧 Library Repair
+
+
Status: Idle +
+
No active repairs +
+
Progress: 0 / 0 +
+
+
+
+