From 945f86c643fd3b8909d3a93a1041cc58aa530590 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:09:18 -0700 Subject: [PATCH] Library Repair Worker: multi-job background maintenance daemon with 10 jobs, findings system, and management modal --- core/repair_jobs/__init__.py | 57 + core/repair_jobs/acoustid_scanner.py | 290 ++++ core/repair_jobs/album_completeness.py | 176 ++ core/repair_jobs/base.py | 82 + core/repair_jobs/cache_evictor.py | 38 + core/repair_jobs/dead_file_cleaner.py | 107 ++ core/repair_jobs/duplicate_detector.py | 192 +++ core/repair_jobs/fake_lossless_detector.py | 260 +++ core/repair_jobs/metadata_gap_filler.py | 197 +++ core/repair_jobs/missing_cover_art.py | 171 ++ core/repair_jobs/orphan_file_detector.py | 122 ++ core/repair_jobs/track_number_repair.py | 881 ++++++++++ core/repair_worker.py | 1804 +++++++++----------- database/music_database.py | 61 + web_server.py | 199 ++- webui/index.html | 70 +- webui/static/script.js | 434 ++++- webui/static/style.css | 694 +++++++- 18 files changed, 4812 insertions(+), 1023 deletions(-) create mode 100644 core/repair_jobs/__init__.py create mode 100644 core/repair_jobs/acoustid_scanner.py create mode 100644 core/repair_jobs/album_completeness.py create mode 100644 core/repair_jobs/base.py create mode 100644 core/repair_jobs/cache_evictor.py create mode 100644 core/repair_jobs/dead_file_cleaner.py create mode 100644 core/repair_jobs/duplicate_detector.py create mode 100644 core/repair_jobs/fake_lossless_detector.py create mode 100644 core/repair_jobs/metadata_gap_filler.py create mode 100644 core/repair_jobs/missing_cover_art.py create mode 100644 core/repair_jobs/orphan_file_detector.py create mode 100644 core/repair_jobs/track_number_repair.py diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py new file mode 100644 index 00000000..1576e08d --- /dev/null +++ b/core/repair_jobs/__init__.py @@ -0,0 +1,57 @@ +"""Repair Jobs Registry — all available maintenance jobs for the Library Worker.""" + +import importlib + +from core.repair_jobs.base import RepairJob, JobContext, JobResult +from utils.logging_config import get_logger + +logger = get_logger("repair_jobs") + +# Registry populated at import time by each job module +JOB_REGISTRY: dict[str, type[RepairJob]] = {} + +_imports_done = False + + +def register_job(cls: type[RepairJob]) -> type[RepairJob]: + """Decorator to register a RepairJob subclass.""" + JOB_REGISTRY[cls.job_id] = cls + return cls + + +def get_all_jobs() -> dict[str, type[RepairJob]]: + """Return the full job registry. Ensures all job modules are imported.""" + _import_all_jobs() + return JOB_REGISTRY + + +_JOB_MODULES = [ + 'core.repair_jobs.track_number_repair', + 'core.repair_jobs.cache_evictor', + 'core.repair_jobs.orphan_file_detector', + 'core.repair_jobs.dead_file_cleaner', + 'core.repair_jobs.duplicate_detector', + 'core.repair_jobs.acoustid_scanner', + 'core.repair_jobs.missing_cover_art', + 'core.repair_jobs.metadata_gap_filler', + 'core.repair_jobs.album_completeness', + 'core.repair_jobs.fake_lossless_detector', +] + + +def _import_all_jobs(): + """Import all job modules to trigger registration. + + Each module is imported individually so that a failure in one + does not prevent the others from loading. + """ + global _imports_done + if _imports_done: + return + _imports_done = True + + for module_name in _JOB_MODULES: + try: + importlib.import_module(module_name) + except Exception as e: + logger.error("Failed to import job module %s: %s", module_name, e) diff --git a/core/repair_jobs/acoustid_scanner.py b/core/repair_jobs/acoustid_scanner.py new file mode 100644 index 00000000..e3a5cb86 --- /dev/null +++ b/core/repair_jobs/acoustid_scanner.py @@ -0,0 +1,290 @@ +"""AcoustID Background Scanner Job — fingerprints tracks to detect wrong downloads.""" + +import os +import re +import time +from difflib import SequenceMatcher +from typing import Optional + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.acoustid") + +AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'} + + +@register_job +class AcoustIDScannerJob(RepairJob): + job_id = 'acoustid_scanner' + display_name = 'AcoustID Scanner' + description = 'Fingerprints tracks to detect wrong downloads' + icon = 'repair-icon-acoustid' + default_enabled = False + default_interval_hours = 168 + default_settings = { + 'fingerprint_threshold': 0.80, + 'title_similarity': 0.70, + 'artist_similarity': 0.60, + 'batch_size': 50, + } + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + settings = self._get_settings(context) + fp_threshold = settings.get('fingerprint_threshold', 0.80) + title_threshold = settings.get('title_similarity', 0.70) + artist_threshold = settings.get('artist_similarity', 0.60) + batch_size = settings.get('batch_size', 50) + + # Get AcoustID client + acoustid_client = context.acoustid_client + if not acoustid_client: + try: + from core.acoustid_client import AcoustIDClient + acoustid_client = AcoustIDClient() + except Exception as e: + logger.warning("AcoustID client not available: %s", e) + return result + + transfer = context.transfer_folder + if not os.path.isdir(transfer): + logger.warning("Transfer folder does not exist: %s", transfer) + return result + + # Read checkpoint (last processed file path) to resume from + checkpoint = None + if context.config_manager: + checkpoint = context.config_manager.get( + f'repair.jobs.{self.job_id}.checkpoint', None + ) + + # Collect all audio files + audio_files = [] + for root, _dirs, files in os.walk(transfer): + if context.check_stop(): + return result + for fname in sorted(files): + ext = os.path.splitext(fname)[1].lower() + if ext in AUDIO_EXTENSIONS: + audio_files.append(os.path.join(root, fname)) + + # Sort for deterministic order (important for checkpoint) + audio_files.sort() + + # Skip past checkpoint if resuming + if checkpoint: + try: + idx = audio_files.index(checkpoint) + audio_files = audio_files[idx + 1:] + logger.info("Resuming AcoustID scan from checkpoint (%d files remaining)", len(audio_files)) + except ValueError: + logger.debug("Checkpoint file not found, starting from beginning") + + total = len(audio_files) + if context.update_progress: + context.update_progress(0, total) + + # Build a lookup of known tracks from DB for comparison + db_tracks = self._load_db_tracks(context) + + batch_count = 0 + for i, fpath in enumerate(audio_files): + if context.check_stop(): + # Save checkpoint before stopping + self._save_checkpoint(context, fpath) + return result + if i % 10 == 0 and context.wait_if_paused(): + self._save_checkpoint(context, fpath) + return result + + result.scanned += 1 + batch_count += 1 + + try: + self._scan_file( + fpath, acoustid_client, db_tracks, context, result, + fp_threshold, title_threshold, artist_threshold + ) + except Exception as e: + logger.debug("Error scanning %s: %s", os.path.basename(fpath), e) + result.errors += 1 + + # Rate limit: pause between batches + if batch_count >= batch_size: + batch_count = 0 + self._save_checkpoint(context, fpath) + time.sleep(2) + + if context.update_progress and (i + 1) % 10 == 0: + context.update_progress(i + 1, total) + + # Clear checkpoint on completion + self._save_checkpoint(context, None) + + if context.update_progress: + context.update_progress(total, total) + + logger.info("AcoustID scan: %d files scanned, %d mismatches found, %d errors", + result.scanned, result.findings_created, result.errors) + return result + + def _scan_file(self, fpath, acoustid_client, db_tracks, context, result, + fp_threshold, title_threshold, artist_threshold): + """Fingerprint a single file and check for mismatches.""" + fname = os.path.basename(fpath) + + # Get expected title/artist from DB or filename + expected = db_tracks.get(os.path.normpath(fpath)) + if not expected: + # Try to extract from filename: "01 - Artist - Title.flac" or "01 Title.flac" + base = os.path.splitext(fname)[0] + # Strip leading track number + base = re.sub(r'^\d{1,3}[\s.\-_]*', '', base) + expected = {'title': base, 'artist': '', 'track_id': None} + + # Fingerprint the file + try: + fp_result = acoustid_client.fingerprint_and_lookup(fpath) + except Exception as e: + logger.debug("Fingerprint failed for %s: %s", fname, e) + return + + if not fp_result or not fp_result.get('recordings'): + # No match — could be a very rare/new track + if context.create_finding: + context.create_finding( + job_id=self.job_id, + finding_type='acoustid_no_match', + severity='info', + entity_type='track', + entity_id=str(expected.get('track_id', '')), + file_path=fpath, + title=f'No AcoustID match: {fname}', + description='File could not be identified by AcoustID fingerprint', + details={ + 'expected_title': expected['title'], + 'expected_artist': expected['artist'], + } + ) + result.findings_created += 1 + return + + # Check best recording match + best_score = fp_result.get('best_score', 0) + if best_score < fp_threshold: + return # Low confidence fingerprint, skip + + # Compare best AcoustID result against expected + best_recording = fp_result['recordings'][0] + aid_title = best_recording.get('title', '') + aid_artist = best_recording.get('artist', '') + + if not aid_title: + return + + # Normalize and compare + norm_expected_title = _normalize(expected['title']) + norm_aid_title = _normalize(aid_title) + norm_expected_artist = _normalize(expected['artist']) + norm_aid_artist = _normalize(aid_artist) + + title_sim = SequenceMatcher(None, norm_expected_title, norm_aid_title).ratio() + artist_sim = SequenceMatcher(None, norm_expected_artist, norm_aid_artist).ratio() if norm_expected_artist else 1.0 + + # If both title AND artist match well, no issue + if title_sim >= title_threshold and artist_sim >= artist_threshold: + return + + # Mismatch detected + if context.create_finding: + severity = 'warning' if best_score >= 0.90 else 'info' + context.create_finding( + job_id=self.job_id, + finding_type='acoustid_mismatch', + severity=severity, + entity_type='track', + entity_id=str(expected.get('track_id', '')), + file_path=fpath, + title=f'Possible wrong download: {fname}', + description=( + f'Expected "{expected["title"]}" by {expected["artist"]}, ' + f'but fingerprint matches "{aid_title}" by {aid_artist} ' + f'(fp: {best_score:.0%}, title: {title_sim:.0%}, artist: {artist_sim:.0%})' + ), + details={ + 'expected_title': expected['title'], + 'expected_artist': expected['artist'], + 'acoustid_title': aid_title, + 'acoustid_artist': aid_artist, + 'fingerprint_score': round(best_score, 3), + 'title_similarity': round(title_sim, 3), + 'artist_similarity': round(artist_sim, 3), + } + ) + result.findings_created += 1 + + def _load_db_tracks(self, context: JobContext) -> dict: + """Load all tracks from DB keyed by normalized file_path.""" + tracks = {} + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, title, artist, file_path + FROM tracks + WHERE file_path IS NOT NULL AND file_path != '' + AND title IS NOT NULL AND title != '' + """) + for row in cursor.fetchall(): + track_id, title, artist, file_path = row + tracks[os.path.normpath(file_path)] = { + 'track_id': track_id, + 'title': title or '', + 'artist': artist or '', + } + except Exception as e: + logger.error("Error loading tracks from DB: %s", e) + finally: + if conn: + conn.close() + return tracks + + def _save_checkpoint(self, context: JobContext, fpath): + """Save or clear the scan checkpoint.""" + if context.config_manager: + context.config_manager.set( + f'repair.jobs.{self.job_id}.checkpoint', + fpath + ) + + def _get_settings(self, context: JobContext) -> dict: + if not context.config_manager: + return self.default_settings.copy() + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) + merged = self.default_settings.copy() + merged.update(cfg) + return merged + + def estimate_scope(self, context: JobContext) -> int: + transfer = context.transfer_folder + if not os.path.isdir(transfer): + return 0 + count = 0 + for _root, _dirs, files in os.walk(transfer): + for fname in files: + if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS: + count += 1 + return count + + +def _normalize(text: str) -> str: + t = text.lower() + t = re.sub(r'\(.*?\)', '', t) + t = re.sub(r'\[.*?\]', '', t) + t = re.sub(r'[^a-z0-9 ]', '', t) + return t.strip() diff --git a/core/repair_jobs/album_completeness.py b/core/repair_jobs/album_completeness.py new file mode 100644 index 00000000..f22d6c2e --- /dev/null +++ b/core/repair_jobs/album_completeness.py @@ -0,0 +1,176 @@ +"""Album Completeness Checker Job — finds albums missing tracks.""" + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.album_complete") + + +@register_job +class AlbumCompletenessJob(RepairJob): + job_id = 'album_completeness' + display_name = 'Album Completeness' + description = 'Checks if all tracks from albums are present' + icon = 'repair-icon-completeness' + default_enabled = False + default_interval_hours = 168 + default_settings = { + 'min_tracks_for_check': 3, + } + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + settings = self._get_settings(context) + min_tracks = settings.get('min_tracks_for_check', 3) + + # Fetch albums with spotify_id that have enough tracks to check + albums = [] + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT a.id, a.title, a.artist, a.spotify_id, a.total_tracks, + COUNT(t.id) as track_count + FROM albums a + LEFT JOIN tracks t ON t.album_id = a.id + WHERE a.spotify_id IS NOT NULL AND a.spotify_id != '' + GROUP BY a.id + HAVING track_count >= ? + """, (min_tracks,)) + albums = cursor.fetchall() + except Exception as e: + logger.error("Error fetching albums: %s", e, exc_info=True) + result.errors += 1 + return result + finally: + if conn: + conn.close() + + total = len(albums) + if context.update_progress: + context.update_progress(0, total) + + logger.info("Checking completeness of %d albums", total) + + for i, row in enumerate(albums): + if context.check_stop(): + return result + if i % 10 == 0 and context.wait_if_paused(): + return result + + album_id, title, artist, spotify_id, total_tracks, track_count = row + result.scanned += 1 + + # If we don't know total_tracks, try to get it from API + expected_total = total_tracks + missing_tracks = [] + + if not expected_total and context.spotify_client: + try: + album_data = context.spotify_client.get_album(spotify_id) + if album_data: + expected_total = album_data.get('total_tracks', 0) + except Exception: + pass + + if not expected_total or track_count >= expected_total: + result.skipped += 1 + if context.update_progress and (i + 1) % 5 == 0: + context.update_progress(i + 1, total) + continue + + # Album is incomplete — try to find which tracks are missing + if context.spotify_client: + try: + api_tracks = context.spotify_client.get_album_tracks(spotify_id) + if api_tracks and 'items' in api_tracks: + # Get track numbers we already have + owned_numbers = set() + conn2 = context.db._get_connection() + cursor2 = conn2.cursor() + cursor2.execute( + "SELECT track_number FROM tracks WHERE album_id = ? AND track_number IS NOT NULL", + (album_id,) + ) + for tr in cursor2.fetchall(): + owned_numbers.add(tr[0]) + conn2.close() + + for item in api_tracks['items']: + tn = item.get('track_number') + if tn and tn not in owned_numbers: + missing_tracks.append({ + 'track_number': tn, + 'name': item.get('name', ''), + 'disc_number': item.get('disc_number', 1), + }) + except Exception as e: + logger.debug("Error getting album tracks for %s: %s", spotify_id, e) + + if context.create_finding: + try: + context.create_finding( + job_id=self.job_id, + finding_type='incomplete_album', + severity='info', + entity_type='album', + entity_id=str(album_id), + file_path=None, + title=f'Incomplete: {title or "Unknown"} ({track_count}/{expected_total})', + description=( + f'Album "{title}" by {artist or "Unknown"} has {track_count} of ' + f'{expected_total} tracks' + ), + details={ + 'album_id': album_id, + 'album_title': title, + 'artist': artist, + 'spotify_id': spotify_id, + 'expected_tracks': expected_total, + 'actual_tracks': track_count, + 'missing_tracks': missing_tracks, + } + ) + result.findings_created += 1 + except Exception as e: + logger.debug("Error creating completeness finding for album %s: %s", album_id, e) + result.errors += 1 + + if context.update_progress and (i + 1) % 5 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + + logger.info("Completeness check: %d albums checked, %d incomplete found", + result.scanned, result.findings_created) + return result + + def _get_settings(self, context: JobContext) -> dict: + if not context.config_manager: + return self.default_settings.copy() + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) + merged = self.default_settings.copy() + merged.update(cfg) + return merged + + def estimate_scope(self, context: JobContext) -> int: + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COUNT(*) FROM albums + WHERE spotify_id IS NOT NULL AND spotify_id != '' + """) + row = cursor.fetchone() + return row[0] if row else 0 + except Exception: + return 0 + finally: + if conn: + conn.close() diff --git a/core/repair_jobs/base.py b/core/repair_jobs/base.py new file mode 100644 index 00000000..3002df65 --- /dev/null +++ b/core/repair_jobs/base.py @@ -0,0 +1,82 @@ +"""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 + + def check_stop(self) -> bool: + """Return True if the worker should stop.""" + return self.should_stop() if self.should_stop else 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 = '' + 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}" diff --git a/core/repair_jobs/cache_evictor.py b/core/repair_jobs/cache_evictor.py new file mode 100644 index 00000000..68e3c613 --- /dev/null +++ b/core/repair_jobs/cache_evictor.py @@ -0,0 +1,38 @@ +"""Metadata Cache Evictor Job — periodically cleans expired cache entries.""" + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.cache_evictor") + + +@register_job +class CacheEvictorJob(RepairJob): + job_id = 'cache_evictor' + display_name = 'Cache Evictor' + description = 'Removes expired metadata cache entries' + icon = 'repair-icon-cache' + default_enabled = True + default_interval_hours = 6 + default_settings = {} + auto_fix = True + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + cache = context.metadata_cache + if not cache: + logger.debug("No metadata cache available — skipping") + return result + + try: + evicted = cache.evict_expired() + result.auto_fixed = evicted + result.scanned = evicted + logger.info("Cache evictor: removed %d expired entries", evicted) + except Exception as e: + logger.error("Cache eviction failed: %s", e, exc_info=True) + result.errors = 1 + + return result diff --git a/core/repair_jobs/dead_file_cleaner.py b/core/repair_jobs/dead_file_cleaner.py new file mode 100644 index 00000000..3f0caeac --- /dev/null +++ b/core/repair_jobs/dead_file_cleaner.py @@ -0,0 +1,107 @@ +"""Dead File Cleaner Job — finds DB track entries where the file no longer exists.""" + +import os + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.dead_files") + + +@register_job +class DeadFileCleanerJob(RepairJob): + job_id = 'dead_file_cleaner' + display_name = 'Dead File Cleaner' + description = 'Finds database entries pointing to missing files' + icon = 'repair-icon-deadfile' + default_enabled = True + default_interval_hours = 24 + default_settings = {} + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + # Fetch all tracks with file paths from DB + tracks = [] + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, title, artist, album, file_path + FROM tracks + WHERE file_path IS NOT NULL AND file_path != '' + """) + tracks = cursor.fetchall() + except Exception as e: + logger.error("Error fetching tracks from DB: %s", e, exc_info=True) + result.errors += 1 + return result + finally: + if conn: + conn.close() + + total = len(tracks) + if context.update_progress: + context.update_progress(0, total) + + for i, row in enumerate(tracks): + if context.check_stop(): + return result + if i % 200 == 0 and context.wait_if_paused(): + return result + + track_id, title, artist, album, file_path = row + result.scanned += 1 + + if not os.path.exists(file_path): + # File is missing — create finding + if context.create_finding: + try: + context.create_finding( + job_id=self.job_id, + finding_type='dead_file', + severity='warning', + entity_type='track', + entity_id=str(track_id), + file_path=file_path, + title=f'Missing file: {title or "Unknown"}', + description=f'Track "{title}" by {artist or "Unknown"} points to a file that no longer exists', + details={ + 'track_id': track_id, + 'title': title, + 'artist': artist, + 'album': album, + 'original_path': file_path, + } + ) + result.findings_created += 1 + except Exception as e: + logger.debug("Error creating dead file finding for track %s: %s", track_id, e) + result.errors += 1 + + if context.update_progress and (i + 1) % 100 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + + logger.info("Dead file scan: %d tracks checked, %d missing files found", + result.scanned, result.findings_created) + return result + + def estimate_scope(self, context: JobContext) -> int: + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM tracks WHERE file_path IS NOT NULL AND file_path != ''") + row = cursor.fetchone() + return row[0] if row else 0 + except Exception: + return 0 + finally: + if conn: + conn.close() diff --git a/core/repair_jobs/duplicate_detector.py b/core/repair_jobs/duplicate_detector.py new file mode 100644 index 00000000..afd06ea2 --- /dev/null +++ b/core/repair_jobs/duplicate_detector.py @@ -0,0 +1,192 @@ +"""Duplicate Track Detector Job — finds potential duplicate tracks in the library.""" + +import re +from collections import defaultdict +from difflib import SequenceMatcher + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.duplicates") + + +@register_job +class DuplicateDetectorJob(RepairJob): + job_id = 'duplicate_detector' + display_name = 'Duplicate Detector' + description = 'Finds potential duplicate tracks in your library' + icon = 'repair-icon-duplicate' + default_enabled = False + default_interval_hours = 168 + default_settings = { + 'title_similarity': 0.85, + 'artist_similarity': 0.80, + } + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + settings = self._get_settings(context) + title_threshold = settings.get('title_similarity', 0.85) + artist_threshold = settings.get('artist_similarity', 0.80) + + # Fetch all tracks from DB + tracks = [] + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, title, artist, album, file_path, format, bitrate, + sample_rate, bit_depth, file_size, duration + FROM tracks + WHERE title IS NOT NULL AND title != '' + AND file_path IS NOT NULL AND file_path != '' + """) + tracks = cursor.fetchall() + except Exception as e: + logger.error("Error fetching tracks from DB: %s", e, exc_info=True) + result.errors += 1 + return result + finally: + if conn: + conn.close() + + if not tracks: + return result + + total = len(tracks) + if context.update_progress: + context.update_progress(0, total) + + # Group tracks by normalized key for fast comparison + # First pass: bucket by first 3 chars of normalized title for efficiency + buckets = defaultdict(list) + for row in tracks: + track_id, title, artist, album, file_path, fmt, bitrate, sample_rate, bit_depth, file_size, duration = row + norm_title = _normalize(title) + # Bucket by first few chars to avoid O(n^2) full comparison + bucket_key = norm_title[:4] if len(norm_title) >= 4 else norm_title + buckets[bucket_key].append({ + 'id': track_id, + 'title': title, + 'norm_title': norm_title, + 'artist': artist or '', + 'norm_artist': _normalize(artist or ''), + 'album': album, + 'file_path': file_path, + 'format': fmt, + 'bitrate': bitrate, + 'sample_rate': sample_rate, + 'bit_depth': bit_depth, + 'file_size': file_size, + 'duration': duration, + }) + + # Second pass: find duplicates within each bucket + found_groups = set() # Track IDs already in a group (frozenset) + processed = 0 + + for bucket_key, bucket_tracks in buckets.items(): + if context.check_stop(): + return result + + for i, t1 in enumerate(bucket_tracks): + if context.check_stop(): + return result + + processed += 1 + result.scanned += 1 + + if t1['id'] in found_groups: + continue + + group = [t1] + + for j in range(i + 1, len(bucket_tracks)): + t2 = bucket_tracks[j] + if t2['id'] in found_groups: + continue + + # Compare titles + title_sim = SequenceMatcher(None, t1['norm_title'], t2['norm_title']).ratio() + if title_sim < title_threshold: + continue + + # Compare artists + artist_sim = SequenceMatcher(None, t1['norm_artist'], t2['norm_artist']).ratio() + if artist_sim < artist_threshold: + continue + + group.append(t2) + + if len(group) >= 2: + # Found a duplicate group + group_ids = frozenset(t['id'] for t in group) + for tid in group_ids: + found_groups.add(tid) + + if context.create_finding: + try: + # Sort group by quality (highest bitrate first) + group.sort(key=lambda t: (t['bitrate'] or 0), reverse=True) + + context.create_finding( + job_id=self.job_id, + finding_type='duplicate_tracks', + severity='info', + entity_type='track', + entity_id=str(group[0]['id']), + file_path=group[0]['file_path'], + title=f'Duplicate: {group[0]["title"]} by {group[0]["artist"]}', + description=f'{len(group)} copies found with similar title/artist', + details={ + 'tracks': [{ + 'id': t['id'], + 'title': t['title'], + 'artist': t['artist'], + 'album': t['album'], + 'file_path': t['file_path'], + 'format': t['format'], + 'bitrate': t['bitrate'], + 'sample_rate': t['sample_rate'], + 'bit_depth': t['bit_depth'], + 'file_size': t['file_size'], + 'duration': t['duration'], + } for t in group], + 'count': len(group), + } + ) + result.findings_created += 1 + except Exception as e: + logger.debug("Error creating duplicate finding: %s", e) + result.errors += 1 + + if context.update_progress and processed % 200 == 0: + context.update_progress(processed, total) + + if context.update_progress: + context.update_progress(total, total) + + logger.info("Duplicate scan: %d tracks checked, %d duplicate groups found", + result.scanned, result.findings_created) + return result + + def _get_settings(self, context: JobContext) -> dict: + if not context.config_manager: + return self.default_settings.copy() + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) + merged = self.default_settings.copy() + merged.update(cfg) + return merged + + +def _normalize(text: str) -> str: + """Normalize text for fuzzy comparison.""" + t = text.lower() + t = re.sub(r'\(.*?\)', '', t) + t = re.sub(r'\[.*?\]', '', t) + t = re.sub(r'[^a-z0-9 ]', '', t) + return t.strip() diff --git a/core/repair_jobs/fake_lossless_detector.py b/core/repair_jobs/fake_lossless_detector.py new file mode 100644 index 00000000..ec5121c3 --- /dev/null +++ b/core/repair_jobs/fake_lossless_detector.py @@ -0,0 +1,260 @@ +"""Fake Lossless Detector Job — detects FLAC/WAV files transcoded from lossy sources.""" + +import json +import os +import subprocess + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.fake_lossless") + +LOSSLESS_EXTENSIONS = {'.flac', '.wav', '.aiff', '.aif'} +_ffprobe_warned = False + + +@register_job +class FakeLosslessDetectorJob(RepairJob): + job_id = 'fake_lossless_detector' + display_name = 'Fake Lossless Detector' + description = 'Detects FLAC/WAV files likely transcoded from lossy' + icon = 'repair-icon-lossless' + default_enabled = False + default_interval_hours = 168 + default_settings = { + 'spectral_cutoff_khz': 16.0, + } + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + global _ffprobe_warned + result = JobResult() + + # Check if ffprobe is available + if not _is_ffprobe_available(): + if not _ffprobe_warned: + logger.warning("ffprobe not found — Fake Lossless Detector requires ffmpeg/ffprobe to be installed") + _ffprobe_warned = True + return result + + settings = self._get_settings(context) + cutoff_khz = settings.get('spectral_cutoff_khz', 16.0) + + transfer = context.transfer_folder + if not os.path.isdir(transfer): + return result + + # Collect lossless files + lossless_files = [] + for root, _dirs, files in os.walk(transfer): + if context.check_stop(): + return result + for fname in files: + ext = os.path.splitext(fname)[1].lower() + if ext in LOSSLESS_EXTENSIONS: + lossless_files.append(os.path.join(root, fname)) + + total = len(lossless_files) + if context.update_progress: + context.update_progress(0, total) + + logger.info("Scanning %d lossless files for fakes", total) + + for i, fpath in enumerate(lossless_files): + if context.check_stop(): + return result + if i % 10 == 0 and context.wait_if_paused(): + return result + + result.scanned += 1 + + try: + analysis = _analyze_file(fpath) + if not analysis: + result.skipped += 1 + continue + + sample_rate = analysis.get('sample_rate', 44100) + max_freq_khz = sample_rate / 2000 # Nyquist frequency in kHz + detected_cutoff = analysis.get('detected_cutoff_khz') + + if detected_cutoff is not None and detected_cutoff < cutoff_khz: + # Likely fake lossless + if context.create_finding: + context.create_finding( + job_id=self.job_id, + finding_type='fake_lossless', + severity='warning', + entity_type='file', + entity_id=None, + file_path=fpath, + title=f'Possible fake lossless: {os.path.basename(fpath)}', + description=( + f'Spectral cutoff at ~{detected_cutoff:.1f} kHz ' + f'(expected >{cutoff_khz:.1f} kHz for true lossless). ' + f'File may be transcoded from a lossy source.' + ), + details={ + 'detected_cutoff_khz': round(detected_cutoff, 1), + 'expected_min_khz': cutoff_khz, + 'sample_rate': sample_rate, + 'nyquist_khz': round(max_freq_khz, 1), + 'format': os.path.splitext(fpath)[1].lower().lstrip('.'), + 'bit_depth': analysis.get('bit_depth'), + 'bitrate': analysis.get('bitrate'), + 'file_size': os.path.getsize(fpath), + } + ) + result.findings_created += 1 + + except Exception as e: + logger.debug("Error analyzing %s: %s", os.path.basename(fpath), e) + result.errors += 1 + + if context.update_progress and (i + 1) % 5 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + + logger.info("Fake lossless scan: %d files checked, %d suspicious found", + result.scanned, result.findings_created) + return result + + def _get_settings(self, context: JobContext) -> dict: + if not context.config_manager: + return self.default_settings.copy() + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) + merged = self.default_settings.copy() + merged.update(cfg) + return merged + + def estimate_scope(self, context: JobContext) -> int: + transfer = context.transfer_folder + if not os.path.isdir(transfer): + return 0 + count = 0 + for _root, _dirs, files in os.walk(transfer): + for fname in files: + if os.path.splitext(fname)[1].lower() in LOSSLESS_EXTENSIONS: + count += 1 + return count + + +def _is_ffprobe_available() -> bool: + """Check if ffprobe is available on PATH.""" + try: + subprocess.run( + ['ffprobe', '-version'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5 + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _analyze_file(fpath: str) -> dict: + """Analyze a lossless audio file using ffprobe to detect spectral properties. + + Uses ffprobe to get stream info (sample rate, bit depth, bitrate). + Then uses ffmpeg's astats filter to estimate the frequency content, + which helps detect files that were transcoded from lossy sources. + """ + try: + # Get basic stream info + probe_cmd = [ + 'ffprobe', '-v', 'quiet', + '-print_format', 'json', + '-show_streams', + '-select_streams', 'a:0', + fpath + ] + probe_result = subprocess.run( + probe_cmd, capture_output=True, text=True, timeout=30 + ) + if probe_result.returncode != 0: + return None + + probe_data = json.loads(probe_result.stdout) + streams = probe_data.get('streams', []) + if not streams: + return None + + stream = streams[0] + sample_rate = int(stream.get('sample_rate', 44100)) + bit_depth = stream.get('bits_per_raw_sample') or stream.get('bits_per_sample') + bitrate = stream.get('bit_rate') + + analysis = { + 'sample_rate': sample_rate, + 'bit_depth': int(bit_depth) if bit_depth else None, + 'bitrate': int(bitrate) if bitrate else None, + } + + # Use astats to analyze audio — check for spectral rolloff + # The 'bandwidth' value from astats gives us an idea of the + # effective frequency range. A low bandwidth relative to + # sample rate indicates possible transcode from lossy. + stats_cmd = [ + 'ffmpeg', '-v', 'quiet', + '-i', fpath, + '-af', 'astats=measure_overall=none:measure_perchannel=Flat_factor', + '-f', 'null', '-' + ] + + # Alternative simpler approach: check the actual spectral content + # by looking at the spectrogram. For a quick heuristic, we use + # the showspectrumpic filter to check energy above the cutoff. + # But that's complex. A simpler approach: check the file's actual + # effective bitrate vs expected for lossless. + # + # True lossless at 44.1kHz/16-bit typically has bitrate > 700kbps + # A 320kbps MP3 transcoded to FLAC would still be ~700+ but with + # spectral cutoff around 16kHz. + # + # For a reliable check, we use ffmpeg's volumedetect on a + # high-pass filtered version of the audio. + highpass_cmd = [ + 'ffmpeg', '-v', 'quiet', + '-i', fpath, + '-t', '30', # Only analyze first 30 seconds + '-af', f'highpass=f={int(sample_rate * 0.35)},volumedetect', + '-f', 'null', '-' + ] + + hp_result = subprocess.run( + highpass_cmd, capture_output=True, text=True, timeout=60 + ) + + # Parse volumedetect output from stderr + hp_output = hp_result.stderr + mean_volume = None + for line in hp_output.split('\n'): + if 'mean_volume' in line: + try: + # Extract dB value + parts = line.split('mean_volume:') + if len(parts) > 1: + db_str = parts[1].strip().replace('dB', '').strip() + mean_volume = float(db_str) + except (ValueError, IndexError): + pass + + if mean_volume is not None: + # If the mean volume above 35% of Nyquist is very low (< -70dB), + # there's likely a hard frequency cutoff → probable transcode + if mean_volume < -70: + # Estimate cutoff: use the highpass frequency as an approximation + analysis['detected_cutoff_khz'] = (sample_rate * 0.35) / 1000 + else: + # Audio has content at high frequencies — likely genuine lossless + analysis['detected_cutoff_khz'] = sample_rate / 2000 # Full range + + return analysis + + except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as e: + logger.debug("ffprobe/ffmpeg analysis failed for %s: %s", os.path.basename(fpath), e) + return None diff --git a/core/repair_jobs/metadata_gap_filler.py b/core/repair_jobs/metadata_gap_filler.py new file mode 100644 index 00000000..293e8a80 --- /dev/null +++ b/core/repair_jobs/metadata_gap_filler.py @@ -0,0 +1,197 @@ +"""Metadata Gap Filler Job — fills missing track metadata from APIs.""" + +import time + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.metadata_gap") + + +@register_job +class MetadataGapFillerJob(RepairJob): + job_id = 'metadata_gap_filler' + display_name = 'Metadata Gap Filler' + description = 'Fills missing genre, year, ISRC, and MusicBrainz IDs' + icon = 'repair-icon-metadata' + default_enabled = False + default_interval_hours = 72 + default_settings = { + 'fill_genre': True, + 'fill_year': True, + 'fill_isrc': True, + 'fill_musicbrainz_id': True, + 'write_to_file': False, + } + auto_fix = True + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + settings = self._get_settings(context) + fill_genre = settings.get('fill_genre', True) + fill_year = settings.get('fill_year', True) + fill_isrc = settings.get('fill_isrc', True) + fill_mb_id = settings.get('fill_musicbrainz_id', True) + + # Build WHERE clauses for missing fields + conditions = [] + if fill_genre: + conditions.append("(genre IS NULL OR genre = '')") + if fill_year: + conditions.append("(year IS NULL OR year = '' OR year = '0')") + if fill_isrc: + conditions.append("(isrc IS NULL OR isrc = '')") + if fill_mb_id: + conditions.append("(musicbrainz_id IS NULL OR musicbrainz_id = '')") + + if not conditions: + return result + + where = " OR ".join(conditions) + + # Fetch tracks with gaps, prioritizing those with spotify_id + tracks = [] + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(f""" + SELECT id, title, artist, album, spotify_id, isrc, genre, year, musicbrainz_id + FROM tracks + WHERE title IS NOT NULL AND title != '' + AND ({where}) + ORDER BY + CASE WHEN spotify_id IS NOT NULL AND spotify_id != '' THEN 0 ELSE 1 END, + id + LIMIT 500 + """) + tracks = cursor.fetchall() + except Exception as e: + logger.error("Error fetching tracks with metadata gaps: %s", e, exc_info=True) + result.errors += 1 + return result + finally: + if conn: + conn.close() + + total = len(tracks) + if context.update_progress: + context.update_progress(0, total) + + logger.info("Found %d tracks with metadata gaps", total) + + for i, row in enumerate(tracks): + if context.check_stop(): + return result + if i % 20 == 0 and context.wait_if_paused(): + return result + + track_id, title, artist, album, spotify_id, isrc, genre, year, mb_id = row + result.scanned += 1 + updates = {} + + # Try Spotify enrichment first (most reliable) + if spotify_id and context.spotify_client: + try: + track_data = context.spotify_client.get_track_details(spotify_id) + if track_data: + if fill_isrc and not isrc: + ext_ids = track_data.get('external_ids', {}) + if ext_ids.get('isrc'): + updates['isrc'] = ext_ids['isrc'] + + if fill_year and not year: + album_data = track_data.get('album', {}) + rd = album_data.get('release_date', '') + if rd and len(rd) >= 4: + updates['year'] = rd[:4] + + # Get album for genre (genres are on artists in Spotify) + if fill_genre and not genre: + artists = track_data.get('artists', []) if track_data else [] + if artists: + artist_data = context.spotify_client.get_artist(artists[0].get('id', '')) + if artist_data and artist_data.get('genres'): + updates['genre'] = ', '.join(artist_data['genres'][:3]) + + except Exception as e: + logger.debug("Spotify enrichment failed for track %s: %s", track_id, e) + + # Try MusicBrainz for MB ID + if fill_mb_id and not mb_id and context.mb_client: + try: + search_query = f'"{title}" AND artist:"{artist}"' if artist else f'"{title}"' + mb_results = context.mb_client.search_recordings(search_query, limit=1) + if mb_results: + recordings = mb_results.get('recording-list', []) + if recordings: + updates['musicbrainz_id'] = recordings[0].get('id', '') + except Exception as e: + logger.debug("MusicBrainz lookup failed for track %s: %s", track_id, e) + + # Apply updates + if updates: + try: + conn2 = context.db._get_connection() + cursor2 = conn2.cursor() + set_parts = [f"{k} = ?" for k in updates.keys()] + values = list(updates.values()) + [track_id] + cursor2.execute( + f"UPDATE tracks SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + values + ) + conn2.commit() + conn2.close() + result.auto_fixed += 1 + logger.debug("Filled %d metadata fields for track '%s' (id=%s)", + len(updates), title, track_id) + except Exception as e: + logger.debug("Error updating metadata for track %s: %s", track_id, e) + result.errors += 1 + else: + result.skipped += 1 + + # Rate limit API calls + if spotify_id: + time.sleep(0.5) + + if context.update_progress and (i + 1) % 10 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + + logger.info("Metadata gap fill: %d tracks checked, %d enriched, %d skipped", + result.scanned, result.auto_fixed, result.skipped) + return result + + def _get_settings(self, context: JobContext) -> dict: + if not context.config_manager: + return self.default_settings.copy() + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) + merged = self.default_settings.copy() + merged.update(cfg) + return merged + + def estimate_scope(self, context: JobContext) -> int: + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COUNT(*) FROM tracks + WHERE title IS NOT NULL AND title != '' + AND ((genre IS NULL OR genre = '') + OR (year IS NULL OR year = '' OR year = '0') + OR (isrc IS NULL OR isrc = '') + OR (musicbrainz_id IS NULL OR musicbrainz_id = '')) + """) + row = cursor.fetchone() + return min(row[0], 500) if row else 0 + except Exception: + return 0 + finally: + if conn: + conn.close() diff --git a/core/repair_jobs/missing_cover_art.py b/core/repair_jobs/missing_cover_art.py new file mode 100644 index 00000000..01b2b962 --- /dev/null +++ b/core/repair_jobs/missing_cover_art.py @@ -0,0 +1,171 @@ +"""Missing Cover Art Filler Job — finds albums without artwork and fills from APIs.""" + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.cover_art") + + +@register_job +class MissingCoverArtJob(RepairJob): + job_id = 'missing_cover_art' + display_name = 'Cover Art Filler' + description = 'Finds albums missing artwork and fills from Spotify/iTunes' + icon = 'repair-icon-coverart' + default_enabled = True + default_interval_hours = 48 + default_settings = { + 'prefer_source': 'spotify', + 'embed_in_file': False, + } + auto_fix = True + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + settings = self._get_settings(context) + prefer_source = settings.get('prefer_source', 'spotify') + + # Fetch albums with missing artwork + albums = [] + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, title, artist, spotify_id, artwork_url + FROM albums + WHERE (artwork_url IS NULL OR artwork_url = '') + AND title IS NOT NULL AND title != '' + """) + albums = cursor.fetchall() + except Exception as e: + logger.error("Error fetching albums without artwork: %s", e, exc_info=True) + result.errors += 1 + return result + finally: + if conn: + conn.close() + + total = len(albums) + if context.update_progress: + context.update_progress(0, total) + + logger.info("Found %d albums missing cover art", total) + + for i, row in enumerate(albums): + if context.check_stop(): + return result + if i % 10 == 0 and context.wait_if_paused(): + return result + + album_id, title, artist, spotify_id, _ = row + result.scanned += 1 + + artwork_url = None + + # Try Spotify first (or iTunes first based on preference) + if prefer_source == 'spotify': + artwork_url = self._try_spotify(spotify_id, title, artist, context) + if not artwork_url: + artwork_url = self._try_itunes(title, artist, context) + else: + artwork_url = self._try_itunes(title, artist, context) + if not artwork_url: + artwork_url = self._try_spotify(spotify_id, title, artist, context) + + if artwork_url: + # Update DB + try: + conn2 = context.db._get_connection() + cursor2 = conn2.cursor() + cursor2.execute( + "UPDATE albums SET artwork_url = ? WHERE id = ?", + (artwork_url, album_id) + ) + conn2.commit() + conn2.close() + result.auto_fixed += 1 + logger.debug("Filled artwork for album '%s': %s", title, artwork_url[:80]) + except Exception as e: + logger.debug("Error updating artwork for album %s: %s", album_id, e) + result.errors += 1 + else: + result.skipped += 1 + + if context.update_progress and (i + 1) % 5 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + + logger.info("Cover art fill: %d albums checked, %d filled, %d skipped", + result.scanned, result.auto_fixed, result.skipped) + return result + + def _try_spotify(self, spotify_id, title, artist, context): + """Try to get album art from Spotify.""" + client = context.spotify_client + if not client: + return None + + try: + # If we have a Spotify album ID, fetch directly + if spotify_id and client.is_spotify_authenticated(): + album_data = client.get_album(spotify_id) + if album_data: + images = album_data.get('images', []) + if images: + return images[0].get('url') + + # Search by name + if title and client.is_spotify_authenticated(): + query = f"{artist} {title}" if artist else title + results = client.search_albums(query, limit=1) + if results and hasattr(results[0], 'image_url') and results[0].image_url: + return results[0].image_url + except Exception as e: + logger.debug("Spotify art lookup failed for '%s': %s", title, e) + return None + + def _try_itunes(self, title, artist, context): + """Try to get album art from iTunes.""" + client = context.itunes_client + if not client: + return None + + try: + query = f"{artist} {title}" if artist else title + results = client.search_albums(query, limit=1) + if results and hasattr(results[0], 'image_url') and results[0].image_url: + return results[0].image_url + except Exception as e: + logger.debug("iTunes art lookup failed for '%s': %s", title, e) + return None + + def _get_settings(self, context: JobContext) -> dict: + if not context.config_manager: + return self.default_settings.copy() + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) + merged = self.default_settings.copy() + merged.update(cfg) + return merged + + def estimate_scope(self, context: JobContext) -> int: + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COUNT(*) FROM albums + WHERE (artwork_url IS NULL OR artwork_url = '') + AND title IS NOT NULL AND title != '' + """) + row = cursor.fetchone() + return row[0] if row else 0 + except Exception: + return 0 + finally: + if conn: + conn.close() diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py new file mode 100644 index 00000000..6ec61669 --- /dev/null +++ b/core/repair_jobs/orphan_file_detector.py @@ -0,0 +1,122 @@ +"""Orphan File Detector Job — finds files in the transfer folder not tracked in the DB.""" + +import os +import time + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.orphan_files") + +AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'} + + +@register_job +class OrphanFileDetectorJob(RepairJob): + job_id = 'orphan_file_detector' + display_name = 'Orphan File Detector' + description = 'Finds audio files not tracked in the database' + icon = 'repair-icon-orphan' + default_enabled = True + default_interval_hours = 24 + default_settings = {} + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + transfer = context.transfer_folder + if not os.path.isdir(transfer): + logger.warning("Transfer folder does not exist: %s", transfer) + return result + + # Build set of all known file paths from DB + known_paths = set() + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND file_path != ''") + for row in cursor.fetchall(): + # Normalize path for comparison + known_paths.add(os.path.normpath(row[0])) + except Exception as e: + logger.error("Error reading known file paths from DB: %s", e, exc_info=True) + result.errors += 1 + return result + finally: + if conn: + conn.close() + + # Walk transfer folder and find orphans + audio_files = [] + for root, _dirs, files in os.walk(transfer): + if context.check_stop(): + return result + for fname in files: + ext = os.path.splitext(fname)[1].lower() + if ext in AUDIO_EXTENSIONS: + audio_files.append(os.path.join(root, fname)) + + total = len(audio_files) + if context.update_progress: + context.update_progress(0, total) + + for i, fpath in enumerate(audio_files): + if context.check_stop(): + return result + if i % 100 == 0 and context.wait_if_paused(): + return result + + result.scanned += 1 + norm_path = os.path.normpath(fpath) + + if norm_path not in known_paths: + # This file is an orphan — create finding + try: + stat = os.stat(fpath) + ext = os.path.splitext(fpath)[1].lower().lstrip('.') + if context.create_finding: + context.create_finding( + job_id=self.job_id, + finding_type='orphan_file', + severity='info', + entity_type='file', + entity_id=None, + file_path=fpath, + title=f'Orphan file: {os.path.basename(fpath)}', + description=f'Audio file in transfer folder is not tracked in the database', + details={ + 'file_size': stat.st_size, + 'format': ext, + 'modified': time.strftime('%Y-%m-%d %H:%M:%S', + time.localtime(stat.st_mtime)), + 'folder': os.path.dirname(fpath), + } + ) + result.findings_created += 1 + except Exception as e: + logger.debug("Error creating orphan finding for %s: %s", fpath, e) + result.errors += 1 + + if context.update_progress and (i + 1) % 50 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + + logger.info("Orphan file scan: %d files scanned, %d orphans found", + result.scanned, result.findings_created) + return result + + def estimate_scope(self, context: JobContext) -> int: + transfer = context.transfer_folder + if not os.path.isdir(transfer): + return 0 + count = 0 + for _root, _dirs, files in os.walk(transfer): + for fname in files: + if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS: + count += 1 + return count diff --git a/core/repair_jobs/track_number_repair.py b/core/repair_jobs/track_number_repair.py new file mode 100644 index 00000000..29ca3e9f --- /dev/null +++ b/core/repair_jobs/track_number_repair.py @@ -0,0 +1,881 @@ +"""Track Number Repair Job — fixes embedded track number tags and filename prefixes. + +Detects albums where 3+ files share the same track number (the "all tracks = 01" +bug pattern), then uses cascading API lookups (Spotify → iTunes → MusicBrainz → +AudioDB) to resolve the correct tracklist and repair each file. +""" + +import os +import re +from difflib import SequenceMatcher +from typing import Any, Dict, List, Optional, Tuple + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.track_number") + +AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'} + +# Placeholder album IDs that are not real API identifiers +_PLACEHOLDER_IDS = { + 'wishlist_album', 'explicit_album', 'explicit_artist', + 'unknown', 'none', 'null', '', +} + + +@register_job +class TrackNumberRepairJob(RepairJob): + job_id = 'track_number_repair' + display_name = 'Track Number Repair' + description = 'Fixes mismatched track numbers using API lookups' + icon = 'repair-icon-tracknumber' + default_enabled = True + default_interval_hours = 24 + default_settings = { + 'anomaly_threshold': 3, + 'title_similarity': 0.80, + } + auto_fix = True + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + settings = self._get_settings(context) + anomaly_threshold = settings.get('anomaly_threshold', 3) + title_similarity = settings.get('title_similarity', 0.80) + + # Thread-local state to avoid race conditions with concurrent scan_folders() + scan_state = { + 'album_tracks_cache': {}, + 'title_similarity': title_similarity, + } + + transfer = context.transfer_folder + if not os.path.isdir(transfer): + logger.warning("Transfer folder does not exist: %s", transfer) + return result + + # Collect album folders (directories containing audio files) + album_folders: Dict[str, List[str]] = {} + for root, _dirs, files in os.walk(transfer): + if context.check_stop(): + return result + for fname in files: + ext = os.path.splitext(fname)[1].lower() + if ext in AUDIO_EXTENSIONS: + album_folders.setdefault(root, []).append(fname) + + total = sum(len(fnames) for fnames in album_folders.values()) + if context.update_progress: + context.update_progress(0, total) + + for folder_path, filenames in album_folders.items(): + if context.check_stop(): + return result + if context.wait_if_paused(): + return result + + try: + folder_result = self._repair_album( + folder_path, filenames, anomaly_threshold, context, scan_state + ) + result.scanned += folder_result.scanned + result.auto_fixed += folder_result.auto_fixed + result.skipped += folder_result.skipped + result.errors += folder_result.errors + except Exception as e: + logger.error("Error processing album folder %s: %s", folder_path, e, exc_info=True) + result.errors += 1 + + if context.update_progress: + context.update_progress(result.scanned, total) + + return result + + def estimate_scope(self, context: JobContext) -> int: + transfer = context.transfer_folder + if not os.path.isdir(transfer): + return 0 + count = 0 + for _root, _dirs, files in os.walk(transfer): + for fname in files: + if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS: + count += 1 + return count + + def _get_settings(self, context: JobContext) -> dict: + """Read job settings from config, falling back to defaults.""" + if not context.config_manager: + return self.default_settings.copy() + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) + merged = self.default_settings.copy() + merged.update(cfg) + return merged + + # ------------------------------------------------------------------ + # Album-level repair + # ------------------------------------------------------------------ + def _repair_album(self, folder_path: str, filenames: List[str], + anomaly_threshold: int, context: JobContext, + scan_state: dict = None) -> JobResult: + from mutagen import File as MutagenFile + + if scan_state is None: + scan_state = {'album_tracks_cache': {}, 'title_similarity': 0.80} + + result = JobResult() + + # Step 0: Anomaly detection + track_num_counts: Dict[int, int] = {} + file_track_data: List[Tuple[str, str, Optional[int]]] = [] + + 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, _ = _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)) + + has_anomaly = any(count >= anomaly_threshold for count in track_num_counts.values()) + if not has_anomaly: + result.scanned += len(filenames) + return result + + duped = {num: cnt for num, cnt in track_num_counts.items() if cnt >= anomaly_threshold} + logger.info("Anomaly detected in %s — %d files share track number(s): %s", + os.path.basename(folder_path), sum(duped.values()), duped) + + # Resolve album tracklist via cascading fallbacks + api_tracks = self._resolve_album_tracklist(file_track_data, folder_path, context, scan_state) + if not api_tracks: + result.skipped += len(filenames) + result.scanned += len(filenames) + return result + + # Process each file + title_sim = scan_state.get('title_similarity', 0.80) + for fpath, fname, _ in file_track_data: + if context.check_stop(): + return result + + result.scanned += 1 + try: + if _repair_single_track(fpath, fname, api_tracks, len(api_tracks), title_sim, context): + result.auto_fixed += 1 + except Exception as e: + logger.error("Error repairing %s: %s", fpath, e, exc_info=True) + result.errors += 1 + + return result + + # ------------------------------------------------------------------ + # Tracklist resolution (6-level fallback cascade) + # ------------------------------------------------------------------ + def _resolve_album_tracklist(self, file_track_data: List[Tuple[str, str, Optional[int]]], + folder_path: str, context: JobContext, + scan_state: dict = None) -> Optional[List[Dict]]: + if scan_state is None: + scan_state = {'album_tracks_cache': {}, 'title_similarity': 0.80} + + cache = scan_state['album_tracks_cache'] + folder_name = os.path.basename(folder_path) + + # Collect all available IDs from files in one pass + spotify_album_id = None + itunes_album_id = None + spotify_track_id = None + mb_album_id = None + album_name = None + artist_name = None + + for fpath, fname, _ in file_track_data: + if not spotify_album_id or not itunes_album_id: + aid, source = _read_album_id_from_file(fpath) + if aid and source == 'spotify' and not spotify_album_id: + spotify_album_id = aid + elif aid and source == 'itunes' and not itunes_album_id: + itunes_album_id = aid + + if not spotify_track_id: + spotify_track_id = _read_spotify_track_id_from_file(fpath) + + if not mb_album_id: + mb_album_id = _read_musicbrainz_album_id_from_file(fpath) + + if not album_name: + album_name, artist_name = _read_album_artist_from_file(fpath) + + if spotify_album_id and itunes_album_id and spotify_track_id and mb_album_id and album_name: + break + + # Fallback 1: Spotify album ID + if spotify_album_id and _is_valid_album_id(spotify_album_id): + tracks = _get_album_tracklist(spotify_album_id, context, cache) + if tracks: + logger.info("[Repair] %s — resolved via Spotify album ID: %s", folder_name, spotify_album_id) + return tracks + + # Fallback 2: iTunes album ID + if itunes_album_id and _is_valid_album_id(itunes_album_id): + tracks = _get_album_tracklist(itunes_album_id, context, cache) + if tracks: + logger.info("[Repair] %s — resolved via iTunes album ID: %s", folder_name, itunes_album_id) + return tracks + + # Fallback 3: Spotify track ID → discover album ID + client = context.spotify_client + if spotify_track_id and client and client.is_spotify_authenticated(): + try: + track_details = client.get_track_details(spotify_track_id) + if track_details and track_details.get('album', {}).get('id'): + real_album_id = track_details['album']['id'] + tracks = _get_album_tracklist(real_album_id, context, cache) + if tracks: + logger.info("[Repair] %s — resolved via Spotify track ID %s → album %s", + folder_name, spotify_track_id, real_album_id) + return tracks + except Exception as e: + logger.debug("Spotify track lookup failed for %s: %s", spotify_track_id, e) + + # Fallback 4: Search Spotify/iTunes by album name + artist + if album_name and client: + try: + query = f"{artist_name} {album_name}" if artist_name else album_name + results = client.search_albums(query, limit=5) + if results: + best = results[0] + tracks = _get_album_tracklist(best.id, context, cache) + if tracks: + logger.info("[Repair] %s — resolved via album search: '%s' → %s", + folder_name, query, best.id) + return tracks + except Exception as e: + logger.debug("Album search failed for '%s': %s", album_name, e) + + # Fallback 5: MusicBrainz album ID from tags + if mb_album_id: + tracks = _get_tracklist_from_musicbrainz(mb_album_id, context, cache) + if tracks: + logger.info("[Repair] %s — resolved via MusicBrainz album ID: %s", folder_name, mb_album_id) + return tracks + + # Fallback 6: AudioDB → MusicBrainz + if album_name and artist_name: + adb_mb_id = _get_musicbrainz_id_via_audiodb(artist_name, album_name, context) + if adb_mb_id and adb_mb_id != mb_album_id: + tracks = _get_tracklist_from_musicbrainz(adb_mb_id, context, cache) + if tracks: + logger.info("[Repair] %s — resolved via AudioDB → MusicBrainz: %s", + folder_name, adb_mb_id) + return tracks + + logger.warning("[Repair] %s — all tracklist resolution strategies exhausted", folder_name) + return None + + # ------------------------------------------------------------------ + # Batch scan support (called by RepairWorker.process_batch) + # ------------------------------------------------------------------ + def scan_folders(self, folders: List[str], context: JobContext) -> JobResult: + """Scan specific folders only (for batch post-download repair).""" + result = JobResult() + settings = self._get_settings(context) + anomaly_threshold = settings.get('anomaly_threshold', 3) + + # Thread-local state (not on self — avoids race with concurrent scan()) + scan_state = { + 'album_tracks_cache': {}, + 'title_similarity': settings.get('title_similarity', 0.80), + } + + for folder_path in folders: + if context.check_stop(): + break + if not os.path.isdir(folder_path): + continue + filenames = [ + f for f in os.listdir(folder_path) + if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS + ] + if not filenames: + continue + + try: + folder_result = self._repair_album(folder_path, filenames, anomaly_threshold, context, scan_state) + result.scanned += folder_result.scanned + result.auto_fixed += folder_result.auto_fixed + result.skipped += folder_result.skipped + result.errors += folder_result.errors + except Exception as e: + logger.error("[Repair] Error scanning %s: %s", folder_path, e, exc_info=True) + result.errors += 1 + + return result + + +# ====================================================================== +# Module-level helper functions (extracted from old RepairWorker methods) +# ====================================================================== + +def _read_track_number_tag(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 _parse_track_str(str(frames[0].text[0])) + elif isinstance(audio, (FLAC, OggVorbis)): + val = audio.get('tracknumber') + if val: + return _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 + + +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 + + +def _read_title_tag(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 _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 + + +def _read_album_id_from_file(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 + + 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' + + 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' + + 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 _is_valid_album_id(album_id: Optional[str]) -> bool: + """Check if an album ID is a real API identifier, not a placeholder.""" + if not album_id: + return False + if album_id.strip().lower() in _PLACEHOLDER_IDS: + return False + if len(album_id.strip()) < 5: + return False + return True + + +def _read_spotify_track_id_from_file(file_path: str) -> Optional[str]: + """Read SPOTIFY_TRACK_ID from embedded tags.""" + 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 + + if hasattr(audio, 'tags') and audio.tags is not None: + if isinstance(audio.tags, ID3): + for key in ['TXXX:SPOTIFY_TRACK_ID', 'TXXX:spotify_track_id']: + frame = audio.tags.getall(key) + if frame and frame[0].text: + return str(frame[0].text[0]) + elif isinstance(audio, (FLAC, OggVorbis)): + for key in ['spotify_track_id', 'SPOTIFY_TRACK_ID']: + val = audio.get(key) + if val: + return str(val[0]) + elif isinstance(audio, MP4): + for key in ['----:com.apple.iTunes:SPOTIFY_TRACK_ID', + '----:com.apple.iTunes:spotify_track_id']: + val = audio.tags.get(key) + if val: + raw = val[0] + return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw) + + except Exception as e: + logger.debug("Error reading Spotify track ID from %s: %s", file_path, e) + return None + + +def _read_musicbrainz_album_id_from_file(file_path: str) -> Optional[str]: + """Read MusicBrainz Album Id (release MBID) from embedded tags.""" + 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 + + if hasattr(audio, 'tags') and audio.tags is not None: + if isinstance(audio.tags, ID3): + for key in ['TXXX:MusicBrainz Album Id', 'TXXX:MUSICBRAINZ_ALBUMID', + 'TXXX:musicbrainz_albumid']: + frame = audio.tags.getall(key) + if frame and frame[0].text: + return str(frame[0].text[0]) + elif isinstance(audio, (FLAC, OggVorbis)): + for key in ['musicbrainz_albumid', 'MUSICBRAINZ_ALBUMID', + 'MusicBrainz Album Id']: + val = audio.get(key) + if val: + return str(val[0]) + elif isinstance(audio, MP4): + for key in ['----:com.apple.iTunes:MusicBrainz Album Id', + '----:com.apple.iTunes:MUSICBRAINZ_ALBUMID', + '----:com.apple.music.albums:MUSICBRAINZ_ALBUMID']: + val = audio.tags.get(key) + if val: + raw = val[0] + return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw) + + except Exception as e: + logger.debug("Error reading MusicBrainz album ID from %s: %s", file_path, e) + return None + + +def _read_album_artist_from_file(file_path: str) -> Tuple[Optional[str], Optional[str]]: + """Read album name and artist name from embedded tags. + Returns (album_name, artist_name).""" + 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 + + album_name = None + artist_name = None + + if hasattr(audio, 'tags') and audio.tags is not None: + if isinstance(audio.tags, ID3): + frames = audio.tags.getall('TALB') + if frames and frames[0].text: + album_name = str(frames[0].text[0]) + for tag in ['TPE2', 'TPE1']: + frames = audio.tags.getall(tag) + if frames and frames[0].text: + artist_name = str(frames[0].text[0]) + break + elif isinstance(audio, (FLAC, OggVorbis)): + val = audio.get('album') + if val: + album_name = str(val[0]) + for key in ['albumartist', 'artist']: + val = audio.get(key) + if val: + artist_name = str(val[0]) + break + elif isinstance(audio, MP4): + val = audio.tags.get('\xa9alb') + if val: + album_name = str(val[0]) + for key in ['aART', '\xa9ART']: + val = audio.tags.get(key) + if val: + artist_name = str(val[0]) + break + + return album_name, artist_name + except Exception as e: + logger.debug("Error reading album/artist from %s: %s", file_path, e) + return None, None + + +def _match_title_to_api_track(file_title: str, api_tracks: List[Dict], + threshold: float) -> Optional[Dict]: + """Fuzzy-match a file title to an API track.""" + norm_file = _normalize_title(file_title) + best_match = None + best_score = 0.0 + + for track in api_tracks: + api_name = track.get('name', '') + norm_api = _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 >= threshold: + return best_match + return None + + +def _normalize_title(title: str) -> str: + """Normalize a title for comparison.""" + t = title.lower() + t = re.sub(r'\(.*?\)', '', t) + t = re.sub(r'\[.*?\]', '', t) + t = re.sub(r'[^a-z0-9 ]', '', t) + return t.strip() + + +def _fix_track_number_tag(file_path: str, correct_num: int, total: int): + """Update ONLY the track number tag in the file.""" + from mutagen import File as MutagenFile + from mutagen.id3 import TRCK, ID3 + from mutagen.flac import FLAC + from mutagen.oggvorbis import OggVorbis + from mutagen.mp4 import MP4 + + try: + 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): + 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) + + +def _fix_filename_track_number(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] + + new_basename = re.sub(r'^\d{1,3}', f'{correct_num:02d}', basename) + if new_basename == basename: + return None + + new_filename = new_basename + ext + parent_dir = os.path.dirname(file_path) + new_path = os.path.join(parent_dir, new_filename) + + 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) + return None + + os.rename(file_path, new_path) + logger.info("Renamed: %s → %s", filename, new_filename) + + # Rename associated .lrc file if it exists + lrc_path = os.path.join(parent_dir, basename + '.lrc') + if os.path.isfile(lrc_path): + new_lrc_path = os.path.join(parent_dir, new_basename + '.lrc') + if not os.path.exists(new_lrc_path): + os.rename(lrc_path, new_lrc_path) + logger.info("Renamed LRC: %s.lrc → %s.lrc", basename, new_basename) + + return new_path + except Exception as e: + logger.error("Error renaming %s: %s", file_path, e, exc_info=True) + return None + + +def _update_db_file_path(db, old_path: str, new_path: str): + """Update file_path in tracks table if this track is tracked.""" + conn = None + try: + conn = 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() + + +def _repair_single_track(file_path: str, filename: str, api_tracks: List[Dict], + total_tracks: int, title_similarity: float, + context: JobContext) -> bool: + """Match a single file to the API tracklist and fix its track number tag + filename. + + Returns True if the track was actually repaired. + """ + from mutagen import File as MutagenFile + + audio = MutagenFile(file_path) + if audio is None: + return False + + # Try to match via embedded title tag first + file_title = _read_title_tag(audio) + matched_track = None + + if file_title: + matched_track = _match_title_to_api_track(file_title, api_tracks, title_similarity) + + # Fallback: match via filename (without track number prefix and extension) + if not matched_track: + basename = os.path.splitext(filename)[0] + # Strip leading track number prefix like "01 - " or "01. " + clean_name = re.sub(r'^\d{1,3}[\s.\-_]*', '', basename).strip() + if clean_name: + matched_track = _match_title_to_api_track(clean_name, api_tracks, title_similarity) + + if not matched_track: + return False + + correct_num = matched_track.get('track_number') + if correct_num is None: + return False + + # Check if track number already correct + current_num, current_total = _read_track_number_tag(audio) + if current_num == correct_num and current_total == total_tracks: + return False # Already correct + + # Fix the track number tag + _fix_track_number_tag(file_path, correct_num, total_tracks) + + # Fix filename prefix if it starts with a track number + new_path = _fix_filename_track_number(file_path, filename, correct_num) + if new_path and context.db: + _update_db_file_path(context.db, file_path, new_path) + + return True + + +def _get_album_tracklist(album_id: str, context: JobContext, + cache: dict) -> Optional[List[Dict]]: + """Fetch an album tracklist from Spotify or iTunes, with per-scan caching. + + Returns a list of dicts with at least 'name' and 'track_number' keys, + or None if lookup fails. + """ + if album_id in cache: + return cache[album_id] + + result = None + + # Try Spotify first + client = context.spotify_client + if client and client.is_spotify_authenticated(): + try: + data = client.get_album_tracks(album_id) + if data and 'items' in data and data['items']: + result = [ + { + 'name': item.get('name', ''), + 'track_number': item.get('track_number'), + 'disc_number': item.get('disc_number', 1), + } + for item in data['items'] + ] + except Exception as e: + logger.debug("Spotify get_album_tracks failed for %s: %s", album_id, e) + + # Fallback to iTunes + if not result and context.itunes_client: + try: + data = context.itunes_client.get_album_tracks(album_id) + if data and 'items' in data and data['items']: + result = [ + { + 'name': item.get('name', ''), + 'track_number': item.get('track_number'), + 'disc_number': item.get('disc_number', 1), + } + for item in data['items'] + ] + except Exception as e: + logger.debug("iTunes get_album_tracks failed for %s: %s", album_id, e) + + cache[album_id] = result + return result + + +def _get_tracklist_from_musicbrainz(mbid: str, context: JobContext, + cache: dict) -> Optional[List[Dict]]: + """Fetch an album tracklist from MusicBrainz release data. + + Returns a list of dicts with 'name' and 'track_number' keys, + or None if lookup fails. + """ + cache_key = f"mb_{mbid}" + if cache_key in cache: + return cache[cache_key] + + result = None + mb = context.mb_client + + if mb: + try: + release = mb.get_release(mbid, includes=['recordings']) + if release and 'media' in release: + tracks = [] + for medium in release['media']: + medium_tracks = medium.get('tracks') or medium.get('track-list', []) + for track in medium_tracks: + name = track.get('title', '') + # MusicBrainz uses 'position' for track number within the medium + position = track.get('position') or track.get('number') + try: + position = int(position) + except (TypeError, ValueError): + position = None + tracks.append({ + 'name': name, + 'track_number': position, + 'disc_number': medium.get('position', 1), + }) + if tracks: + result = tracks + except Exception as e: + logger.debug("MusicBrainz get_release failed for %s: %s", mbid, e) + + cache[cache_key] = result + return result + + +def _get_musicbrainz_id_via_audiodb(artist_name: str, album_name: str, + context: JobContext) -> Optional[str]: + """Search AudioDB for an album and extract its MusicBrainz release ID.""" + try: + from core.audiodb_client import AudioDBClient + client = AudioDBClient() + except Exception: + return None + + try: + result = client.search_album(artist_name, album_name) + if result: + mb_id = result.get('strMusicBrainzAlbumID') + if mb_id and mb_id.strip(): + logger.debug("AudioDB returned MusicBrainz ID %s for '%s - %s'", + mb_id, artist_name, album_name) + return mb_id.strip() + except Exception as e: + logger.debug("AudioDB lookup failed for '%s - %s': %s", artist_name, album_name, e) + return None diff --git a/core/repair_worker.py b/core/repair_worker.py index 061fb1ea..8fc9a693 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -1,13 +1,22 @@ +"""Library Maintenance Worker — multi-job background daemon. + +Rotates through registered repair jobs (track number repair, AcoustID scanner, +duplicate detection, etc.) based on staleness-priority scheduling. Each job +is independently configurable and can be enabled/disabled by the user. + +The worker is deactivated by default — the user must explicitly enable it. +""" + import json 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 typing import Any, Dict, List, Optional, Tuple + +from core.repair_jobs import get_all_jobs +from core.repair_jobs.base import JobContext, JobResult, RepairJob from utils.logging_config import get_logger -from database.music_database import MusicDatabase logger = get_logger("repair_worker") @@ -15,72 +24,71 @@ AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '. class RepairWorker: - """Background worker for scanning and repairing library files. + """Multi-job background maintenance worker. - 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. + Rotates through enabled repair jobs using staleness-priority scheduling. + Deactivated by default — user must enable via the management modal. """ - def __init__(self, database: MusicDatabase, transfer_folder: str = None): + def __init__(self, database, transfer_folder: str = None): self.db = database - - # Initial transfer folder (re-read from DB each scan cycle) self.transfer_folder = transfer_folder or './Transfer' # Worker state self.running = False - self.paused = True + self.enabled = False # Master toggle (replaces 'paused') self.should_stop = False self.thread = None - # Current item being processed (for UI tooltip) - self.current_item = None + # Current job being executed + self._current_job_id = None + self._current_job_name = None + self._current_progress = {'scanned': 0, 'total': 0, 'percent': 0} - # Statistics + # Aggregate stats for the current scan cycle self.stats = { 'scanned': 0, 'repaired': 0, 'skipped': 0, 'errors': 0, - 'pending': 0 + 'pending': 0, } - # How often to re-scan the full library (hours) - self.rescan_interval_hours = 24 + # Job instances (instantiated once) + self._jobs: Dict[str, RepairJob] = {} - # 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 - - # MusicBrainzClient (lazy-init) - self._mb_client = None - - # AudioDBClient (lazy-init) - self._audiodb_client = None - - # Per-batch folder queues: batch_id -> set of folder paths + # Per-batch folder queues (for post-download scanning) self._batch_folders: Dict[str, set] = {} self._batch_folders_lock = threading.Lock() - # Known placeholder album IDs that are not real API identifiers - self._placeholder_ids = { - 'wishlist_album', 'explicit_album', 'explicit_artist', - 'unknown', 'none', 'null', '', - } + # Forced job queue (for "Run Now" button — processed by main loop) + self._force_run_queue: List[str] = [] + self._force_run_lock = threading.Lock() + + # Config manager (set externally after init) + self._config_manager = None + + # Lazy client accessors + self._spotify_client = None + self._itunes_client = None + self._mb_client = None + self._acoustid_client = None + self._metadata_cache = None logger.info("Repair worker initialized (transfer_folder=%s)", self.transfer_folder) # ------------------------------------------------------------------ - # Lazy SpotifyClient accessor + # Config manager + # ------------------------------------------------------------------ + def set_config_manager(self, config_manager): + """Set the config manager for persisting job settings.""" + self._config_manager = config_manager + # Load master enabled state + if config_manager: + self.enabled = config_manager.get('repair.master_enabled', False) + + # ------------------------------------------------------------------ + # Lazy client accessors # ------------------------------------------------------------------ @property def spotify_client(self): @@ -92,9 +100,16 @@ class RepairWorker: logger.error("Failed to initialize SpotifyClient: %s", e) return self._spotify_client - # ------------------------------------------------------------------ - # Lazy client accessors - # ------------------------------------------------------------------ + @property + def itunes_client(self): + if self._itunes_client is None: + try: + from core.itunes_client import iTunesClient + self._itunes_client = iTunesClient() + except Exception as e: + logger.error("Failed to initialize iTunesClient: %s", e) + return self._itunes_client + @property def mb_client(self): if self._mb_client is None: @@ -106,17 +121,113 @@ class RepairWorker: return self._mb_client @property - def audiodb_client(self): - if self._audiodb_client is None: + def acoustid_client(self): + if self._acoustid_client is None: try: - from core.audiodb_client import AudioDBClient - self._audiodb_client = AudioDBClient() + from core.acoustid_client import AcoustIDClient + self._acoustid_client = AcoustIDClient() except Exception as e: - logger.error("Failed to initialize AudioDBClient: %s", e) - return self._audiodb_client + logger.error("Failed to initialize AcoustIDClient: %s", e) + return self._acoustid_client + + @property + def metadata_cache(self): + if self._metadata_cache is None: + try: + from core.metadata_cache import get_metadata_cache + self._metadata_cache = get_metadata_cache() + except Exception as e: + logger.error("Failed to get metadata cache: %s", e) + return self._metadata_cache # ------------------------------------------------------------------ - # Lifecycle (identical to AudioDB worker) + # Job registry + # ------------------------------------------------------------------ + def _ensure_jobs_loaded(self): + """Load job instances from the registry.""" + if self._jobs: + return + registry = get_all_jobs() + for job_id, job_cls in registry.items(): + try: + self._jobs[job_id] = job_cls() + except Exception as e: + logger.error("Failed to instantiate job %s: %s", job_id, e) + + def get_job_config(self, job_id: str) -> dict: + """Get the full config for a specific job.""" + self._ensure_jobs_loaded() + job = self._jobs.get(job_id) + if not job: + return {} + + defaults = { + 'enabled': job.default_enabled, + 'interval_hours': job.default_interval_hours, + 'settings': job.default_settings.copy(), + } + + if self._config_manager: + cfg = self._config_manager.get(f'repair.jobs.{job_id}', {}) + if isinstance(cfg, dict): + defaults['enabled'] = cfg.get('enabled', defaults['enabled']) + defaults['interval_hours'] = cfg.get('interval_hours', defaults['interval_hours']) + if 'settings' in cfg and isinstance(cfg['settings'], dict): + defaults['settings'].update(cfg['settings']) + + return defaults + + def set_job_enabled(self, job_id: str, enabled: bool): + """Enable or disable a specific job.""" + if self._config_manager: + self._config_manager.set(f'repair.jobs.{job_id}.enabled', enabled) + + def set_job_settings(self, job_id: str, interval_hours: int = None, settings: dict = None): + """Update job interval and/or settings.""" + if not self._config_manager: + return + if interval_hours is not None: + self._config_manager.set(f'repair.jobs.{job_id}.interval_hours', interval_hours) + if settings is not None: + current = self._config_manager.get(f'repair.jobs.{job_id}.settings', {}) + if isinstance(current, dict): + current.update(settings) + else: + current = settings + self._config_manager.set(f'repair.jobs.{job_id}.settings', current) + + def get_all_job_info(self) -> List[dict]: + """Get info for all jobs (for API response).""" + self._ensure_jobs_loaded() + jobs_info = [] + for job_id, job in self._jobs.items(): + config = self.get_job_config(job_id) + last_run = self._get_last_run(job_id) + next_run = None + if last_run and config['enabled']: + last_dt = datetime.fromisoformat(last_run['finished_at']) if last_run.get('finished_at') else None + if last_dt: + next_dt = last_dt + timedelta(hours=config['interval_hours']) + next_run = next_dt.isoformat() + + jobs_info.append({ + 'job_id': job_id, + 'display_name': job.display_name, + 'description': job.description, + 'icon': job.icon, + 'auto_fix': job.auto_fix, + 'enabled': config['enabled'], + 'interval_hours': config['interval_hours'], + 'settings': config['settings'], + 'default_settings': job.default_settings.copy(), + 'last_run': last_run, + 'next_run': next_run, + 'is_running': self._current_job_id == job_id, + }) + return jobs_info + + # ------------------------------------------------------------------ + # Lifecycle # ------------------------------------------------------------------ def start(self): if self.running: @@ -138,36 +249,100 @@ class RepairWorker: self.thread.join(timeout=5) logger.info("Repair worker stopped") + def toggle(self) -> bool: + """Toggle master enabled state. Returns new state.""" + self.enabled = not self.enabled + if self._config_manager: + self._config_manager.set('repair.master_enabled', self.enabled) + logger.info("Repair worker %s", "enabled" if self.enabled else "disabled") + return self.enabled + + def set_enabled(self, enabled: bool): + """Set master enabled state.""" + self.enabled = enabled + if self._config_manager: + self._config_manager.set('repair.master_enabled', enabled) + + # Backward compatibility def pause(self): - if not self.running: - logger.warning("Repair worker not running, cannot pause") - return - self.paused = True - logger.info("Repair worker paused") + self.set_enabled(False) 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") + self.set_enabled(True) + @property + def paused(self): + return not self.enabled + + @paused.setter + def paused(self, value): + self.enabled = not value + + # ------------------------------------------------------------------ + # Current item (backward compat for WebSocket tooltip) + # ------------------------------------------------------------------ + @property + def current_item(self): + if self._current_job_id: + return { + 'type': 'job', + 'name': self._current_job_name or self._current_job_id, + 'job_id': self._current_job_id, + } + return None + + @current_item.setter + def current_item(self, value): + # Backward compat — ignore direct sets + pass + + # ------------------------------------------------------------------ + # Stats + # ------------------------------------------------------------------ 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 + and self.enabled + and self._current_job_id is None ) - return { - 'enabled': True, - 'running': is_actually_running and not self.paused, - 'paused': self.paused, + + # Get pending findings count + findings_pending = self._get_findings_count('pending') + + result = { + 'enabled': self.enabled, + 'running': is_actually_running and self.enabled, + 'paused': not self.enabled, # backward compat 'idle': is_idle, 'current_item': self.current_item, + 'current_job': None, + 'findings_pending': findings_pending, 'stats': self.stats.copy(), - 'progress': self._get_progress() + 'progress': self._get_progress(), + } + + if self._current_job_id: + result['current_job'] = { + 'job_id': self._current_job_id, + 'display_name': self._current_job_name, + 'progress': self._current_progress.copy(), + } + + return result + + 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, + } } # ------------------------------------------------------------------ @@ -175,60 +350,589 @@ class RepairWorker: # ------------------------------------------------------------------ def _run(self): logger.info("Repair worker thread started") + self._ensure_jobs_loaded() while not self.should_stop: try: - if self.paused: - time.sleep(1) + # Check force-run queue even when disabled (user explicitly requested) + forced_job = None + with self._force_run_lock: + if self._force_run_queue: + forced_job = self._force_run_queue.pop(0) + + if forced_job: + self._run_job(forced_job) + time.sleep(2) continue - self.current_item = None + if not self.enabled: + self._current_job_id = None + self._current_job_name = None + time.sleep(2) + continue - # Reset stats for a new scan pass - self.stats = { - 'scanned': 0, - 'repaired': 0, - 'skipped': 0, - 'errors': 0, - 'pending': 0 - } - self._album_tracks_cache.clear() + # Find the next job to run based on staleness + next_job = self._pick_next_job() - 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) - # Also re-scan immediately if transfer path changes - sleep_until = time.time() + self.rescan_interval_hours * 3600 - last_path = self.transfer_folder - while time.time() < sleep_until and not self.should_stop: - if self.paused: - time.sleep(1) - continue - # Check if transfer path changed in settings - current_path = self._resolve_path(self._get_transfer_path_from_db()) - if current_path != last_path: - logger.info("Transfer path changed: %s -> %s — triggering rescan", last_path, current_path) - self.transfer_folder = current_path - break + if not next_job: + # Nothing due — sleep and re-check + self._current_job_id = None + self._current_job_name = None time.sleep(10) + continue + + # Run the selected job + self._run_job(next_job) + + # Brief pause between jobs + time.sleep(5) except Exception as e: logger.error("Error in repair worker loop: %s", e, exc_info=True) + self._current_job_id = None + self._current_job_name = None time.sleep(30) logger.info("Repair worker thread finished") + def _pick_next_job(self) -> Optional[str]: + """Pick the next job to run based on staleness priority. + + Returns job_id of the stalest job whose interval has elapsed, + or None if nothing is due. + """ + now = datetime.now() + best_job_id = None + best_staleness = -1 + + for job_id, job in self._jobs.items(): + config = self.get_job_config(job_id) + if not config['enabled']: + continue + + interval_hours = config['interval_hours'] + last_run = self._get_last_run(job_id) + + if not last_run or not last_run.get('finished_at'): + # Never run — highest staleness + best_job_id = job_id + best_staleness = float('inf') + continue + + try: + last_finished = datetime.fromisoformat(last_run['finished_at']) + elapsed_hours = (now - last_finished).total_seconds() / 3600 + + if elapsed_hours < interval_hours: + continue # Not due yet + + staleness = elapsed_hours / interval_hours + if staleness > best_staleness: + best_staleness = staleness + best_job_id = job_id + except (ValueError, TypeError): + # Malformed timestamp — treat as never run + best_job_id = job_id + best_staleness = float('inf') + + return best_job_id + + def _run_job(self, job_id: str): + """Execute a single job and record the run.""" + job = self._jobs.get(job_id) + if not job: + return + + logger.info("Starting job: %s (%s)", job.display_name, job_id) + + self._current_job_id = job_id + self._current_job_name = job.display_name + self._current_progress = {'scanned': 0, 'total': 0, 'percent': 0} + + # Re-read transfer path + raw = self._get_transfer_path_from_db() + self.transfer_folder = self._resolve_path(raw) + + # Record job start + run_id = self._record_job_start(job_id) + + # Build context + context = JobContext( + db=self.db, + transfer_folder=self.transfer_folder, + config_manager=self._config_manager, + spotify_client=self.spotify_client, + itunes_client=self.itunes_client, + mb_client=self.mb_client, + acoustid_client=self.acoustid_client, + metadata_cache=self.metadata_cache, + create_finding=self._create_finding, + should_stop=lambda: self.should_stop, + is_paused=lambda: not self.enabled, + update_progress=self._update_progress, + ) + + start_time = time.time() + result = JobResult() + + try: + result = job.scan(context) + except Exception as e: + logger.error("Job %s failed: %s", job_id, e, exc_info=True) + result.errors += 1 + + duration = time.time() - start_time + + # Update aggregate stats + self.stats['scanned'] += result.scanned + self.stats['repaired'] += result.auto_fixed + self.stats['skipped'] += result.skipped + self.stats['errors'] += result.errors + + # Record job completion + self._record_job_finish(run_id, job_id, result, duration) + + logger.info( + "Job %s complete: scanned=%d fixed=%d findings=%d errors=%d (%.1fs)", + job_id, result.scanned, result.auto_fixed, + result.findings_created, result.errors, duration + ) + + self._current_job_id = None + self._current_job_name = None + self._current_progress = {'scanned': 0, 'total': 0, 'percent': 0} + + def run_job_now(self, job_id: str): + """Queue a job for immediate execution by the main worker loop. + + Uses a thread-safe queue instead of spawning a separate thread + to avoid race conditions with the main loop's _run_job(). + """ + self._ensure_jobs_loaded() + if job_id not in self._jobs: + logger.warning("Unknown job: %s", job_id) + return + + with self._force_run_lock: + if job_id not in self._force_run_queue: + self._force_run_queue.append(job_id) + logger.info("Job %s queued for immediate run", job_id) + + def _update_progress(self, scanned: int, total: int): + """Callback for jobs to report progress.""" + percent = round(scanned / total * 100) if total > 0 else 0 + self._current_progress = { + 'scanned': scanned, + 'total': total, + 'percent': percent, + } + # ------------------------------------------------------------------ - # Library scanning + # Findings + # ------------------------------------------------------------------ + def _create_finding(self, job_id: str, finding_type: str, severity: str, + entity_type: str, entity_id: str, file_path: str, + title: str, description: str, details: dict = None): + """Create a repair finding in the database.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + # Dedup check: skip if same finding already pending + cursor.execute(""" + SELECT id FROM repair_findings + WHERE job_id = ? AND finding_type = ? AND status = 'pending' + AND ((entity_type = ? AND entity_id = ?) OR (file_path = ? AND file_path IS NOT NULL)) + LIMIT 1 + """, (job_id, finding_type, entity_type, entity_id, file_path)) + + if cursor.fetchone(): + return # Already exists + + cursor.execute(""" + INSERT INTO repair_findings + (job_id, finding_type, severity, status, entity_type, entity_id, + file_path, title, description, details_json) + VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?) + """, ( + job_id, finding_type, severity, entity_type, entity_id, + file_path, title, description, + json.dumps(details) if details else '{}' + )) + conn.commit() + except Exception as e: + logger.debug("Error creating finding: %s", e) + finally: + if conn: + conn.close() + + def get_findings(self, job_id: str = None, status: str = None, + severity: str = None, page: int = 0, limit: int = 50) -> dict: + """Get paginated findings with optional filters.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + where_parts = [] + params = [] + + if job_id: + where_parts.append("job_id = ?") + params.append(job_id) + if status: + where_parts.append("status = ?") + params.append(status) + if severity: + where_parts.append("severity = ?") + params.append(severity) + + where = f"WHERE {' AND '.join(where_parts)}" if where_parts else "" + + # Count total + cursor.execute(f"SELECT COUNT(*) FROM repair_findings {where}", params) + total = cursor.fetchone()[0] + + # Fetch page + offset = page * limit + cursor.execute(f""" + SELECT id, job_id, finding_type, severity, status, entity_type, + entity_id, file_path, title, description, details_json, + user_action, resolved_at, created_at, updated_at + FROM repair_findings + {where} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, params + [limit, offset]) + + items = [] + for row in cursor.fetchall(): + items.append({ + 'id': row[0], + 'job_id': row[1], + 'finding_type': row[2], + 'severity': row[3], + 'status': row[4], + 'entity_type': row[5], + 'entity_id': row[6], + 'file_path': row[7], + 'title': row[8], + 'description': row[9], + 'details': json.loads(row[10]) if row[10] else {}, + 'user_action': row[11], + 'resolved_at': row[12], + 'created_at': row[13], + 'updated_at': row[14], + }) + + return {'items': items, 'total': total, 'page': page, 'limit': limit} + + except Exception as e: + logger.error("Error fetching findings: %s", e, exc_info=True) + return {'items': [], 'total': 0, 'page': page, 'limit': limit} + finally: + if conn: + conn.close() + + def resolve_finding(self, finding_id: int, action: str = None) -> bool: + """Resolve a finding with an optional action.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE repair_findings + SET status = 'resolved', user_action = ?, resolved_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (action, finding_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error("Error resolving finding %s: %s", finding_id, e) + return False + finally: + if conn: + conn.close() + + def dismiss_finding(self, finding_id: int) -> bool: + """Dismiss a finding.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE repair_findings + SET status = 'dismissed', resolved_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (finding_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error("Error dismissing finding %s: %s", finding_id, e) + return False + finally: + if conn: + conn.close() + + def bulk_update_findings(self, finding_ids: List[int], action: str) -> int: + """Bulk resolve or dismiss findings. Returns count updated.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + placeholders = ','.join(['?'] * len(finding_ids)) + + if action == 'dismiss': + cursor.execute(f""" + UPDATE repair_findings + SET status = 'dismissed', resolved_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id IN ({placeholders}) + """, finding_ids) + else: + cursor.execute(f""" + UPDATE repair_findings + SET status = 'resolved', user_action = ?, resolved_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id IN ({placeholders}) + """, [action] + finding_ids) + + conn.commit() + return cursor.rowcount + except Exception as e: + logger.error("Error bulk updating findings: %s", e) + return 0 + finally: + if conn: + conn.close() + + def _get_findings_count(self, status: str = None) -> int: + """Get count of findings by status.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + if status: + cursor.execute("SELECT COUNT(*) FROM repair_findings WHERE status = ?", (status,)) + else: + cursor.execute("SELECT COUNT(*) FROM repair_findings") + row = cursor.fetchone() + return row[0] if row else 0 + except Exception: + return 0 + finally: + if conn: + conn.close() + + def get_findings_counts(self) -> dict: + """Get counts by status.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT status, COUNT(*) FROM repair_findings + GROUP BY status + """) + counts = {row[0]: row[1] for row in cursor.fetchall()} + return { + 'pending': counts.get('pending', 0), + 'resolved': counts.get('resolved', 0), + 'dismissed': counts.get('dismissed', 0), + 'auto_fixed': counts.get('auto_fixed', 0), + 'total': sum(counts.values()), + } + except Exception: + return {'pending': 0, 'resolved': 0, 'dismissed': 0, 'auto_fixed': 0, 'total': 0} + finally: + if conn: + conn.close() + + # ------------------------------------------------------------------ + # Job run history + # ------------------------------------------------------------------ + def _record_job_start(self, job_id: str) -> Optional[int]: + """Record a job run start. Returns run_id.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO repair_job_runs (job_id, started_at, status) + VALUES (?, CURRENT_TIMESTAMP, 'running') + """, (job_id,)) + conn.commit() + return cursor.lastrowid + except Exception as e: + logger.debug("Error recording job start: %s", e) + return None + finally: + if conn: + conn.close() + + def _record_job_finish(self, run_id: Optional[int], job_id: str, + result: JobResult, duration: float): + """Record a job run completion.""" + if not run_id: + return + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + status = 'completed' + cursor.execute(""" + UPDATE repair_job_runs + SET finished_at = CURRENT_TIMESTAMP, duration_seconds = ?, + items_scanned = ?, findings_created = ?, auto_fixed = ?, + errors = ?, status = ? + WHERE id = ? + """, (duration, result.scanned, result.findings_created, + result.auto_fixed, result.errors, status, run_id)) + conn.commit() + except Exception as e: + logger.debug("Error recording job finish: %s", e) + finally: + if conn: + conn.close() + + def _get_last_run(self, job_id: str) -> Optional[dict]: + """Get the most recent run for a job.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, started_at, finished_at, duration_seconds, + items_scanned, findings_created, auto_fixed, errors, status + FROM repair_job_runs + WHERE job_id = ? + ORDER BY started_at DESC + LIMIT 1 + """, (job_id,)) + row = cursor.fetchone() + if not row: + return None + return { + 'id': row[0], + 'started_at': row[1], + 'finished_at': row[2], + 'duration_seconds': row[3], + 'items_scanned': row[4], + 'findings_created': row[5], + 'auto_fixed': row[6], + 'errors': row[7], + 'status': row[8], + } + except Exception: + return None + finally: + if conn: + conn.close() + + def get_history(self, job_id: str = None, limit: int = 50) -> List[dict]: + """Get job run history.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + if job_id: + cursor.execute(""" + SELECT id, job_id, started_at, finished_at, duration_seconds, + items_scanned, findings_created, auto_fixed, errors, status + FROM repair_job_runs + WHERE job_id = ? + ORDER BY started_at DESC + LIMIT ? + """, (job_id, limit)) + else: + cursor.execute(""" + SELECT id, job_id, started_at, finished_at, duration_seconds, + items_scanned, findings_created, auto_fixed, errors, status + FROM repair_job_runs + ORDER BY started_at DESC + LIMIT ? + """, (limit,)) + + runs = [] + for row in cursor.fetchall(): + # Get display name for this job + job = self._jobs.get(row[1]) + display_name = job.display_name if job else row[1] + runs.append({ + 'id': row[0], + 'job_id': row[1], + 'display_name': display_name, + 'started_at': row[2], + 'finished_at': row[3], + 'duration_seconds': row[4], + 'items_scanned': row[5], + 'findings_created': row[6], + 'auto_fixed': row[7], + 'errors': row[8], + 'status': row[9], + }) + return runs + except Exception as e: + logger.error("Error fetching job history: %s", e, exc_info=True) + return [] + finally: + if conn: + conn.close() + + # ------------------------------------------------------------------ + # Batch scan support (post-download) + # ------------------------------------------------------------------ + def register_folder(self, batch_id: str, folder_path: str): + """Register an album folder for repair scanning when its batch completes.""" + if not folder_path: + return + with self._batch_folders_lock: + self._batch_folders.setdefault(batch_id, set()).add(folder_path) + + def process_batch(self, batch_id: str): + """Scan all folders registered for a completed batch. + + Runs the track number repair job on specific folders only. + """ + with self._batch_folders_lock: + folders = self._batch_folders.pop(batch_id, set()) + + if not folders: + return + + self._ensure_jobs_loaded() + tnr_job = self._jobs.get('track_number_repair') + if not tnr_job: + return + + def _do_scan(): + context = JobContext( + db=self.db, + transfer_folder=self.transfer_folder, + config_manager=self._config_manager, + spotify_client=self.spotify_client, + itunes_client=self.itunes_client, + mb_client=self.mb_client, + should_stop=lambda: self.should_stop, + is_paused=lambda: False, # Batch scans don't respect pause + ) + + try: + logger.info("[Repair] Batch %s: scanning %d folders", batch_id, len(folders)) + result = tnr_job.scan_folders(list(folders), context) + logger.info("[Repair] Batch %s complete: scanned=%d fixed=%d errors=%d", + batch_id, result.scanned, result.auto_fixed, result.errors) + except Exception as e: + logger.error("[Repair] Batch %s failed: %s", batch_id, e, exc_info=True) + + threading.Thread(target=_do_scan, daemon=True).start() + + # ------------------------------------------------------------------ + # Path utilities # ------------------------------------------------------------------ @staticmethod def _resolve_path(path_str: str) -> str: @@ -256,885 +960,3 @@ class RepairWorker: if conn: conn.close() return './Transfer' - - def _scan_library(self): - """Walk the transfer folder and process album folders.""" - # Re-read transfer path from DB each scan so changes take effect without restart - raw = self._get_transfer_path_from_db() - self.transfer_folder = self._resolve_path(raw) - 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 - - # ------------------------------------------------------------------ - # On-demand single-folder scan (called from post-processing) - # ------------------------------------------------------------------ - def register_folder(self, batch_id: str, folder_path: str): - """Register an album folder for repair scanning when its batch completes. - - Called during post-processing for each track. The actual scan is - deferred until process_batch() is called at batch completion. - """ - if not folder_path: - return - with self._batch_folders_lock: - self._batch_folders.setdefault(batch_id, set()).add(folder_path) - - def process_batch(self, batch_id: str): - """Scan all folders registered for a completed batch. - - Called when a download modal/batch finishes all its tracks. - Runs in a background thread to avoid blocking the caller. - """ - with self._batch_folders_lock: - folders = self._batch_folders.pop(batch_id, set()) - - if not folders: - return - - def _do_scan(): - for folder_path in folders: - try: - if not os.path.isdir(folder_path): - continue - - filenames = [ - f for f in os.listdir(folder_path) - if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS - ] - if not filenames: - continue - - logger.info("[Repair] Batch %s scan: %s (%d files)", - batch_id, os.path.basename(folder_path), len(filenames)) - self._repair_album_track_numbers(folder_path, filenames) - except Exception as e: - logger.error("[Repair] Error scanning %s for batch %s: %s", - folder_path, batch_id, e, exc_info=True) - - threading.Thread(target=_do_scan, daemon=True).start() - - # ------------------------------------------------------------------ - # 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-2: Resolve album tracklist via cascading fallbacks --- - api_tracks = self._resolve_album_tracklist(file_track_data, folder_path) - if not api_tracks: - 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 _is_valid_album_id(self, album_id: Optional[str]) -> bool: - """Check if an album ID is a real API identifier, not a placeholder.""" - if not album_id: - return False - if album_id.strip().lower() in self._placeholder_ids: - return False - if len(album_id.strip()) < 5: - return False - return True - - def _read_spotify_track_id_from_file(self, file_path: str) -> Optional[str]: - """Read SPOTIFY_TRACK_ID from embedded tags.""" - 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 - - if hasattr(audio, 'tags') and audio.tags is not None: - if isinstance(audio.tags, ID3): - for key in ['TXXX:SPOTIFY_TRACK_ID', 'TXXX:spotify_track_id']: - frame = audio.tags.getall(key) - if frame and frame[0].text: - return str(frame[0].text[0]) - - elif isinstance(audio, (FLAC, OggVorbis)): - for key in ['spotify_track_id', 'SPOTIFY_TRACK_ID']: - val = audio.get(key) - if val: - return str(val[0]) - - elif isinstance(audio, MP4): - for key in ['----:com.apple.iTunes:SPOTIFY_TRACK_ID', - '----:com.apple.iTunes:spotify_track_id']: - val = audio.tags.get(key) - if val: - raw = val[0] - return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw) - - except Exception as e: - logger.debug("Error reading Spotify track ID from %s: %s", file_path, e) - return None - - def _read_musicbrainz_album_id_from_file(self, file_path: str) -> Optional[str]: - """Read MusicBrainz Album Id (release MBID) from embedded tags.""" - 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 - - if hasattr(audio, 'tags') and audio.tags is not None: - if isinstance(audio.tags, ID3): - for key in ['TXXX:MusicBrainz Album Id', 'TXXX:MUSICBRAINZ_ALBUMID', - 'TXXX:musicbrainz_albumid']: - frame = audio.tags.getall(key) - if frame and frame[0].text: - return str(frame[0].text[0]) - - elif isinstance(audio, (FLAC, OggVorbis)): - for key in ['musicbrainz_albumid', 'MUSICBRAINZ_ALBUMID', - 'MusicBrainz Album Id']: - val = audio.get(key) - if val: - return str(val[0]) - - elif isinstance(audio, MP4): - for key in ['----:com.apple.iTunes:MusicBrainz Album Id', - '----:com.apple.iTunes:MUSICBRAINZ_ALBUMID', - '----:com.apple.music.albums:MUSICBRAINZ_ALBUMID']: - val = audio.tags.get(key) - if val: - raw = val[0] - return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw) - - except Exception as e: - logger.debug("Error reading MusicBrainz album ID from %s: %s", file_path, e) - return None - - def _read_album_artist_from_file(self, file_path: str) -> Tuple[Optional[str], Optional[str]]: - """Read album name and artist name from embedded tags. - Returns (album_name, artist_name).""" - 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 - - album_name = None - artist_name = None - - if hasattr(audio, 'tags') and audio.tags is not None: - if isinstance(audio.tags, ID3): - frames = audio.tags.getall('TALB') - if frames and frames[0].text: - album_name = str(frames[0].text[0]) - # Prefer album artist (TPE2), fall back to track artist (TPE1) - for tag in ['TPE2', 'TPE1']: - frames = audio.tags.getall(tag) - if frames and frames[0].text: - artist_name = str(frames[0].text[0]) - break - - elif isinstance(audio, (FLAC, OggVorbis)): - val = audio.get('album') - if val: - album_name = str(val[0]) - for key in ['albumartist', 'artist']: - val = audio.get(key) - if val: - artist_name = str(val[0]) - break - - elif isinstance(audio, MP4): - val = audio.tags.get('\xa9alb') - if val: - album_name = str(val[0]) - for key in ['aART', '\xa9ART']: - val = audio.tags.get(key) - if val: - artist_name = str(val[0]) - break - - return album_name, artist_name - - except Exception as e: - logger.debug("Error reading album/artist 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 - - def _get_tracklist_from_musicbrainz(self, mbid: str) -> Optional[List[Dict]]: - """Fetch album tracklist from MusicBrainz by release MBID. - Returns list of dicts with {name, track_number, disc_number} matching - the Spotify tracklist format, or None on failure.""" - cache_key = f"mb:{mbid}" - if cache_key in self._album_tracks_cache: - return self._album_tracks_cache[cache_key] - - client = self.mb_client - if not client: - logger.warning("No MusicBrainzClient available for release lookup") - return None - - try: - release = client.get_release(mbid, includes=['recordings']) - if not release or 'media' not in release: - logger.debug("No media returned for MusicBrainz release %s", mbid) - self._album_tracks_cache[cache_key] = None - return None - - tracks = [] - for medium in release['media']: - disc_num = medium.get('position', 1) - for track in medium.get('tracks', []): - tracks.append({ - 'name': track.get('title', ''), - 'track_number': track.get('position'), - 'disc_number': disc_num, - }) - - if not tracks: - self._album_tracks_cache[cache_key] = None - return None - - self._album_tracks_cache[cache_key] = tracks - return tracks - - except Exception as e: - logger.error("Error fetching MusicBrainz release %s: %s", mbid, e) - return None - - def _get_musicbrainz_id_via_audiodb(self, artist_name: str, album_name: str) -> Optional[str]: - """Search AudioDB for an album and extract its MusicBrainz release ID.""" - client = self.audiodb_client - if not client: - return None - - try: - result = client.search_album(artist_name, album_name) - if result: - mb_id = result.get('strMusicBrainzAlbumID') - if mb_id and mb_id.strip(): - logger.debug("AudioDB returned MusicBrainz ID %s for '%s - %s'", - mb_id, artist_name, album_name) - return mb_id.strip() - except Exception as e: - logger.debug("AudioDB lookup failed for '%s - %s': %s", artist_name, album_name, e) - return None - - def _resolve_album_tracklist(self, file_track_data: List[Tuple[str, str, Optional[int]]], - folder_path: str) -> Optional[List[Dict]]: - """Cascading resolution to find the correct album tracklist. - - Tries in order: - 1. SPOTIFY_ALBUM_ID from tags (skip if placeholder) - 2. ITUNES_ALBUM_ID from tags - 3. SPOTIFY_TRACK_ID → get_track_details() → real album ID (requires Spotify auth) - 4. Search Spotify/iTunes by album name + artist - 5. MusicBrainz Album Id from tags → MusicBrainz release lookup - 6. AudioDB search → MusicBrainz album ID → MusicBrainz release - """ - folder_name = os.path.basename(folder_path) - - # --- Collect all available IDs from files in one pass --- - spotify_album_id = None - itunes_album_id = None - spotify_track_id = None - mb_album_id = None - album_name = None - artist_name = None - - for fpath, fname, _ in file_track_data: - # Album IDs (split Spotify and iTunes) - if not spotify_album_id or not itunes_album_id: - aid, source = self._read_album_id_from_file(fpath) - if aid and source == 'spotify' and not spotify_album_id: - spotify_album_id = aid - elif aid and source == 'itunes' and not itunes_album_id: - itunes_album_id = aid - - # Spotify track ID - if not spotify_track_id: - spotify_track_id = self._read_spotify_track_id_from_file(fpath) - - # MusicBrainz album ID - if not mb_album_id: - mb_album_id = self._read_musicbrainz_album_id_from_file(fpath) - - # Album name + artist - if not album_name: - album_name, artist_name = self._read_album_artist_from_file(fpath) - - # Stop early if we have everything - if (spotify_album_id and itunes_album_id and spotify_track_id - and mb_album_id and album_name): - break - - # --- Fallback 1: Spotify album ID --- - if spotify_album_id and self._is_valid_album_id(spotify_album_id): - tracks = self._get_album_tracklist(spotify_album_id) - if tracks: - logger.info("[Repair] %s — resolved via Spotify album ID: %s", folder_name, spotify_album_id) - return tracks - - # --- Fallback 2: iTunes album ID --- - if itunes_album_id and self._is_valid_album_id(itunes_album_id): - tracks = self._get_album_tracklist(itunes_album_id) - if tracks: - logger.info("[Repair] %s — resolved via iTunes album ID: %s", folder_name, itunes_album_id) - return tracks - - # --- Fallback 3: Spotify track ID → discover album ID (requires Spotify auth) --- - client = self.spotify_client - if spotify_track_id and client and client.is_spotify_authenticated(): - try: - track_details = client.get_track_details(spotify_track_id) - if track_details and track_details.get('album', {}).get('id'): - real_album_id = track_details['album']['id'] - tracks = self._get_album_tracklist(real_album_id) - if tracks: - logger.info("[Repair] %s — resolved via Spotify track ID %s → album %s", - folder_name, spotify_track_id, real_album_id) - return tracks - except Exception as e: - logger.debug("Spotify track lookup failed for %s: %s", spotify_track_id, e) - - # --- Fallback 4: Search Spotify/iTunes by album name + artist --- - if album_name and client: - try: - query = f"{artist_name} {album_name}" if artist_name else album_name - results = client.search_albums(query, limit=5) - if results: - # Pick the first result (best match from API) - best = results[0] - tracks = self._get_album_tracklist(best.id) - if tracks: - logger.info("[Repair] %s — resolved via album search: '%s' → %s", - folder_name, query, best.id) - return tracks - except Exception as e: - logger.debug("Album search failed for '%s': %s", album_name, e) - - # --- Fallback 5: MusicBrainz album ID from tags --- - if mb_album_id: - tracks = self._get_tracklist_from_musicbrainz(mb_album_id) - if tracks: - logger.info("[Repair] %s — resolved via MusicBrainz album ID: %s", folder_name, mb_album_id) - return tracks - - # --- Fallback 6: AudioDB → MusicBrainz --- - if album_name and artist_name: - adb_mb_id = self._get_musicbrainz_id_via_audiodb(artist_name, album_name) - if adb_mb_id and adb_mb_id != mb_album_id: # Don't retry same MBID - tracks = self._get_tracklist_from_musicbrainz(adb_mb_id) - if tracks: - logger.info("[Repair] %s — resolved via AudioDB → MusicBrainz: %s", - folder_name, adb_mb_id) - return tracks - - logger.warning("[Repair] %s — all tracklist resolution strategies exhausted", folder_name) - 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) - - # Rename associated .lrc file if it exists - lrc_path = os.path.join(parent_dir, basename + '.lrc') - if os.path.isfile(lrc_path): - new_lrc_path = os.path.join(parent_dir, new_basename + '.lrc') - if not os.path.exists(new_lrc_path): - os.rename(lrc_path, new_lrc_path) - logger.info("Renamed LRC: %s.lrc → %s.lrc", basename, new_basename) - - 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 d49e7e01..573db96c 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -355,6 +355,9 @@ class MusicDatabase: # Universal metadata cache (Spotify + iTunes API responses) self._add_metadata_cache_tables(cursor) + # Repair worker v2 tables (findings + job runs) + self._add_repair_worker_tables(cursor) + # Mirrored playlists — persistent backup of parsed playlists from any service cursor.execute(""" CREATE TABLE IF NOT EXISTS mirrored_playlists ( @@ -2377,6 +2380,64 @@ class MusicDatabase: except Exception as e: logger.error(f"Error creating metadata cache tables: {e}") + def _add_repair_worker_tables(self, cursor): + """Create repair_findings and repair_job_runs tables for the multi-job repair worker.""" + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'repair_worker_v2' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + + logger.info("Creating repair worker v2 tables...") + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS repair_findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL, + finding_type TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'info', + status TEXT NOT NULL DEFAULT 'pending', + entity_type TEXT, + entity_id TEXT, + file_path TEXT, + title TEXT NOT NULL, + description TEXT, + details_json TEXT DEFAULT '{}', + user_action TEXT, + resolved_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_rf_job ON repair_findings (job_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_rf_status ON repair_findings (status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_rf_type ON repair_findings (finding_type)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_rf_created ON repair_findings (created_at)") + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS repair_job_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL, + started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP, + duration_seconds REAL, + items_scanned INTEGER DEFAULT 0, + findings_created INTEGER DEFAULT 0, + auto_fixed INTEGER DEFAULT 0, + errors INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'running' + ) + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_rjr_job ON repair_job_runs (job_id)") + + cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('repair_worker_v2', '1')") + + logger.info("Repair worker v2 tables created successfully") + + except Exception as e: + logger.error(f"Error creating repair worker v2 tables: {e}") + # ── Profile CRUD ────────────────────────────────────────────────── def get_all_profiles(self) -> List[Dict[str, Any]]: diff --git a/web_server.py b/web_server.py index 4d64cbc9..bc1ec106 100644 --- a/web_server.py +++ b/web_server.py @@ -36697,6 +36697,7 @@ try: 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.set_config_manager(config_manager) repair_worker.start() print("✅ Repair worker initialized and started") except Exception as e: @@ -36707,14 +36708,17 @@ except Exception as e: @app.route('/api/repair/status', methods=['GET']) def repair_status(): - """Get repair worker status for UI polling""" + """Get repair worker status""" try: if repair_worker is None: return jsonify({ 'enabled': False, 'running': False, - 'paused': False, + 'paused': True, + 'idle': False, 'current_item': None, + 'current_job': None, + 'findings_pending': 0, 'stats': {'scanned': 0, 'repaired': 0, 'skipped': 0, 'errors': 0, 'pending': 0}, 'progress': {} }), 200 @@ -36725,32 +36729,203 @@ def repair_status(): 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""" +@app.route('/api/repair/toggle', methods=['POST']) +def repair_toggle(): + """Toggle master enable/disable""" try: if repair_worker is None: return jsonify({'error': 'Repair worker not initialized'}), 400 + new_state = repair_worker.toggle() + logger.info("Repair worker %s via UI", "enabled" if new_state else "disabled") + return jsonify({'enabled': new_state}), 200 + except Exception as e: + logger.error(f"Error toggling repair worker: {e}") + return jsonify({'error': str(e)}), 500 + +# Backward compat aliases +@app.route('/api/repair/pause', methods=['POST']) +def repair_pause(): + 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() + return jsonify({'status': 'running'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/jobs', methods=['GET']) +def repair_jobs_list(): + """Get all jobs with config and last run info""" + try: + if repair_worker is None: + return jsonify({'jobs': []}), 200 + jobs = repair_worker.get_all_job_info() + return jsonify({'jobs': jobs}), 200 + except Exception as e: + logger.error(f"Error getting repair jobs: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/jobs//toggle', methods=['POST']) +def repair_job_toggle(job_id): + """Enable/disable a specific job""" 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 + data = request.get_json(silent=True) or {} + enabled = data.get('enabled') + + if enabled is None: + # Toggle — get current state and flip it + config = repair_worker.get_job_config(job_id) + enabled = not config.get('enabled', False) + + repair_worker.set_job_enabled(job_id, enabled) + logger.info("Repair job %s %s via UI", job_id, "enabled" if enabled else "disabled") + return jsonify({'job_id': job_id, 'enabled': enabled}), 200 except Exception as e: - logger.error(f"Error resuming repair worker: {e}") + logger.error(f"Error toggling repair job {job_id}: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/jobs//settings', methods=['PUT']) +def repair_job_settings(job_id): + """Update job interval and/or settings""" + try: + if repair_worker is None: + return jsonify({'error': 'Repair worker not initialized'}), 400 + + data = request.get_json(silent=True) or {} + interval_hours = data.get('interval_hours') + settings = data.get('settings') + + repair_worker.set_job_settings(job_id, interval_hours=interval_hours, settings=settings) + logger.info("Repair job %s settings updated via UI", job_id) + return jsonify({'success': True}), 200 + except Exception as e: + logger.error(f"Error updating repair job settings {job_id}: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/jobs//run', methods=['POST']) +def repair_job_run(job_id): + """Trigger immediate run of a specific job""" + try: + if repair_worker is None: + return jsonify({'error': 'Repair worker not initialized'}), 400 + + repair_worker.run_job_now(job_id) + logger.info("Repair job %s triggered manually via UI", job_id) + return jsonify({'success': True, 'job_id': job_id}), 200 + except Exception as e: + logger.error(f"Error running repair job {job_id}: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/findings', methods=['GET']) +def repair_findings_list(): + """Get paginated findings with filters""" + try: + if repair_worker is None: + return jsonify({'items': [], 'total': 0, 'page': 0, 'limit': 50}), 200 + + job_id = request.args.get('job_id') + status = request.args.get('status') + severity = request.args.get('severity') + page = int(request.args.get('page', 0)) + limit = int(request.args.get('limit', 50)) + + result = repair_worker.get_findings( + job_id=job_id, status=status, severity=severity, + page=page, limit=limit + ) + return jsonify(result), 200 + except Exception as e: + logger.error(f"Error getting repair findings: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/findings/counts', methods=['GET']) +def repair_findings_counts(): + """Get findings counts by status""" + try: + if repair_worker is None: + return jsonify({'pending': 0, 'resolved': 0, 'dismissed': 0, 'total': 0}), 200 + + counts = repair_worker.get_findings_counts() + return jsonify(counts), 200 + except Exception as e: + logger.error(f"Error getting findings counts: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/findings//resolve', methods=['POST']) +def repair_finding_resolve(finding_id): + """Resolve a finding with optional action""" + try: + if repair_worker is None: + return jsonify({'error': 'Repair worker not initialized'}), 400 + + data = request.get_json(silent=True) or {} + action = data.get('action') + success = repair_worker.resolve_finding(finding_id, action) + return jsonify({'success': success}), 200 + except Exception as e: + logger.error(f"Error resolving finding {finding_id}: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/findings//dismiss', methods=['POST']) +def repair_finding_dismiss(finding_id): + """Dismiss a finding""" + try: + if repair_worker is None: + return jsonify({'error': 'Repair worker not initialized'}), 400 + + success = repair_worker.dismiss_finding(finding_id) + return jsonify({'success': success}), 200 + except Exception as e: + logger.error(f"Error dismissing finding {finding_id}: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/findings/bulk', methods=['POST']) +def repair_findings_bulk(): + """Bulk resolve or dismiss findings""" + try: + if repair_worker is None: + return jsonify({'error': 'Repair worker not initialized'}), 400 + + data = request.get_json(silent=True) or {} + finding_ids = data.get('ids', []) + action = data.get('action', 'dismiss') + + if not finding_ids: + return jsonify({'error': 'No finding IDs provided'}), 400 + + count = repair_worker.bulk_update_findings(finding_ids, action) + return jsonify({'success': True, 'updated': count}), 200 + except Exception as e: + logger.error(f"Error bulk updating findings: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/repair/history', methods=['GET']) +def repair_history(): + """Get job run history""" + try: + if repair_worker is None: + return jsonify({'runs': []}), 200 + + job_id = request.args.get('job_id') + limit = int(request.args.get('limit', 50)) + runs = repair_worker.get_history(job_id=job_id, limit=limit) + return jsonify({'runs': runs}), 200 + except Exception as e: + logger.error(f"Error getting repair history: {e}") return jsonify({'error': str(e)}), 500 # ================================================================================================ diff --git a/webui/index.html b/webui/index.html index e8072ea0..cf273afe 100644 --- a/webui/index.html +++ b/webui/index.html @@ -506,9 +506,10 @@
-
@@ -5519,6 +5520,73 @@
+ + +
diff --git a/webui/static/script.js b/webui/static/script.js index b002a957..9196f3e5 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -49280,32 +49280,430 @@ function updateRepairStatusFromData(data) { const tracks = data.progress.tracks || {}; tooltipProgress.textContent = `Checked: ${tracks.checked || 0} / ${tracks.total || 0} (${tracks.percent || 0}%) | Repaired: ${tracks.repaired || 0}`; } + + // Update findings badge + const badge = document.getElementById('repair-findings-badge'); + const findingsPending = data.findings_pending || 0; + if (badge) { + badge.textContent = findingsPending; + badge.style.display = findingsPending > 0 ? '' : 'none'; + } + const tabBadge = document.getElementById('repair-findings-tab-badge'); + if (tabBadge) { + tabBadge.textContent = findingsPending; + tabBadge.style.display = findingsPending > 0 ? '' : 'none'; + } + + // Update master toggle in modal if open + const masterToggle = document.getElementById('repair-master-toggle'); + const masterLabel = document.getElementById('repair-master-label'); + if (masterToggle) masterToggle.checked = data.enabled || false; + if (masterLabel) masterLabel.textContent = data.enabled ? 'Enabled' : 'Disabled'; + + // Update button state + if (!data.enabled) { + button.classList.add('paused'); + button.classList.remove('active', 'complete'); + } } +// ── Repair Modal State ── +let _repairCurrentTab = 'jobs'; +let _repairFindingsPage = 0; +let _repairSelectedFindings = new Set(); +const REPAIR_FINDINGS_PAGE_SIZE = 30; + /** - * Toggle repair worker pause/resume + * Open the Library Maintenance modal */ -async function toggleRepairWorker() { +function openRepairModal() { + const modal = document.getElementById('repair-modal'); + if (!modal) return; + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + _repairCurrentTab = 'jobs'; + switchRepairTab('jobs'); + // Load master toggle state + updateRepairStatus(); +} + +function closeRepairModal() { + const modal = document.getElementById('repair-modal'); + if (!modal) return; + modal.style.display = 'none'; + document.body.style.overflow = ''; +} + +async function toggleRepairMaster() { try { - const button = document.getElementById('repair-button'); - if (!button) return; + const response = await fetch('/api/repair/toggle', { method: 'POST' }); + if (!response.ok) throw new Error('Failed to toggle'); + const data = await response.json(); + const label = document.getElementById('repair-master-label'); + const toggle = document.getElementById('repair-master-toggle'); + if (label) label.textContent = data.enabled ? 'Enabled' : 'Disabled'; + if (toggle) toggle.checked = data.enabled; + await updateRepairStatus(); + } catch (error) { + console.error('Error toggling repair master:', error); + showToast('Error toggling maintenance worker', 'error'); + } +} - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/repair/pause' : '/api/repair/resume'; +function switchRepairTab(tab) { + _repairCurrentTab = tab; + document.querySelectorAll('.repair-tab').forEach(t => { + t.classList.toggle('active', t.dataset.tab === tab); + }); + document.querySelectorAll('.repair-tab-content').forEach(c => { + c.style.display = 'none'; + }); + const content = document.getElementById(`repair-tab-${tab}`); + if (content) content.style.display = ''; - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} repair worker`); + if (tab === 'jobs') loadRepairJobs(); + else if (tab === 'findings') loadRepairFindings(); + else if (tab === 'history') loadRepairHistory(); +} + +async function loadRepairJobs() { + const container = document.getElementById('repair-jobs-list'); + if (!container) return; + + try { + const response = await fetch('/api/repair/jobs'); + if (!response.ok) throw new Error('Failed to fetch jobs'); + const data = await response.json(); + const jobs = data.jobs || []; + + if (jobs.length === 0) { + container.innerHTML = '
No jobs available
'; + return; } - // Immediately update UI - await updateRepairStatus(); + // Populate findings job filter dropdown + const jobFilter = document.getElementById('repair-findings-job-filter'); + if (jobFilter && jobFilter.options.length <= 1) { + jobs.forEach(job => { + const opt = document.createElement('option'); + opt.value = job.job_id; + opt.textContent = job.display_name; + jobFilter.appendChild(opt); + }); + } - console.log(`Repair worker ${isRunning ? 'paused' : 'resumed'}`); + container.innerHTML = jobs.map(job => { + const lastRunText = job.last_run ? formatCacheAge(job.last_run.finished_at) : 'Never'; + const nextRunText = job.next_run ? formatCacheAge(job.next_run) : (job.enabled ? 'Pending' : '-'); + const statusClass = job.is_running ? 'running' : (job.enabled ? 'idle' : 'disabled'); + const statusText = job.is_running ? 'Running' : (job.enabled ? 'Idle' : 'Disabled'); + const lastStats = job.last_run ? `Scanned: ${(job.last_run.items_scanned || 0).toLocaleString()}` : ''; + + // Build settings HTML + let settingsHtml = ''; + if (job.settings && Object.keys(job.settings).length > 0) { + const settingsRows = Object.entries(job.settings).map(([key, val]) => { + const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + const inputType = typeof val === 'boolean' ? 'checkbox' : + typeof val === 'number' ? 'number' : 'text'; + const inputVal = inputType === 'checkbox' ? + (val ? ' checked' : '') : + ` value="${val}"`; + return `
+ + +
`; + }).join(''); + + settingsHtml = ` + `; + } + + return `
+
+
+
+ ${job.display_name} + ${statusText} + ${job.auto_fix ? 'Auto-fix' : ''} +
+
${job.description}
+
+ Last: ${lastRunText} · Next: ${nextRunText} + ${lastStats ? ' · ' + lastStats : ''} +
+
+
+ + + ${Object.keys(job.settings || {}).length > 0 ? + `` : ''} +
+
+ ${settingsHtml} +
`; + }).join(''); } catch (error) { - console.error('Error toggling repair worker:', error); - showToast(`Error: ${error.message}`, 'error'); + console.error('Error loading repair jobs:', error); + container.innerHTML = '
Error loading jobs
'; + } +} + +async function toggleRepairJob(jobId, enabled) { + try { + await fetch(`/api/repair/jobs/${jobId}/toggle`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }) + }); + } catch (error) { + console.error('Error toggling job:', error); + showToast('Error toggling job', 'error'); + } +} + +function expandRepairJobSettings(jobId) { + const el = document.getElementById(`repair-settings-${jobId}`); + if (el) el.style.display = el.style.display === 'none' ? '' : 'none'; +} + +async function saveRepairJobSettings(jobId) { + try { + const inputs = document.querySelectorAll(`.repair-setting-input[data-job="${jobId}"]`); + let intervalHours = null; + const settings = {}; + + inputs.forEach(input => { + const key = input.dataset.key; + if (key === '_interval_hours') { + intervalHours = parseInt(input.value) || 24; + } else { + if (input.type === 'checkbox') settings[key] = input.checked; + else if (input.type === 'number') settings[key] = parseFloat(input.value); + else settings[key] = input.value; + } + }); + + await fetch(`/api/repair/jobs/${jobId}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interval_hours: intervalHours, settings }) + }); + + showToast('Settings saved', 'success'); + } catch (error) { + console.error('Error saving job settings:', error); + showToast('Error saving settings', 'error'); + } +} + +async function runRepairJobNow(jobId) { + try { + await fetch(`/api/repair/jobs/${jobId}/run`, { method: 'POST' }); + showToast('Job started', 'success'); + setTimeout(() => loadRepairJobs(), 1000); + } catch (error) { + console.error('Error running job:', error); + showToast('Error starting job', 'error'); + } +} + +async function loadRepairFindings() { + const container = document.getElementById('repair-findings-list'); + if (!container) return; + + const jobFilter = document.getElementById('repair-findings-job-filter'); + const severityFilter = document.getElementById('repair-findings-severity-filter'); + const statusFilter = document.getElementById('repair-findings-status-filter'); + + const params = new URLSearchParams(); + if (jobFilter && jobFilter.value) params.set('job_id', jobFilter.value); + if (severityFilter && severityFilter.value) params.set('severity', severityFilter.value); + if (statusFilter && statusFilter.value) params.set('status', statusFilter.value); + params.set('page', _repairFindingsPage); + params.set('limit', REPAIR_FINDINGS_PAGE_SIZE); + + try { + const response = await fetch(`/api/repair/findings?${params}`); + if (!response.ok) throw new Error('Failed to fetch findings'); + const data = await response.json(); + const items = data.items || []; + + _repairSelectedFindings.clear(); + const bulkBar = document.getElementById('repair-findings-bulk'); + if (bulkBar) bulkBar.style.display = 'none'; + + if (items.length === 0) { + container.innerHTML = '
No findings match your filters
'; + document.getElementById('repair-findings-pagination').innerHTML = ''; + return; + } + + const severityIcons = { info: 'ℹ️', warning: '⚠️', critical: '🔴' }; + + container.innerHTML = items.map(f => { + const icon = severityIcons[f.severity] || 'ℹ️'; + const age = formatCacheAge(f.created_at); + const statusBadge = f.status !== 'pending' ? + `${f.status}` : ''; + + return `
+
+ +
+
+
+ ${icon} + ${f.title} + ${statusBadge} +
+
${f.description || ''}
+
+ ${f.job_id.replace(/_/g, ' ')} · ${f.entity_type || 'file'} · ${age} +
+
+
+ ${f.status === 'pending' ? ` + + + ` : ''} +
+
`; + }).join(''); + + // Pagination + renderRepairFindingsPagination(data.total, data.page); + + } catch (error) { + console.error('Error loading findings:', error); + container.innerHTML = '
Error loading findings
'; + } +} + +function toggleFindingSelect(id, checked) { + if (checked) _repairSelectedFindings.add(id); + else _repairSelectedFindings.delete(id); + + const bulkBar = document.getElementById('repair-findings-bulk'); + if (bulkBar) bulkBar.style.display = _repairSelectedFindings.size > 0 ? '' : 'none'; +} + +function renderRepairFindingsPagination(total, currentPage) { + const container = document.getElementById('repair-findings-pagination'); + if (!container) return; + + const totalPages = Math.ceil(total / REPAIR_FINDINGS_PAGE_SIZE); + if (totalPages <= 1) { container.innerHTML = ''; return; } + + let html = ''; + if (currentPage > 0) { + html += ``; + } + for (let i = 0; i < totalPages && i < 10; i++) { + html += ``; + } + if (currentPage < totalPages - 1) { + html += ``; + } + container.innerHTML = html; +} + +async function resolveRepairFinding(id) { + try { + await fetch(`/api/repair/findings/${id}/resolve`, { method: 'POST' }); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error resolving finding:', error); + } +} + +async function dismissRepairFinding(id) { + try { + await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' }); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error dismissing finding:', error); + } +} + +async function bulkRepairAction(action) { + if (_repairSelectedFindings.size === 0) return; + try { + await fetch('/api/repair/findings/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: Array.from(_repairSelectedFindings), action }) + }); + showToast(`${_repairSelectedFindings.size} findings ${action === 'dismiss' ? 'dismissed' : 'resolved'}`, 'success'); + _repairSelectedFindings.clear(); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error bulk updating findings:', error); + showToast('Error updating findings', 'error'); + } +} + +async function loadRepairHistory() { + const container = document.getElementById('repair-history-list'); + if (!container) return; + + try { + const response = await fetch('/api/repair/history?limit=50'); + if (!response.ok) throw new Error('Failed to fetch history'); + const data = await response.json(); + const runs = data.runs || []; + + if (runs.length === 0) { + container.innerHTML = '
No job history yet
'; + return; + } + + container.innerHTML = runs.map(run => { + const duration = run.duration_seconds ? `${run.duration_seconds.toFixed(1)}s` : '-'; + const age = formatCacheAge(run.started_at); + const statusClass = run.status === 'completed' ? 'success' : + run.status === 'failed' ? 'error' : 'running'; + + return `
+
+ ${run.display_name || run.job_id} + ${run.status} +
+
+ ${age} · ${duration} · + Scanned: ${(run.items_scanned || 0).toLocaleString()} · + Fixed: ${run.auto_fixed || 0} · + Findings: ${run.findings_created || 0} + ${run.errors ? ` · Errors: ${run.errors}` : ''} +
+
`; + }).join(''); + + } catch (error) { + console.error('Error loading repair history:', error); + container.innerHTML = '
Error loading history
'; } } @@ -49314,17 +49712,17 @@ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { const button = document.getElementById('repair-button'); if (button) { - button.addEventListener('click', toggleRepairWorker); + button.addEventListener('click', openRepairModal); updateRepairStatus(); - setInterval(updateRepairStatus, 2000); + setInterval(updateRepairStatus, 5000); } }); } else { const button = document.getElementById('repair-button'); if (button) { - button.addEventListener('click', toggleRepairWorker); + button.addEventListener('click', openRepairModal); updateRepairStatus(); - setInterval(updateRepairStatus, 2000); + setInterval(updateRepairStatus, 5000); } } diff --git a/webui/static/style.css b/webui/static/style.css index 84dc4f39..0e60ed3c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -38900,4 +38900,696 @@ tr.tag-diff-same { .mcache-filters { flex-direction: column; } -} \ No newline at end of file +} + +/* ================================================================ + LIBRARY MAINTENANCE MODAL + ================================================================ */ + +/* Findings badge on header button */ +.repair-badge { + position: absolute; + top: -4px; + right: -4px; + background: #ef4444; + color: #fff; + font-size: 10px; + font-weight: 700; + min-width: 16px; + height: 16px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + line-height: 1; + z-index: 10; +} + +/* Modal overlay */ +.repair-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +.repair-modal { + background: rgba(14, 14, 14, 0.98); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + width: 90vw; + max-width: 900px; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); +} + +.repair-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.repair-modal-title { + font-size: 20px; + font-weight: 600; + color: #fff; + margin: 0; +} + +.repair-modal-header-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.repair-modal-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: all 0.2s ease; +} + +.repair-modal-close:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); +} + +/* Master toggle */ +.repair-master-toggle { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.repair-toggle-label { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); + font-weight: 500; +} + +.repair-toggle-slider { + position: relative; + width: 44px; + height: 24px; + background: rgba(255, 255, 255, 0.12); + border-radius: 12px; + transition: background 0.2s ease; + display: inline-block; +} + +.repair-toggle-slider::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s ease; +} + +.repair-master-toggle input { + display: none; +} + +.repair-master-toggle input:checked + .repair-toggle-slider { + background: var(--accent-color, #6366f1); +} + +.repair-master-toggle input:checked + .repair-toggle-slider::after { + transform: translateX(20px); +} + +.repair-toggle-slider.small { + width: 36px; + height: 20px; + border-radius: 10px; +} + +.repair-toggle-slider.small::after { + top: 2px; + left: 2px; + width: 16px; + height: 16px; +} + +.repair-job-toggle input:checked + .repair-toggle-slider.small::after { + transform: translateX(16px); +} + +.repair-job-toggle input { + display: none; +} + +.repair-job-toggle input:checked + .repair-toggle-slider.small { + background: var(--accent-color, #6366f1); +} + +/* Tabs */ +.repair-tabs { + display: flex; + gap: 0; + padding: 0 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.repair-tab { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + font-weight: 500; + padding: 12px 20px; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 6px; +} + +.repair-tab:hover { + color: rgba(255, 255, 255, 0.8); +} + +.repair-tab.active { + color: var(--accent-color, #6366f1); + border-bottom-color: var(--accent-color, #6366f1); +} + +.repair-tab-badge { + background: #ef4444; + color: #fff; + font-size: 10px; + font-weight: 700; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 5px; +} + +/* Tab content */ +.repair-tab-content { + flex: 1; + overflow-y: auto; + padding: 16px 24px; +} + +.repair-loading, .repair-empty { + text-align: center; + color: rgba(255, 255, 255, 0.4); + padding: 48px 20px; + font-size: 14px; +} + +/* ── Jobs Tab ── */ +.repair-jobs-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.repair-job-card { + background: rgba(22, 22, 22, 0.8); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + overflow: hidden; + transition: border-color 0.2s ease; +} + +.repair-job-card:hover { + border-color: rgba(255, 255, 255, 0.12); +} + +.repair-job-card.running { + border-color: rgba(var(--accent-rgb, 99, 102, 241), 0.3); +} + +.repair-job-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 16px; + gap: 16px; +} + +.repair-job-info { + flex: 1; + min-width: 0; +} + +.repair-job-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + flex-wrap: wrap; +} + +.repair-job-name { + font-size: 14px; + font-weight: 600; + color: #fff; +} + +.repair-job-status-pill { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.repair-job-status-pill.running { + background: rgba(var(--accent-rgb, 99, 102, 241), 0.15); + color: var(--accent-color, #6366f1); +} + +.repair-job-status-pill.idle { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; +} + +.repair-job-status-pill.disabled { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.35); +} + +.repair-job-autofix-pill { + font-size: 10px; + font-weight: 500; + padding: 2px 6px; + border-radius: 8px; + background: rgba(59, 130, 246, 0.12); + color: #60a5fa; +} + +.repair-job-desc { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + margin-bottom: 4px; +} + +.repair-job-meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.3); +} + +.repair-job-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.repair-run-btn, .repair-settings-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.5); + width: 30px; + height: 30px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.2s ease; +} + +.repair-run-btn:hover, .repair-settings-btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; + border-color: rgba(255, 255, 255, 0.15); +} + +/* Job settings panel */ +.repair-job-settings { + padding: 12px 16px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + background: rgba(0, 0, 0, 0.2); +} + +.repair-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; + gap: 12px; +} + +.repair-setting-row label { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.repair-setting-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: #fff; + padding: 5px 10px; + font-size: 12px; + width: 100px; + text-align: right; +} + +.repair-setting-input[type="checkbox"] { + width: auto; + accent-color: var(--accent-color, #6366f1); +} + +.repair-save-settings-btn { + margin-top: 10px; + background: var(--accent-color, #6366f1); + color: #fff; + border: none; + border-radius: 6px; + padding: 6px 16px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.repair-save-settings-btn:hover { + opacity: 0.85; +} + +/* ── Findings Tab ── */ +.repair-findings-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + gap: 12px; + flex-wrap: wrap; +} + +.repair-findings-filters { + display: flex; + gap: 8px; +} + +.repair-findings-filters select { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: #fff; + padding: 6px 10px; + font-size: 12px; + cursor: pointer; +} + +.repair-findings-filters select option { + background: #1a1a1a; +} + +.repair-findings-bulk { + display: flex; + gap: 8px; +} + +.repair-bulk-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: rgba(255, 255, 255, 0.7); + padding: 6px 14px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.repair-bulk-btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.repair-findings-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.repair-finding-card { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + background: rgba(22, 22, 22, 0.8); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + transition: border-color 0.2s ease; +} + +.repair-finding-card:hover { + border-color: rgba(255, 255, 255, 0.12); +} + +.repair-finding-card.warning { + border-left: 3px solid rgba(245, 158, 11, 0.5); +} + +.repair-finding-card.critical { + border-left: 3px solid rgba(239, 68, 68, 0.5); +} + +.repair-finding-card.info { + border-left: 3px solid rgba(59, 130, 246, 0.3); +} + +.repair-finding-select { + padding-top: 2px; + flex-shrink: 0; +} + +.repair-finding-select input[type="checkbox"] { + accent-color: var(--accent-color, #6366f1); +} + +.repair-finding-content { + flex: 1; + min-width: 0; +} + +.repair-finding-title { + font-size: 13px; + font-weight: 500; + color: #fff; + margin-bottom: 3px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.repair-finding-icon { + font-size: 12px; +} + +.repair-finding-status-badge { + font-size: 9px; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + text-transform: uppercase; +} + +.repair-finding-status-badge.resolved { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; +} + +.repair-finding-status-badge.dismissed { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.4); +} + +.repair-finding-desc { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.repair-finding-meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.25); +} + +.repair-finding-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.repair-finding-btn { + background: none; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 5px; + width: 28px; + height: 28px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all 0.2s ease; +} + +.repair-finding-btn.resolve { + color: #22c55e; +} + +.repair-finding-btn.resolve:hover { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.3); +} + +.repair-finding-btn.dismiss { + color: rgba(255, 255, 255, 0.4); +} + +.repair-finding-btn.dismiss:hover { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.3); + color: #ef4444; +} + +/* Pagination */ +.repair-findings-pagination { + display: flex; + justify-content: center; + gap: 4px; + padding: 12px 0 4px; +} + +.repair-page-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.5); + padding: 4px 10px; + border-radius: 5px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; +} + +.repair-page-btn.active { + background: var(--accent-color, #6366f1); + color: #fff; + border-color: var(--accent-color, #6366f1); +} + +.repair-page-btn:hover:not(.active) { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +/* ── History Tab ── */ +.repair-history-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.repair-history-entry { + padding: 10px 14px; + background: rgba(22, 22, 22, 0.8); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; +} + +.repair-history-job { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.repair-history-name { + font-size: 13px; + font-weight: 500; + color: #fff; +} + +.repair-history-status { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 8px; + text-transform: uppercase; +} + +.repair-history-status.success { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; +} + +.repair-history-status.error { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; +} + +.repair-history-status.running { + background: rgba(var(--accent-rgb, 99, 102, 241), 0.12); + color: var(--accent-color, #6366f1); +} + +.repair-history-meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); +} + +/* ── Responsive ── */ +@media (max-width: 640px) { + .repair-modal { + width: 98vw; + max-height: 92vh; + border-radius: 12px; + } + + .repair-modal-header { + padding: 14px 16px; + } + + .repair-tab-content { + padding: 12px 14px; + } + + .repair-findings-filters { + flex-wrap: wrap; + } + + .repair-job-main { + flex-direction: column; + gap: 10px; + } + + .repair-job-actions { + align-self: flex-end; + } +}