Library Repair Worker: multi-job background maintenance daemon with 10 jobs, findings system, and management modal
This commit is contained in:
parent
6de3ab7cef
commit
945f86c643
18 changed files with 4812 additions and 1023 deletions
57
core/repair_jobs/__init__.py
Normal file
57
core/repair_jobs/__init__.py
Normal 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)
|
||||
290
core/repair_jobs/acoustid_scanner.py
Normal file
290
core/repair_jobs/acoustid_scanner.py
Normal 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()
|
||||
176
core/repair_jobs/album_completeness.py
Normal file
176
core/repair_jobs/album_completeness.py
Normal 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
82
core/repair_jobs/base.py
Normal 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}"
|
||||
38
core/repair_jobs/cache_evictor.py
Normal file
38
core/repair_jobs/cache_evictor.py
Normal 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
|
||||
107
core/repair_jobs/dead_file_cleaner.py
Normal file
107
core/repair_jobs/dead_file_cleaner.py
Normal 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()
|
||||
192
core/repair_jobs/duplicate_detector.py
Normal file
192
core/repair_jobs/duplicate_detector.py
Normal 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()
|
||||
260
core/repair_jobs/fake_lossless_detector.py
Normal file
260
core/repair_jobs/fake_lossless_detector.py
Normal 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
|
||||
197
core/repair_jobs/metadata_gap_filler.py
Normal file
197
core/repair_jobs/metadata_gap_filler.py
Normal 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()
|
||||
171
core/repair_jobs/missing_cover_art.py
Normal file
171
core/repair_jobs/missing_cover_art.py
Normal 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()
|
||||
122
core/repair_jobs/orphan_file_detector.py
Normal file
122
core/repair_jobs/orphan_file_detector.py
Normal 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
|
||||
881
core/repair_jobs/track_number_repair.py
Normal file
881
core/repair_jobs/track_number_repair.py
Normal 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
|
|
@ -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]]:
|
||||
|
|
|
|||
199
web_server.py
199
web_server.py
|
|
@ -36697,6 +36697,7 @@ try:
|
|||
repair_db = MusicDatabase()
|
||||
transfer_path = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||
repair_worker = RepairWorker(database=repair_db, transfer_folder=transfer_path)
|
||||
repair_worker.set_config_manager(config_manager)
|
||||
repair_worker.start()
|
||||
print("✅ Repair worker initialized and started")
|
||||
except Exception as e:
|
||||
|
|
@ -36707,14 +36708,17 @@ except Exception as e:
|
|||
|
||||
@app.route('/api/repair/status', methods=['GET'])
|
||||
def repair_status():
|
||||
"""Get repair worker status for UI polling"""
|
||||
"""Get repair worker status"""
|
||||
try:
|
||||
if repair_worker is None:
|
||||
return jsonify({
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'paused': True,
|
||||
'idle': False,
|
||||
'current_item': None,
|
||||
'current_job': None,
|
||||
'findings_pending': 0,
|
||||
'stats': {'scanned': 0, 'repaired': 0, 'skipped': 0, 'errors': 0, 'pending': 0},
|
||||
'progress': {}
|
||||
}), 200
|
||||
|
|
@ -36725,32 +36729,203 @@ def repair_status():
|
|||
logger.error(f"Error getting repair status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/repair/pause', methods=['POST'])
|
||||
def repair_pause():
|
||||
"""Pause repair worker"""
|
||||
@app.route('/api/repair/toggle', methods=['POST'])
|
||||
def repair_toggle():
|
||||
"""Toggle master enable/disable"""
|
||||
try:
|
||||
if repair_worker is None:
|
||||
return jsonify({'error': 'Repair worker not initialized'}), 400
|
||||
|
||||
new_state = repair_worker.toggle()
|
||||
logger.info("Repair worker %s via UI", "enabled" if new_state else "disabled")
|
||||
return jsonify({'enabled': new_state}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error toggling repair worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# Backward compat aliases
|
||||
@app.route('/api/repair/pause', methods=['POST'])
|
||||
def repair_pause():
|
||||
try:
|
||||
if repair_worker is None:
|
||||
return jsonify({'error': 'Repair worker not initialized'}), 400
|
||||
repair_worker.pause()
|
||||
logger.info("Repair worker paused via UI")
|
||||
return jsonify({'status': 'paused'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing repair worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/repair/resume', methods=['POST'])
|
||||
def repair_resume():
|
||||
"""Resume repair worker"""
|
||||
try:
|
||||
if repair_worker is None:
|
||||
return jsonify({'error': 'Repair worker not initialized'}), 400
|
||||
repair_worker.resume()
|
||||
return jsonify({'status': 'running'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/repair/jobs', methods=['GET'])
|
||||
def repair_jobs_list():
|
||||
"""Get all jobs with config and last run info"""
|
||||
try:
|
||||
if repair_worker is None:
|
||||
return jsonify({'jobs': []}), 200
|
||||
jobs = repair_worker.get_all_job_info()
|
||||
return jsonify({'jobs': jobs}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting repair jobs: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/repair/jobs/<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
|
||||
|
||||
# ================================================================================================
|
||||
|
|
|
|||
|
|
@ -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()">×</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">
|
||||
|
|
|
|||
|
|
@ -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} · Next: ${nextRunText}
|
||||
${lastStats ? ' · ' + 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">▶</button>
|
||||
${Object.keys(job.settings || {}).length > 0 ?
|
||||
`<button class="repair-settings-btn" onclick="expandRepairJobSettings('${job.job_id}')"
|
||||
title="Settings">⚙</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, ' ')} · ${f.entity_type || 'file'} · ${age}
|
||||
</div>
|
||||
</div>
|
||||
<div class="repair-finding-actions">
|
||||
${f.status === 'pending' ? `
|
||||
<button class="repair-finding-btn resolve" onclick="resolveRepairFinding(${f.id})" title="Resolve">✓</button>
|
||||
<button class="repair-finding-btn dismiss" onclick="dismissRepairFinding(${f.id})" title="Dismiss">×</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()">←</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()">→</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} · ${duration} ·
|
||||
Scanned: ${(run.items_scanned || 0).toLocaleString()} ·
|
||||
Fixed: ${run.auto_fixed || 0} ·
|
||||
Findings: ${run.findings_created || 0}
|
||||
${run.errors ? ` · 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue