Add minimum 60% title similarity gate to match_recording() — prevents artist bonus + MB score from pushing unrelated titles past the confidence threshold (e.g. "Sweet Surrender" matching "Answers" by same artist). New MBID Mismatch Detector repair job reads embedded MusicBrainz recording IDs from audio files, verifies them against the MusicBrainz API, and flags tracks where the MBID points to a different song. Fix action strips the bad MBID tag so media servers like Navidrome fall back to correct file tags.
1353 lines
52 KiB
Python
1353 lines
52 KiB
Python
"""Library Maintenance Worker — multi-job background daemon.
|
|
|
|
Rotates through registered repair jobs (track number repair, AcoustID scanner,
|
|
duplicate detection, etc.) based on staleness-priority scheduling. Each job
|
|
is independently configurable and can be enabled/disabled by the user.
|
|
|
|
The worker is deactivated by default — the user must explicitly enable it.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from core.repair_jobs import get_all_jobs
|
|
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("repair_worker")
|
|
|
|
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
|
|
|
|
|
|
def _resolve_file_path(file_path, transfer_folder, download_folder=None):
|
|
"""Resolve a stored DB path to an actual file on disk.
|
|
|
|
Tries the raw path first, then progressively shorter suffixes against
|
|
configured directories. Handles cross-environment path mismatches
|
|
(e.g. Docker paths vs native Windows paths).
|
|
"""
|
|
if not file_path:
|
|
return None
|
|
if os.path.exists(file_path):
|
|
return file_path
|
|
|
|
path_parts = file_path.replace('\\', '/').split('/')
|
|
|
|
for base_dir in [transfer_folder, download_folder]:
|
|
if not base_dir or not os.path.isdir(base_dir):
|
|
continue
|
|
for i in range(1, len(path_parts)):
|
|
candidate = os.path.join(base_dir, *path_parts[i:])
|
|
if os.path.exists(candidate):
|
|
return candidate
|
|
|
|
return None
|
|
|
|
|
|
class RepairWorker:
|
|
"""Multi-job background maintenance worker.
|
|
|
|
Rotates through enabled repair jobs using staleness-priority scheduling.
|
|
Deactivated by default — user must enable via the management modal.
|
|
"""
|
|
|
|
def __init__(self, database, transfer_folder: str = None):
|
|
self.db = database
|
|
self.transfer_folder = transfer_folder or './Transfer'
|
|
|
|
# Worker state
|
|
self.running = False
|
|
self.enabled = False # Master toggle (replaces 'paused')
|
|
self.should_stop = False
|
|
self.thread = None
|
|
|
|
# Current job being executed
|
|
self._current_job_id = None
|
|
self._current_job_name = None
|
|
self._current_progress = {'scanned': 0, 'total': 0, 'percent': 0}
|
|
|
|
# Aggregate stats for the current scan cycle
|
|
self.stats = {
|
|
'scanned': 0,
|
|
'repaired': 0,
|
|
'skipped': 0,
|
|
'errors': 0,
|
|
'pending': 0,
|
|
}
|
|
|
|
# Job instances (instantiated once)
|
|
self._jobs: Dict[str, RepairJob] = {}
|
|
|
|
# Per-batch folder queues (for post-download scanning)
|
|
self._batch_folders: Dict[str, set] = {}
|
|
self._batch_folders_lock = threading.Lock()
|
|
|
|
# Forced job queue (for "Run Now" button — processed by main loop)
|
|
self._force_run_queue: List[str] = []
|
|
self._force_run_lock = threading.Lock()
|
|
|
|
# Config manager (set externally after init)
|
|
self._config_manager = None
|
|
|
|
# Rich progress callbacks (set by web_server.py)
|
|
self._on_job_start = None # (job_id, display_name) -> None
|
|
self._on_job_progress = None # (job_id, **kwargs) -> None
|
|
self._on_job_finish = None # (job_id, status, result) -> None
|
|
|
|
# Lazy client accessors
|
|
self._spotify_client = None
|
|
self._itunes_client = None
|
|
self._mb_client = None
|
|
self._acoustid_client = None
|
|
self._metadata_cache = None
|
|
|
|
logger.info("Repair worker initialized (transfer_folder=%s)", self.transfer_folder)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Config manager
|
|
# ------------------------------------------------------------------
|
|
def register_progress_callbacks(self, on_start, on_progress, on_finish):
|
|
"""Register callbacks for rich per-job progress reporting.
|
|
|
|
Args:
|
|
on_start: (job_id, display_name) called when a job begins
|
|
on_progress: (job_id, **kwargs) called for incremental updates
|
|
on_finish: (job_id, status, result) called when a job ends
|
|
"""
|
|
self._on_job_start = on_start
|
|
self._on_job_progress = on_progress
|
|
self._on_job_finish = on_finish
|
|
|
|
def set_config_manager(self, config_manager):
|
|
"""Set the config manager for persisting job settings."""
|
|
self._config_manager = config_manager
|
|
# Load master enabled state
|
|
if config_manager:
|
|
self.enabled = config_manager.get('repair.master_enabled', True)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lazy client accessors
|
|
# ------------------------------------------------------------------
|
|
@property
|
|
def spotify_client(self):
|
|
if self._spotify_client is None:
|
|
try:
|
|
from core.spotify_client import SpotifyClient
|
|
self._spotify_client = SpotifyClient()
|
|
except Exception as e:
|
|
logger.error("Failed to initialize SpotifyClient: %s", e)
|
|
return self._spotify_client
|
|
|
|
@property
|
|
def itunes_client(self):
|
|
if self._itunes_client is None:
|
|
try:
|
|
from core.itunes_client import iTunesClient
|
|
self._itunes_client = iTunesClient()
|
|
except Exception as e:
|
|
logger.error("Failed to initialize iTunesClient: %s", e)
|
|
return self._itunes_client
|
|
|
|
@property
|
|
def mb_client(self):
|
|
if self._mb_client is None:
|
|
try:
|
|
from core.musicbrainz_client import MusicBrainzClient
|
|
self._mb_client = MusicBrainzClient()
|
|
except Exception as e:
|
|
logger.error("Failed to initialize MusicBrainzClient: %s", e)
|
|
return self._mb_client
|
|
|
|
@property
|
|
def acoustid_client(self):
|
|
if self._acoustid_client is None:
|
|
try:
|
|
from core.acoustid_client import AcoustIDClient
|
|
self._acoustid_client = AcoustIDClient()
|
|
except Exception as e:
|
|
logger.error("Failed to initialize AcoustIDClient: %s", e)
|
|
return self._acoustid_client
|
|
|
|
@property
|
|
def metadata_cache(self):
|
|
if self._metadata_cache is None:
|
|
try:
|
|
from core.metadata_cache import get_metadata_cache
|
|
self._metadata_cache = get_metadata_cache()
|
|
except Exception as e:
|
|
logger.error("Failed to get metadata cache: %s", e)
|
|
return self._metadata_cache
|
|
|
|
# ------------------------------------------------------------------
|
|
# Job registry
|
|
# ------------------------------------------------------------------
|
|
def _ensure_jobs_loaded(self):
|
|
"""Load job instances from the registry."""
|
|
if self._jobs:
|
|
return
|
|
registry = get_all_jobs()
|
|
for job_id, job_cls in registry.items():
|
|
try:
|
|
self._jobs[job_id] = job_cls()
|
|
except Exception as e:
|
|
logger.error("Failed to instantiate job %s: %s", job_id, e)
|
|
|
|
def get_job_config(self, job_id: str) -> dict:
|
|
"""Get the full config for a specific job."""
|
|
self._ensure_jobs_loaded()
|
|
job = self._jobs.get(job_id)
|
|
if not job:
|
|
return {}
|
|
|
|
defaults = {
|
|
'enabled': job.default_enabled,
|
|
'interval_hours': job.default_interval_hours,
|
|
'settings': job.default_settings.copy(),
|
|
}
|
|
|
|
if self._config_manager:
|
|
cfg = self._config_manager.get(f'repair.jobs.{job_id}', {})
|
|
if isinstance(cfg, dict):
|
|
defaults['enabled'] = cfg.get('enabled', defaults['enabled'])
|
|
defaults['interval_hours'] = cfg.get('interval_hours', defaults['interval_hours'])
|
|
if 'settings' in cfg and isinstance(cfg['settings'], dict):
|
|
defaults['settings'].update(cfg['settings'])
|
|
|
|
return defaults
|
|
|
|
def set_job_enabled(self, job_id: str, enabled: bool):
|
|
"""Enable or disable a specific job."""
|
|
if self._config_manager:
|
|
self._config_manager.set(f'repair.jobs.{job_id}.enabled', enabled)
|
|
|
|
def set_job_settings(self, job_id: str, interval_hours: int = None, settings: dict = None):
|
|
"""Update job interval and/or settings."""
|
|
if not self._config_manager:
|
|
return
|
|
if interval_hours is not None:
|
|
self._config_manager.set(f'repair.jobs.{job_id}.interval_hours', interval_hours)
|
|
if settings is not None:
|
|
current = self._config_manager.get(f'repair.jobs.{job_id}.settings', {})
|
|
if isinstance(current, dict):
|
|
current.update(settings)
|
|
else:
|
|
current = settings
|
|
self._config_manager.set(f'repair.jobs.{job_id}.settings', current)
|
|
|
|
def get_all_job_info(self) -> List[dict]:
|
|
"""Get info for all jobs (for API response)."""
|
|
self._ensure_jobs_loaded()
|
|
jobs_info = []
|
|
for job_id, job in self._jobs.items():
|
|
config = self.get_job_config(job_id)
|
|
last_run = self._get_last_run(job_id)
|
|
next_run = None
|
|
if last_run and config['enabled']:
|
|
last_dt = datetime.fromisoformat(last_run['finished_at']) if last_run.get('finished_at') else None
|
|
if last_dt:
|
|
next_dt = last_dt + timedelta(hours=config['interval_hours'])
|
|
next_run = next_dt.isoformat()
|
|
|
|
jobs_info.append({
|
|
'job_id': job_id,
|
|
'display_name': job.display_name,
|
|
'description': job.description,
|
|
'help_text': job.help_text,
|
|
'icon': job.icon,
|
|
'auto_fix': job.auto_fix,
|
|
'enabled': config['enabled'],
|
|
'interval_hours': config['interval_hours'],
|
|
'settings': config['settings'],
|
|
'default_settings': job.default_settings.copy(),
|
|
'last_run': last_run,
|
|
'next_run': next_run,
|
|
'is_running': self._current_job_id == job_id,
|
|
})
|
|
return jobs_info
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
def start(self):
|
|
if self.running:
|
|
logger.warning("Repair worker already running")
|
|
return
|
|
self.running = True
|
|
self.should_stop = False
|
|
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
self.thread.start()
|
|
logger.info("Repair worker started")
|
|
|
|
def stop(self):
|
|
if not self.running:
|
|
return
|
|
logger.info("Stopping repair worker...")
|
|
self.should_stop = True
|
|
self.running = False
|
|
if self.thread:
|
|
self.thread.join(timeout=5)
|
|
logger.info("Repair worker stopped")
|
|
|
|
def toggle(self) -> bool:
|
|
"""Toggle master enabled state. Returns new state."""
|
|
self.enabled = not self.enabled
|
|
if self._config_manager:
|
|
self._config_manager.set('repair.master_enabled', self.enabled)
|
|
logger.info("Repair worker %s", "enabled" if self.enabled else "disabled")
|
|
return self.enabled
|
|
|
|
def set_enabled(self, enabled: bool):
|
|
"""Set master enabled state."""
|
|
self.enabled = enabled
|
|
if self._config_manager:
|
|
self._config_manager.set('repair.master_enabled', enabled)
|
|
|
|
# Backward compatibility
|
|
def pause(self):
|
|
self.set_enabled(False)
|
|
|
|
def resume(self):
|
|
self.set_enabled(True)
|
|
|
|
@property
|
|
def paused(self):
|
|
return not self.enabled
|
|
|
|
@paused.setter
|
|
def paused(self, value):
|
|
self.enabled = not value
|
|
|
|
# ------------------------------------------------------------------
|
|
# Current item (backward compat for WebSocket tooltip)
|
|
# ------------------------------------------------------------------
|
|
@property
|
|
def current_item(self):
|
|
if self._current_job_id:
|
|
return {
|
|
'type': 'job',
|
|
'name': self._current_job_name or self._current_job_id,
|
|
'job_id': self._current_job_id,
|
|
}
|
|
return None
|
|
|
|
@current_item.setter
|
|
def current_item(self, value):
|
|
# Backward compat — ignore direct sets
|
|
pass
|
|
|
|
# ------------------------------------------------------------------
|
|
# Stats
|
|
# ------------------------------------------------------------------
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
|
|
is_idle = (
|
|
is_actually_running
|
|
and self.enabled
|
|
and self._current_job_id is None
|
|
)
|
|
|
|
# Get pending findings count
|
|
findings_pending = self._get_findings_count('pending')
|
|
|
|
result = {
|
|
'enabled': self.enabled,
|
|
'running': is_actually_running and self.enabled,
|
|
'paused': not self.enabled, # backward compat
|
|
'idle': is_idle,
|
|
'current_item': self.current_item,
|
|
'current_job': None,
|
|
'findings_pending': findings_pending,
|
|
'stats': self.stats.copy(),
|
|
'progress': self._get_progress(),
|
|
}
|
|
|
|
if self._current_job_id:
|
|
job_progress = self._current_progress.copy()
|
|
result['current_job'] = {
|
|
'job_id': self._current_job_id,
|
|
'display_name': self._current_job_name,
|
|
'progress': job_progress,
|
|
}
|
|
# Include per-job progress in the overall progress for tooltip display
|
|
if job_progress.get('total', 0) > 0:
|
|
result['progress']['current_job'] = {
|
|
'scanned': job_progress.get('scanned', 0),
|
|
'total': job_progress.get('total', 0),
|
|
'percent': job_progress.get('percent', 0),
|
|
}
|
|
|
|
return result
|
|
|
|
def _get_progress(self) -> Dict[str, Any]:
|
|
total = self.stats['scanned'] + self.stats['pending']
|
|
percent = round(self.stats['scanned'] / total * 100) if total > 0 else 0
|
|
return {
|
|
'tracks': {
|
|
'total': total,
|
|
'checked': self.stats['scanned'],
|
|
'repaired': self.stats['repaired'],
|
|
'ok': self.stats['scanned'] - self.stats['repaired'] - self.stats['skipped'] - self.stats['errors'],
|
|
'skipped': self.stats['skipped'],
|
|
'percent': percent,
|
|
}
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Main loop
|
|
# ------------------------------------------------------------------
|
|
def _run(self):
|
|
logger.info("Repair worker thread started")
|
|
self._ensure_jobs_loaded()
|
|
|
|
while not self.should_stop:
|
|
try:
|
|
# Check force-run queue even when disabled (user explicitly requested)
|
|
forced_job = None
|
|
with self._force_run_lock:
|
|
if self._force_run_queue:
|
|
forced_job = self._force_run_queue.pop(0)
|
|
|
|
if forced_job:
|
|
self._run_job(forced_job)
|
|
time.sleep(2)
|
|
continue
|
|
|
|
if not self.enabled:
|
|
self._current_job_id = None
|
|
self._current_job_name = None
|
|
time.sleep(2)
|
|
continue
|
|
|
|
# Find the next job to run based on staleness
|
|
next_job = self._pick_next_job()
|
|
|
|
if not next_job:
|
|
# Nothing due — sleep and re-check
|
|
self._current_job_id = None
|
|
self._current_job_name = None
|
|
time.sleep(10)
|
|
continue
|
|
|
|
# Run the selected job
|
|
self._run_job(next_job)
|
|
|
|
# Brief pause between jobs
|
|
time.sleep(5)
|
|
|
|
except Exception as e:
|
|
logger.error("Error in repair worker loop: %s", e, exc_info=True)
|
|
self._current_job_id = None
|
|
self._current_job_name = None
|
|
time.sleep(30)
|
|
|
|
logger.info("Repair worker thread finished")
|
|
|
|
def _pick_next_job(self) -> Optional[str]:
|
|
"""Pick the next job to run based on staleness priority.
|
|
|
|
Returns job_id of the stalest job whose interval has elapsed,
|
|
or None if nothing is due.
|
|
"""
|
|
now = datetime.now()
|
|
best_job_id = None
|
|
best_staleness = -1
|
|
|
|
for job_id, job in self._jobs.items():
|
|
config = self.get_job_config(job_id)
|
|
if not config['enabled']:
|
|
continue
|
|
|
|
interval_hours = config['interval_hours']
|
|
last_run = self._get_last_run(job_id)
|
|
|
|
if not last_run or not last_run.get('finished_at'):
|
|
# Never run — highest staleness
|
|
best_job_id = job_id
|
|
best_staleness = float('inf')
|
|
continue
|
|
|
|
try:
|
|
last_finished = datetime.fromisoformat(last_run['finished_at'])
|
|
elapsed_hours = (now - last_finished).total_seconds() / 3600
|
|
|
|
if elapsed_hours < interval_hours:
|
|
continue # Not due yet
|
|
|
|
staleness = elapsed_hours / interval_hours
|
|
if staleness > best_staleness:
|
|
best_staleness = staleness
|
|
best_job_id = job_id
|
|
except (ValueError, TypeError):
|
|
# Malformed timestamp — treat as never run
|
|
best_job_id = job_id
|
|
best_staleness = float('inf')
|
|
|
|
return best_job_id
|
|
|
|
def _run_job(self, job_id: str):
|
|
"""Execute a single job and record the run."""
|
|
job = self._jobs.get(job_id)
|
|
if not job:
|
|
return
|
|
|
|
logger.info("Starting job: %s (%s)", job.display_name, job_id)
|
|
|
|
self._current_job_id = job_id
|
|
self._current_job_name = job.display_name
|
|
self._current_progress = {'scanned': 0, 'total': 0, 'percent': 0}
|
|
|
|
# Re-read transfer path
|
|
raw = self._get_transfer_path_from_db()
|
|
self.transfer_folder = self._resolve_path(raw)
|
|
|
|
# Notify rich progress system
|
|
if self._on_job_start:
|
|
try:
|
|
self._on_job_start(job_id, job.display_name)
|
|
except Exception:
|
|
pass
|
|
|
|
# Record job start
|
|
run_id = self._record_job_start(job_id)
|
|
|
|
# Build report_progress callback for this job
|
|
def _report_progress(**kwargs):
|
|
if self._on_job_progress:
|
|
try:
|
|
self._on_job_progress(job_id, **kwargs)
|
|
except Exception:
|
|
pass
|
|
|
|
# Build context
|
|
context = JobContext(
|
|
db=self.db,
|
|
transfer_folder=self.transfer_folder,
|
|
config_manager=self._config_manager,
|
|
spotify_client=self.spotify_client,
|
|
itunes_client=self.itunes_client,
|
|
mb_client=self.mb_client,
|
|
acoustid_client=self.acoustid_client,
|
|
metadata_cache=self.metadata_cache,
|
|
create_finding=self._create_finding,
|
|
should_stop=lambda: self.should_stop,
|
|
is_paused=lambda: not self.enabled,
|
|
update_progress=self._update_progress,
|
|
report_progress=_report_progress,
|
|
)
|
|
|
|
start_time = time.time()
|
|
result = JobResult()
|
|
|
|
try:
|
|
result = job.scan(context)
|
|
except Exception as e:
|
|
logger.error("Job %s failed: %s", job_id, e, exc_info=True)
|
|
result.errors += 1
|
|
|
|
duration = time.time() - start_time
|
|
|
|
# Update aggregate stats
|
|
self.stats['scanned'] += result.scanned
|
|
self.stats['repaired'] += result.auto_fixed
|
|
self.stats['skipped'] += result.skipped
|
|
self.stats['errors'] += result.errors
|
|
|
|
# Record job completion
|
|
self._record_job_finish(run_id, job_id, result, duration)
|
|
|
|
# Notify rich progress system of completion
|
|
if self._on_job_finish:
|
|
try:
|
|
status = 'error' if result.errors > 0 and result.auto_fixed == 0 else 'finished'
|
|
self._on_job_finish(job_id, status, result)
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info(
|
|
"Job %s complete: scanned=%d fixed=%d findings=%d errors=%d (%.1fs)",
|
|
job_id, result.scanned, result.auto_fixed,
|
|
result.findings_created, result.errors, duration
|
|
)
|
|
|
|
self._current_job_id = None
|
|
self._current_job_name = None
|
|
self._current_progress = {'scanned': 0, 'total': 0, 'percent': 0}
|
|
|
|
def run_job_now(self, job_id: str):
|
|
"""Queue a job for immediate execution by the main worker loop.
|
|
|
|
Uses a thread-safe queue instead of spawning a separate thread
|
|
to avoid race conditions with the main loop's _run_job().
|
|
"""
|
|
self._ensure_jobs_loaded()
|
|
if job_id not in self._jobs:
|
|
logger.warning("Unknown job: %s", job_id)
|
|
return
|
|
|
|
with self._force_run_lock:
|
|
if job_id not in self._force_run_queue:
|
|
self._force_run_queue.append(job_id)
|
|
logger.info("Job %s queued for immediate run", job_id)
|
|
|
|
def _update_progress(self, scanned: int, total: int):
|
|
"""Callback for jobs to report progress."""
|
|
percent = round(scanned / total * 100) if total > 0 else 0
|
|
self._current_progress = {
|
|
'scanned': scanned,
|
|
'total': total,
|
|
'percent': percent,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Findings
|
|
# ------------------------------------------------------------------
|
|
def _create_finding(self, job_id: str, finding_type: str, severity: str,
|
|
entity_type: str, entity_id: str, file_path: str,
|
|
title: str, description: str, details: dict = None):
|
|
"""Create a repair finding in the database."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Dedup check: skip if same finding already pending
|
|
cursor.execute("""
|
|
SELECT id FROM repair_findings
|
|
WHERE job_id = ? AND finding_type = ? AND status = 'pending'
|
|
AND ((entity_type = ? AND entity_id = ?) OR (file_path = ? AND file_path IS NOT NULL))
|
|
LIMIT 1
|
|
""", (job_id, finding_type, entity_type, entity_id, file_path))
|
|
|
|
if cursor.fetchone():
|
|
return # Already exists
|
|
|
|
cursor.execute("""
|
|
INSERT INTO repair_findings
|
|
(job_id, finding_type, severity, status, entity_type, entity_id,
|
|
file_path, title, description, details_json)
|
|
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
job_id, finding_type, severity, entity_type, entity_id,
|
|
file_path, title, description,
|
|
json.dumps(details) if details else '{}'
|
|
))
|
|
conn.commit()
|
|
except Exception as e:
|
|
logger.debug("Error creating finding: %s", e)
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def get_findings(self, job_id: str = None, status: str = None,
|
|
severity: str = None, page: int = 0, limit: int = 50) -> dict:
|
|
"""Get paginated findings with optional filters."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
where_parts = []
|
|
params = []
|
|
|
|
if job_id:
|
|
where_parts.append("job_id = ?")
|
|
params.append(job_id)
|
|
if status:
|
|
where_parts.append("status = ?")
|
|
params.append(status)
|
|
if severity:
|
|
where_parts.append("severity = ?")
|
|
params.append(severity)
|
|
|
|
where = f"WHERE {' AND '.join(where_parts)}" if where_parts else ""
|
|
|
|
# Count total
|
|
cursor.execute(f"SELECT COUNT(*) FROM repair_findings {where}", params)
|
|
total = cursor.fetchone()[0]
|
|
|
|
# Fetch page
|
|
offset = page * limit
|
|
cursor.execute(f"""
|
|
SELECT id, job_id, finding_type, severity, status, entity_type,
|
|
entity_id, file_path, title, description, details_json,
|
|
user_action, resolved_at, created_at, updated_at
|
|
FROM repair_findings
|
|
{where}
|
|
ORDER BY created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
""", params + [limit, offset])
|
|
|
|
items = []
|
|
for row in cursor.fetchall():
|
|
items.append({
|
|
'id': row[0],
|
|
'job_id': row[1],
|
|
'finding_type': row[2],
|
|
'severity': row[3],
|
|
'status': row[4],
|
|
'entity_type': row[5],
|
|
'entity_id': row[6],
|
|
'file_path': row[7],
|
|
'title': row[8],
|
|
'description': row[9],
|
|
'details': json.loads(row[10]) if row[10] else {},
|
|
'user_action': row[11],
|
|
'resolved_at': row[12],
|
|
'created_at': row[13],
|
|
'updated_at': row[14],
|
|
})
|
|
|
|
return {'items': items, 'total': total, 'page': page, 'limit': limit}
|
|
|
|
except Exception as e:
|
|
logger.error("Error fetching findings: %s", e, exc_info=True)
|
|
return {'items': [], 'total': 0, 'page': page, 'limit': limit}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def resolve_finding(self, finding_id: int, action: str = None) -> bool:
|
|
"""Resolve a finding with an optional action."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
UPDATE repair_findings
|
|
SET status = 'resolved', user_action = ?, resolved_at = CURRENT_TIMESTAMP,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""", (action, finding_id))
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
except Exception as e:
|
|
logger.error("Error resolving finding %s: %s", finding_id, e)
|
|
return False
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def fix_finding(self, finding_id: int) -> dict:
|
|
"""Execute the appropriate fix action for a finding, then mark it resolved."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT id, job_id, finding_type, entity_type, entity_id,
|
|
file_path, details_json
|
|
FROM repair_findings WHERE id = ? AND status = 'pending'
|
|
""", (finding_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return {'success': False, 'error': 'Finding not found or already resolved'}
|
|
|
|
fid, job_id, finding_type, entity_type, entity_id, file_path, details_json = row
|
|
details = json.loads(details_json) if details_json else {}
|
|
conn.close()
|
|
conn = None
|
|
|
|
# Dispatch fix by finding type
|
|
result = self._execute_fix(finding_type, entity_type, entity_id, file_path, details)
|
|
|
|
if result.get('success'):
|
|
self.resolve_finding(finding_id, action=result.get('action', 'auto_fix'))
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error("Error fixing finding %s: %s", finding_id, e, exc_info=True)
|
|
return {'success': False, 'error': str(e)}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _execute_fix(self, finding_type: str, entity_type: str, entity_id: str,
|
|
file_path: str, details: dict) -> dict:
|
|
"""Route a fix to the correct handler based on finding_type."""
|
|
handlers = {
|
|
'dead_file': self._fix_dead_file,
|
|
'orphan_file': self._fix_orphan_file,
|
|
'track_number_mismatch': self._fix_track_number,
|
|
'missing_cover_art': self._fix_missing_cover_art,
|
|
'metadata_gap': self._fix_metadata_gap,
|
|
'duplicate_tracks': self._fix_duplicates,
|
|
'mbid_mismatch': self._fix_mbid_mismatch,
|
|
}
|
|
handler = handlers.get(finding_type)
|
|
if not handler:
|
|
return {'success': False, 'error': f'No fix available for finding type: {finding_type}'}
|
|
return handler(entity_type, entity_id, file_path, details)
|
|
|
|
def _fix_dead_file(self, entity_type, entity_id, file_path, details):
|
|
"""Remove the dead track entry from the database."""
|
|
if not entity_id:
|
|
return {'success': False, 'error': 'No track ID associated with this finding'}
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
|
|
conn.commit()
|
|
if cursor.rowcount > 0:
|
|
return {'success': True, 'action': 'removed_db_entry',
|
|
'message': 'Removed dead track entry from database'}
|
|
return {'success': False, 'error': 'Track not found in database'}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _fix_orphan_file(self, entity_type, entity_id, file_path, details):
|
|
"""Delete the orphan file from disk."""
|
|
if not file_path:
|
|
return {'success': False, 'error': 'No file path associated with this finding'}
|
|
try:
|
|
# Resolve path in case of cross-environment mismatch
|
|
download_folder = None
|
|
if self._config_manager:
|
|
download_folder = self._config_manager.get('soulseek.download_path', '')
|
|
resolved = _resolve_file_path(file_path, self.transfer_folder, download_folder) or file_path
|
|
|
|
if os.path.exists(resolved):
|
|
os.remove(resolved)
|
|
# Clean up empty parent directories (never remove transfer folder itself)
|
|
transfer_norm = os.path.normpath(self.transfer_folder)
|
|
parent = os.path.dirname(resolved)
|
|
for _ in range(3): # Up to 3 levels (track/album/artist)
|
|
if (parent and os.path.isdir(parent)
|
|
and os.path.normpath(parent) != transfer_norm
|
|
and not os.listdir(parent)):
|
|
os.rmdir(parent)
|
|
parent = os.path.dirname(parent)
|
|
else:
|
|
break
|
|
return {'success': True, 'action': 'deleted_file',
|
|
'message': 'Deleted orphan file from disk'}
|
|
return {'success': True, 'action': 'already_gone',
|
|
'message': 'File was already removed'}
|
|
except OSError as e:
|
|
return {'success': False, 'error': f'Failed to delete file: {e}'}
|
|
|
|
def _fix_track_number(self, entity_type, entity_id, file_path, details):
|
|
"""Update track number in the database to the correct value."""
|
|
correct_num = details.get('correct_track_num')
|
|
if correct_num is None:
|
|
return {'success': False, 'error': 'No correct track number in finding details'}
|
|
if not entity_id:
|
|
return {'success': False, 'error': 'No track ID associated with this finding'}
|
|
result = self.db.update_track_fields(int(entity_id), {'track_number': int(correct_num)})
|
|
if result.get('success'):
|
|
return {'success': True, 'action': 'fixed_track_number',
|
|
'message': f'Updated track number to {correct_num}'}
|
|
return {'success': False, 'error': result.get('error', 'Failed to update track number')}
|
|
|
|
def _fix_missing_cover_art(self, entity_type, entity_id, file_path, details):
|
|
"""Update album thumbnail URL from the found artwork."""
|
|
artwork_url = details.get('found_artwork_url')
|
|
if not artwork_url:
|
|
return {'success': False, 'error': 'No artwork URL found in finding details'}
|
|
album_id = details.get('album_id') or entity_id
|
|
if not album_id:
|
|
return {'success': False, 'error': 'No album ID associated with this finding'}
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("UPDATE albums SET thumb_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
(artwork_url, album_id))
|
|
conn.commit()
|
|
if cursor.rowcount > 0:
|
|
return {'success': True, 'action': 'applied_cover_art',
|
|
'message': 'Applied cover art to album'}
|
|
return {'success': False, 'error': 'Album not found in database'}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _fix_metadata_gap(self, entity_type, entity_id, file_path, details):
|
|
"""Apply found metadata fields to the track."""
|
|
found_fields = details.get('found_fields')
|
|
if not found_fields or not isinstance(found_fields, dict):
|
|
return {'success': False, 'error': 'No metadata fields found in finding details'}
|
|
if not entity_id:
|
|
return {'success': False, 'error': 'No track ID associated with this finding'}
|
|
|
|
# Map found_fields to DB-updatable fields
|
|
field_map = {
|
|
'bpm': 'bpm', 'tempo': 'bpm',
|
|
'explicit': 'explicit',
|
|
'style': 'style', 'mood': 'mood',
|
|
}
|
|
updates = {}
|
|
for key, value in found_fields.items():
|
|
db_field = field_map.get(key.lower())
|
|
if db_field:
|
|
updates[db_field] = value
|
|
|
|
# Handle non-whitelisted fields via direct SQL
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
direct_fields = {}
|
|
for key, value in found_fields.items():
|
|
lk = key.lower()
|
|
if lk in ('isrc', 'spotify_track_id', 'musicbrainz_recording_id'):
|
|
direct_fields[lk] = value
|
|
|
|
if direct_fields:
|
|
set_parts = [f"{k} = ?" for k in direct_fields]
|
|
vals = list(direct_fields.values()) + [entity_id]
|
|
cursor.execute(
|
|
f"UPDATE tracks SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
vals
|
|
)
|
|
conn.commit()
|
|
|
|
if updates:
|
|
conn.close()
|
|
conn = None
|
|
self.db.update_track_fields(int(entity_id), updates)
|
|
|
|
applied = list(updates.keys()) + list(direct_fields.keys())
|
|
if applied:
|
|
return {'success': True, 'action': 'applied_metadata',
|
|
'message': f'Applied metadata: {", ".join(applied)}'}
|
|
return {'success': False, 'error': 'No applicable metadata fields to update'}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _fix_duplicates(self, entity_type, entity_id, file_path, details):
|
|
"""Keep the best quality duplicate and remove the rest from the database."""
|
|
tracks = details.get('tracks', [])
|
|
if len(tracks) < 2:
|
|
return {'success': False, 'error': 'Not enough duplicate info to determine best copy'}
|
|
|
|
# Pick best: highest bitrate, then longest duration
|
|
best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0))
|
|
best_id = best.get('track_id') or best.get('id')
|
|
if not best_id:
|
|
return {'success': False, 'error': 'Could not determine best track ID'}
|
|
|
|
remove_ids = []
|
|
for t in tracks:
|
|
tid = t.get('track_id') or t.get('id')
|
|
if tid and str(tid) != str(best_id):
|
|
remove_ids.append(tid)
|
|
|
|
if not remove_ids:
|
|
return {'success': False, 'error': 'No duplicates to remove'}
|
|
|
|
# Collect file paths before deleting DB entries
|
|
remove_paths = []
|
|
for t in tracks:
|
|
tid = t.get('track_id') or t.get('id')
|
|
if tid and str(tid) != str(best_id) and t.get('file_path'):
|
|
remove_paths.append(t['file_path'])
|
|
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join(['?'] * len(remove_ids))
|
|
cursor.execute(f"DELETE FROM tracks WHERE id IN ({placeholders})", remove_ids)
|
|
conn.commit()
|
|
removed = cursor.rowcount
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
# Delete duplicate files from disk (resolve paths for cross-environment compat)
|
|
download_folder = None
|
|
if self._config_manager:
|
|
download_folder = self._config_manager.get('soulseek.download_path', '')
|
|
transfer_norm = os.path.normpath(self.transfer_folder)
|
|
files_deleted = 0
|
|
for fpath in remove_paths:
|
|
try:
|
|
resolved = _resolve_file_path(fpath, self.transfer_folder, download_folder)
|
|
if resolved and os.path.exists(resolved):
|
|
os.remove(resolved)
|
|
files_deleted += 1
|
|
# Clean up empty parent directories (never remove transfer folder itself)
|
|
parent = os.path.dirname(resolved)
|
|
for _ in range(3):
|
|
if (parent and os.path.isdir(parent)
|
|
and os.path.normpath(parent) != transfer_norm
|
|
and not os.listdir(parent)):
|
|
os.rmdir(parent)
|
|
parent = os.path.dirname(parent)
|
|
else:
|
|
break
|
|
except OSError:
|
|
pass # Best effort — DB entry already removed
|
|
|
|
msg = f'Kept best quality copy, removed {removed} duplicate(s)'
|
|
if files_deleted:
|
|
msg += f' and {files_deleted} file(s) from disk'
|
|
return {'success': True, 'action': 'removed_duplicates', 'message': msg}
|
|
|
|
def _fix_mbid_mismatch(self, entity_type, entity_id, file_path, details):
|
|
"""Remove the mismatched MusicBrainz recording ID from the audio file."""
|
|
if not file_path:
|
|
return {'success': False, 'error': 'No file path associated with this finding'}
|
|
|
|
# Resolve path
|
|
download_folder = None
|
|
if self._config_manager:
|
|
download_folder = self._config_manager.get('soulseek.download_path', '')
|
|
resolved = _resolve_file_path(file_path, self.transfer_folder, download_folder)
|
|
if not resolved or not os.path.exists(resolved):
|
|
return {'success': False, 'error': f'File not found: {file_path}'}
|
|
|
|
try:
|
|
from core.repair_jobs.mbid_mismatch_detector import _remove_mbid_from_file
|
|
removed = _remove_mbid_from_file(resolved)
|
|
if removed:
|
|
mbid = details.get('mbid', 'unknown')
|
|
mb_title = details.get('mb_title', 'unknown')
|
|
title = details.get('title', 'unknown')
|
|
return {
|
|
'success': True,
|
|
'action': 'removed_mbid',
|
|
'message': f'Removed wrong MBID ({mbid[:8]}...) from "{title}" — was pointing to "{mb_title}"'
|
|
}
|
|
else:
|
|
return {'success': False, 'error': 'MBID tag not found in file (may have been removed already)'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'Failed to remove MBID: {str(e)}'}
|
|
|
|
def dismiss_finding(self, finding_id: int) -> bool:
|
|
"""Dismiss a finding."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
UPDATE repair_findings
|
|
SET status = 'dismissed', resolved_at = CURRENT_TIMESTAMP,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""", (finding_id,))
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
except Exception as e:
|
|
logger.error("Error dismissing finding %s: %s", finding_id, e)
|
|
return False
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def bulk_update_findings(self, finding_ids: List[int], action: str) -> int:
|
|
"""Bulk resolve or dismiss findings. Returns count updated."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join(['?'] * len(finding_ids))
|
|
|
|
if action == 'dismiss':
|
|
cursor.execute(f"""
|
|
UPDATE repair_findings
|
|
SET status = 'dismissed', resolved_at = CURRENT_TIMESTAMP,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id IN ({placeholders})
|
|
""", finding_ids)
|
|
else:
|
|
cursor.execute(f"""
|
|
UPDATE repair_findings
|
|
SET status = 'resolved', user_action = ?, resolved_at = CURRENT_TIMESTAMP,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id IN ({placeholders})
|
|
""", [action] + finding_ids)
|
|
|
|
conn.commit()
|
|
return cursor.rowcount
|
|
except Exception as e:
|
|
logger.error("Error bulk updating findings: %s", e)
|
|
return 0
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _get_findings_count(self, status: str = None) -> int:
|
|
"""Get count of findings by status."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
if status:
|
|
cursor.execute("SELECT COUNT(*) FROM repair_findings WHERE status = ?", (status,))
|
|
else:
|
|
cursor.execute("SELECT COUNT(*) FROM repair_findings")
|
|
row = cursor.fetchone()
|
|
return row[0] if row else 0
|
|
except Exception:
|
|
return 0
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def get_findings_counts(self) -> dict:
|
|
"""Get counts by status and by job."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Overall counts by status
|
|
cursor.execute("""
|
|
SELECT status, COUNT(*) FROM repair_findings
|
|
GROUP BY status
|
|
""")
|
|
status_counts = {row[0]: row[1] for row in cursor.fetchall()}
|
|
|
|
# Pending counts per job
|
|
cursor.execute("""
|
|
SELECT job_id, finding_type, severity, COUNT(*) FROM repair_findings
|
|
WHERE status = 'pending'
|
|
GROUP BY job_id, finding_type, severity
|
|
""")
|
|
by_job = {}
|
|
for job_id, finding_type, severity, cnt in cursor.fetchall():
|
|
if job_id not in by_job:
|
|
by_job[job_id] = {'total': 0, 'types': {}, 'warning': 0, 'info': 0}
|
|
by_job[job_id]['total'] += cnt
|
|
by_job[job_id]['types'][finding_type] = by_job[job_id]['types'].get(finding_type, 0) + cnt
|
|
if severity in ('warning', 'info'):
|
|
by_job[job_id][severity] += cnt
|
|
|
|
# Resolve display names
|
|
self._ensure_jobs_loaded()
|
|
for job_id in by_job:
|
|
job = self._jobs.get(job_id)
|
|
by_job[job_id]['display_name'] = job.display_name if job else job_id
|
|
|
|
return {
|
|
'pending': status_counts.get('pending', 0),
|
|
'resolved': status_counts.get('resolved', 0),
|
|
'dismissed': status_counts.get('dismissed', 0),
|
|
'auto_fixed': status_counts.get('auto_fixed', 0),
|
|
'total': sum(status_counts.values()),
|
|
'by_job': by_job,
|
|
}
|
|
except Exception:
|
|
return {'pending': 0, 'resolved': 0, 'dismissed': 0, 'auto_fixed': 0, 'total': 0, 'by_job': {}}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Job run history
|
|
# ------------------------------------------------------------------
|
|
def _record_job_start(self, job_id: str) -> Optional[int]:
|
|
"""Record a job run start. Returns run_id."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
INSERT INTO repair_job_runs (job_id, started_at, status)
|
|
VALUES (?, CURRENT_TIMESTAMP, 'running')
|
|
""", (job_id,))
|
|
conn.commit()
|
|
return cursor.lastrowid
|
|
except Exception as e:
|
|
logger.debug("Error recording job start: %s", e)
|
|
return None
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _record_job_finish(self, run_id: Optional[int], job_id: str,
|
|
result: JobResult, duration: float):
|
|
"""Record a job run completion."""
|
|
if not run_id:
|
|
return
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
status = 'completed'
|
|
cursor.execute("""
|
|
UPDATE repair_job_runs
|
|
SET finished_at = CURRENT_TIMESTAMP, duration_seconds = ?,
|
|
items_scanned = ?, findings_created = ?, auto_fixed = ?,
|
|
errors = ?, status = ?
|
|
WHERE id = ?
|
|
""", (duration, result.scanned, result.findings_created,
|
|
result.auto_fixed, result.errors, status, run_id))
|
|
conn.commit()
|
|
except Exception as e:
|
|
logger.debug("Error recording job finish: %s", e)
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _get_last_run(self, job_id: str) -> Optional[dict]:
|
|
"""Get the most recent run for a job."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT id, started_at, finished_at, duration_seconds,
|
|
items_scanned, findings_created, auto_fixed, errors, status
|
|
FROM repair_job_runs
|
|
WHERE job_id = ?
|
|
ORDER BY started_at DESC
|
|
LIMIT 1
|
|
""", (job_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return {
|
|
'id': row[0],
|
|
'started_at': row[1],
|
|
'finished_at': row[2],
|
|
'duration_seconds': row[3],
|
|
'items_scanned': row[4],
|
|
'findings_created': row[5],
|
|
'auto_fixed': row[6],
|
|
'errors': row[7],
|
|
'status': row[8],
|
|
}
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def get_history(self, job_id: str = None, limit: int = 50) -> List[dict]:
|
|
"""Get job run history."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
if job_id:
|
|
cursor.execute("""
|
|
SELECT id, job_id, started_at, finished_at, duration_seconds,
|
|
items_scanned, findings_created, auto_fixed, errors, status
|
|
FROM repair_job_runs
|
|
WHERE job_id = ?
|
|
ORDER BY started_at DESC
|
|
LIMIT ?
|
|
""", (job_id, limit))
|
|
else:
|
|
cursor.execute("""
|
|
SELECT id, job_id, started_at, finished_at, duration_seconds,
|
|
items_scanned, findings_created, auto_fixed, errors, status
|
|
FROM repair_job_runs
|
|
ORDER BY started_at DESC
|
|
LIMIT ?
|
|
""", (limit,))
|
|
|
|
runs = []
|
|
for row in cursor.fetchall():
|
|
# Get display name for this job
|
|
job = self._jobs.get(row[1])
|
|
display_name = job.display_name if job else row[1]
|
|
runs.append({
|
|
'id': row[0],
|
|
'job_id': row[1],
|
|
'display_name': display_name,
|
|
'started_at': row[2],
|
|
'finished_at': row[3],
|
|
'duration_seconds': row[4],
|
|
'items_scanned': row[5],
|
|
'findings_created': row[6],
|
|
'auto_fixed': row[7],
|
|
'errors': row[8],
|
|
'status': row[9],
|
|
})
|
|
return runs
|
|
except Exception as e:
|
|
logger.error("Error fetching job history: %s", e, exc_info=True)
|
|
return []
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Batch scan support (post-download)
|
|
# ------------------------------------------------------------------
|
|
def register_folder(self, batch_id: str, folder_path: str):
|
|
"""Register an album folder for repair scanning when its batch completes."""
|
|
if not folder_path:
|
|
return
|
|
with self._batch_folders_lock:
|
|
self._batch_folders.setdefault(batch_id, set()).add(folder_path)
|
|
|
|
def process_batch(self, batch_id: str):
|
|
"""Scan all folders registered for a completed batch.
|
|
|
|
Runs the track number repair job on specific folders only.
|
|
"""
|
|
with self._batch_folders_lock:
|
|
folders = self._batch_folders.pop(batch_id, set())
|
|
|
|
if not folders:
|
|
return
|
|
|
|
self._ensure_jobs_loaded()
|
|
tnr_job = self._jobs.get('track_number_repair')
|
|
if not tnr_job:
|
|
return
|
|
|
|
def _do_scan():
|
|
context = JobContext(
|
|
db=self.db,
|
|
transfer_folder=self.transfer_folder,
|
|
config_manager=self._config_manager,
|
|
spotify_client=self.spotify_client,
|
|
itunes_client=self.itunes_client,
|
|
mb_client=self.mb_client,
|
|
should_stop=lambda: self.should_stop,
|
|
is_paused=lambda: False, # Batch scans don't respect pause
|
|
)
|
|
|
|
try:
|
|
logger.info("[Repair] Batch %s: scanning %d folders", batch_id, len(folders))
|
|
result = tnr_job.scan_folders(list(folders), context)
|
|
logger.info("[Repair] Batch %s complete: scanned=%d fixed=%d errors=%d",
|
|
batch_id, result.scanned, result.auto_fixed, result.errors)
|
|
except Exception as e:
|
|
logger.error("[Repair] Batch %s failed: %s", batch_id, e, exc_info=True)
|
|
|
|
threading.Thread(target=_do_scan, daemon=True).start()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Path utilities
|
|
# ------------------------------------------------------------------
|
|
@staticmethod
|
|
def _resolve_path(path_str: str) -> str:
|
|
"""Resolve Docker path mapping if running in a container."""
|
|
if os.path.exists('/.dockerenv') and len(path_str) >= 3 and path_str[1] == ':' and path_str[0].isalpha():
|
|
drive_letter = path_str[0].lower()
|
|
rest_of_path = path_str[2:].replace('\\', '/')
|
|
return f"/host/mnt/{drive_letter}{rest_of_path}"
|
|
return path_str
|
|
|
|
def _get_transfer_path_from_db(self) -> str:
|
|
"""Read transfer path directly from the database app_config."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT value FROM metadata WHERE key = 'app_config'")
|
|
row = cursor.fetchone()
|
|
if row and row[0]:
|
|
config = json.loads(row[0])
|
|
return config.get('soulseek', {}).get('transfer_path', './Transfer')
|
|
except Exception as e:
|
|
logger.error("Error reading transfer path from DB: %s", e)
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
return './Transfer'
|