Library Repair Worker: multi-job background maintenance daemon with 10 jobs, findings system, and management modal

This commit is contained in:
Broque Thomas 2026-03-14 09:09:18 -07:00
parent 6de3ab7cef
commit 945f86c643
18 changed files with 4812 additions and 1023 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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()

82
core/repair_jobs/base.py Normal file
View file

@ -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}"

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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]]:

View file

@ -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/<job_id>/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/<job_id>/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/<job_id>/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/<int:finding_id>/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/<int:finding_id>/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
# ================================================================================================

View file

@ -506,9 +506,10 @@
</div>
<!-- Library Repair Worker Status Icon -->
<div class="repair-button-container">
<button class="repair-button" id="repair-button" title="Library Repair Worker">
<button class="repair-button" id="repair-button" title="Library Maintenance">
<img src="/static/whisoul.png" alt="Repair" class="repair-logo">
<div class="repair-spinner"></div>
<span class="repair-badge" id="repair-findings-badge" style="display:none">0</span>
</button>
<!-- Repair Worker Tooltip -->
<div class="repair-tooltip" id="repair-tooltip">
@ -5519,6 +5520,73 @@
</div>
</div>
<!-- Library Maintenance Modal -->
<div class="repair-modal-overlay" id="repair-modal" style="display:none;" onclick="if(event.target===this)closeRepairModal()">
<div class="repair-modal">
<div class="repair-modal-header">
<h2 class="repair-modal-title">Library Maintenance</h2>
<div class="repair-modal-header-actions">
<label class="repair-master-toggle">
<input type="checkbox" id="repair-master-toggle" onchange="toggleRepairMaster()">
<span class="repair-toggle-slider"></span>
<span class="repair-toggle-label" id="repair-master-label">Disabled</span>
</label>
<button class="repair-modal-close" onclick="closeRepairModal()">&times;</button>
</div>
</div>
<div class="repair-tabs">
<button class="repair-tab active" data-tab="jobs" onclick="switchRepairTab('jobs')">Jobs</button>
<button class="repair-tab" data-tab="findings" onclick="switchRepairTab('findings')">
Findings <span class="repair-tab-badge" id="repair-findings-tab-badge" style="display:none">0</span>
</button>
<button class="repair-tab" data-tab="history" onclick="switchRepairTab('history')">History</button>
</div>
<div class="repair-tab-content" id="repair-tab-jobs">
<div class="repair-jobs-list" id="repair-jobs-list">
<div class="repair-loading">Loading jobs...</div>
</div>
</div>
<div class="repair-tab-content" id="repair-tab-findings" style="display:none;">
<div class="repair-findings-header">
<div class="repair-findings-filters">
<select id="repair-findings-job-filter" onchange="loadRepairFindings()">
<option value="">All Jobs</option>
</select>
<select id="repair-findings-severity-filter" onchange="loadRepairFindings()">
<option value="">All Severity</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
<select id="repair-findings-status-filter" onchange="loadRepairFindings()">
<option value="pending">Pending</option>
<option value="">All Status</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
<div class="repair-findings-bulk" id="repair-findings-bulk" style="display:none;">
<button class="repair-bulk-btn" onclick="bulkRepairAction('dismiss')">Dismiss Selected</button>
<button class="repair-bulk-btn" onclick="bulkRepairAction('resolve')">Resolve Selected</button>
</div>
</div>
<div class="repair-findings-list" id="repair-findings-list">
<div class="repair-loading">Loading findings...</div>
</div>
<div class="repair-findings-pagination" id="repair-findings-pagination"></div>
</div>
<div class="repair-tab-content" id="repair-tab-history" style="display:none;">
<div class="repair-history-list" id="repair-history-list">
<div class="repair-loading">Loading history...</div>
</div>
</div>
</div>
</div>
<!-- Right Sidebar Download Indicator (Global - outside all containers) -->
<div class="discover-download-sidebar" id="discover-download-sidebar">
<div class="discover-download-sidebar-header">

View file

@ -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 = '<div class="repair-empty">No jobs available</div>';
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 `<div class="repair-setting-row">
<label>${label}</label>
<input type="${inputType}" class="repair-setting-input"
data-job="${job.job_id}" data-key="${key}"${inputVal}
${inputType === 'number' ? 'step="0.01" min="0"' : ''}>
</div>`;
}).join('');
settingsHtml = `
<div class="repair-job-settings" id="repair-settings-${job.job_id}" style="display:none;">
<div class="repair-setting-row">
<label>Interval (hours)</label>
<input type="number" class="repair-setting-input"
data-job="${job.job_id}" data-key="_interval_hours"
value="${job.interval_hours}" min="1" step="1">
</div>
${settingsRows}
<button class="repair-save-settings-btn" onclick="saveRepairJobSettings('${job.job_id}')">Save Settings</button>
</div>`;
}
return `<div class="repair-job-card ${statusClass}">
<div class="repair-job-main">
<div class="repair-job-info">
<div class="repair-job-title">
<span class="repair-job-name">${job.display_name}</span>
<span class="repair-job-status-pill ${statusClass}">${statusText}</span>
${job.auto_fix ? '<span class="repair-job-autofix-pill">Auto-fix</span>' : ''}
</div>
<div class="repair-job-desc">${job.description}</div>
<div class="repair-job-meta">
Last: ${lastRunText} &middot; Next: ${nextRunText}
${lastStats ? ' &middot; ' + lastStats : ''}
</div>
</div>
<div class="repair-job-actions">
<label class="repair-job-toggle">
<input type="checkbox" ${job.enabled ? 'checked' : ''}
onchange="toggleRepairJob('${job.job_id}', this.checked)">
<span class="repair-toggle-slider small"></span>
</label>
<button class="repair-run-btn" onclick="runRepairJobNow('${job.job_id}')"
title="Run now">&#9654;</button>
${Object.keys(job.settings || {}).length > 0 ?
`<button class="repair-settings-btn" onclick="expandRepairJobSettings('${job.job_id}')"
title="Settings">&#9881;</button>` : ''}
</div>
</div>
${settingsHtml}
</div>`;
}).join('');
} catch (error) {
console.error('Error toggling repair worker:', error);
showToast(`Error: ${error.message}`, 'error');
console.error('Error loading repair jobs:', error);
container.innerHTML = '<div class="repair-empty">Error loading jobs</div>';
}
}
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 = '<div class="repair-empty">No findings match your filters</div>';
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' ?
`<span class="repair-finding-status-badge ${f.status}">${f.status}</span>` : '';
return `<div class="repair-finding-card ${f.severity}" data-id="${f.id}">
<div class="repair-finding-select">
<input type="checkbox" onchange="toggleFindingSelect(${f.id}, this.checked)">
</div>
<div class="repair-finding-content">
<div class="repair-finding-title">
<span class="repair-finding-icon">${icon}</span>
${f.title}
${statusBadge}
</div>
<div class="repair-finding-desc">${f.description || ''}</div>
<div class="repair-finding-meta">
${f.job_id.replace(/_/g, ' ')} &middot; ${f.entity_type || 'file'} &middot; ${age}
</div>
</div>
<div class="repair-finding-actions">
${f.status === 'pending' ? `
<button class="repair-finding-btn resolve" onclick="resolveRepairFinding(${f.id})" title="Resolve">&#10003;</button>
<button class="repair-finding-btn dismiss" onclick="dismissRepairFinding(${f.id})" title="Dismiss">&times;</button>
` : ''}
</div>
</div>`;
}).join('');
// Pagination
renderRepairFindingsPagination(data.total, data.page);
} catch (error) {
console.error('Error loading findings:', error);
container.innerHTML = '<div class="repair-empty">Error loading findings</div>';
}
}
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 += `<button class="repair-page-btn" onclick="_repairFindingsPage=${currentPage - 1};loadRepairFindings()">&larr;</button>`;
}
for (let i = 0; i < totalPages && i < 10; i++) {
html += `<button class="repair-page-btn ${i === currentPage ? 'active' : ''}"
onclick="_repairFindingsPage=${i};loadRepairFindings()">${i + 1}</button>`;
}
if (currentPage < totalPages - 1) {
html += `<button class="repair-page-btn" onclick="_repairFindingsPage=${currentPage + 1};loadRepairFindings()">&rarr;</button>`;
}
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 = '<div class="repair-empty">No job history yet</div>';
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 `<div class="repair-history-entry">
<div class="repair-history-job">
<span class="repair-history-name">${run.display_name || run.job_id}</span>
<span class="repair-history-status ${statusClass}">${run.status}</span>
</div>
<div class="repair-history-meta">
${age} &middot; ${duration} &middot;
Scanned: ${(run.items_scanned || 0).toLocaleString()} &middot;
Fixed: ${run.auto_fixed || 0} &middot;
Findings: ${run.findings_created || 0}
${run.errors ? ` &middot; Errors: ${run.errors}` : ''}
</div>
</div>`;
}).join('');
} catch (error) {
console.error('Error loading repair history:', error);
container.innerHTML = '<div class="repair-empty">Error loading history</div>';
}
}
@ -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);
}
}

View file

@ -38900,4 +38900,696 @@ tr.tag-diff-same {
.mcache-filters {
flex-direction: column;
}
}
}
/* ================================================================
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;
}
}