two field reports: 1) FIX-ALL skipped these findings — bulk_fix_findings() has a hardcoded fixable_types allowlist that didn't include 'short_preview_track'. added it, so select-all/fix-all and the per-page bulk-fix now cover this tool. 2) RE-WISHLISTED ITEM WAS ART-LESS — the payload pulled album art from the library thumb, which is empty for un-enriched HiFi previews, so the wishlist orb showed initials (no album OR artist image, since the orb falls back to album art). now the duration lookup also captures the metadata source's CDN art from raw_data (spotify album.images / itunes artworkUrl, upscaled) and stores it on the finding; the fix prefers that over the empty thumb. 3 new tests (art capture from spotify raw_data, itunes artwork upscale, fix uses finding art); 8 job tests + repair suite green.
3915 lines
173 KiB
Python
3915 lines
173 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 re
|
|
import shutil
|
|
import sys
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from difflib import SequenceMatcher
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from core.metadata_service import (
|
|
get_album_tracks_for_source,
|
|
get_source_priority,
|
|
get_primary_source,
|
|
)
|
|
from core.library.path_resolver import resolve_library_file_path
|
|
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 _album_fill_artist_names_match(expected_artist: str, candidate_artist: str) -> bool:
|
|
"""Strict artist gate for Album Completeness auto-fill.
|
|
|
|
Auto-fill moves/copies files into an existing album, so artist identity
|
|
must outrank album/title similarity. Use the alias-aware matcher when it
|
|
is available, then fall back to conservative normalized similarity.
|
|
"""
|
|
expected = (expected_artist or '').strip()
|
|
candidate = (candidate_artist or '').strip()
|
|
if not expected or not candidate:
|
|
return False
|
|
|
|
try:
|
|
from core.matching.artist_aliases import artist_names_match
|
|
matched, score = artist_names_match(expected, candidate, threshold=0.82)
|
|
if matched:
|
|
return True
|
|
if score < 0.82:
|
|
return False
|
|
except Exception as alias_err:
|
|
logger.debug("artist_names_match unavailable, using fallback: %s", alias_err)
|
|
|
|
try:
|
|
from core.matching_engine import MusicMatchingEngine
|
|
engine = MusicMatchingEngine()
|
|
expected_norm = engine.clean_artist(expected)
|
|
candidate_norm = engine.clean_artist(candidate)
|
|
except Exception:
|
|
expected_norm = expected.lower()
|
|
candidate_norm = candidate.lower()
|
|
|
|
if not expected_norm or not candidate_norm:
|
|
return False
|
|
return expected_norm == candidate_norm or SequenceMatcher(None, expected_norm, candidate_norm).ratio() >= 0.82
|
|
|
|
|
|
def _album_fill_target_artist_allows_track(album_artist: str, track_artists: List[str]) -> bool:
|
|
"""Return whether a source track can be auto-filled into an album artist.
|
|
|
|
Compilation-style album artists are allowed to contain varied track
|
|
artists. Normal albums require at least one source track artist to match
|
|
the target album artist before any local candidate is considered.
|
|
"""
|
|
album_artist = (album_artist or '').strip()
|
|
if not album_artist:
|
|
return False
|
|
|
|
normalized_album_artist = album_artist.lower().strip()
|
|
if normalized_album_artist in {'various artists', 'various', 'soundtrack'}:
|
|
return True
|
|
|
|
source_artists = [str(a).strip() for a in (track_artists or []) if str(a or '').strip()]
|
|
if not source_artists:
|
|
return True
|
|
|
|
return any(_album_fill_artist_names_match(album_artist, artist) for artist in source_artists)
|
|
|
|
|
|
def _split_acoustid_credit(credit: str) -> List[str]:
|
|
"""Split an AcoustID artist credit into individual contributor names.
|
|
|
|
Reuses the matching layer's credit splitter so the AcoustID retag
|
|
path tags multi-artist tracks the same way the post-download
|
|
enrichment pipeline does (comma / ampersand / feat. / etc).
|
|
Returns ``[credit]`` for single-artist credits — the writer's
|
|
``len > 1`` check is what gates whether the multi-value tag gets
|
|
written.
|
|
"""
|
|
try:
|
|
from core.matching.artist_aliases import split_artist_credit
|
|
return split_artist_credit(credit)
|
|
except Exception:
|
|
return [credit] if credit else []
|
|
|
|
|
|
def _resolve_file_path(file_path, transfer_folder, download_folder=None,
|
|
config_manager=None, plex_client=None):
|
|
"""Resolve a stored DB path to an actual file on disk.
|
|
|
|
Thin wrapper around ``core.library.path_resolver.resolve_library_file_path``
|
|
that preserves the legacy signature used by every caller in this module
|
|
and the repair-job modules. The shared resolver also probes the
|
|
user-configured ``library.music_paths`` and Plex-reported library
|
|
locations — which is what fixes the Album Completeness Auto-Fill
|
|
failure on Docker setups (issue #476). Pre-existing call sites that
|
|
don't pass ``config_manager`` keep the old transfer+download-only
|
|
behavior; sites that pass it in pick up the wider search automatically.
|
|
"""
|
|
return resolve_library_file_path(
|
|
file_path,
|
|
transfer_folder=transfer_folder,
|
|
download_folder=download_folder,
|
|
config_manager=config_manager,
|
|
plex_client=plex_client,
|
|
)
|
|
|
|
|
|
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._stop_event = threading.Event()
|
|
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._itunes_client = None
|
|
self._mb_client = None
|
|
self._acoustid_client = None
|
|
self._metadata_cache = None
|
|
|
|
# Metadata enhancement callback (injected from web_server.py)
|
|
self._enhance_file_metadata = 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)
|
|
|
|
def set_metadata_enhancer(self, enhance_fn):
|
|
"""Inject the metadata enhancement function from web_server.py.
|
|
|
|
This is _enhance_file_metadata(file_path, context, artist, album_info)
|
|
which handles full tag writing, source ID embedding, cover art, etc.
|
|
"""
|
|
self._enhance_file_metadata = enhance_fn
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lazy client accessors
|
|
# ------------------------------------------------------------------
|
|
@property
|
|
def spotify_client(self):
|
|
try:
|
|
from core.metadata_service import get_client_for_source
|
|
return get_client_for_source('spotify')
|
|
except Exception as e:
|
|
logger.error("Failed to resolve shared Spotify client: %s", e)
|
|
return None
|
|
|
|
@property
|
|
def itunes_client(self):
|
|
if self._itunes_client is None:
|
|
try:
|
|
from core.metadata_service import get_primary_client
|
|
self._itunes_client = get_primary_client()
|
|
except Exception as e:
|
|
logger.error("Failed to initialize fallback metadata client: %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).
|
|
|
|
Includes ``pending_findings_count`` per job so the job-card
|
|
badge can show CURRENT pending state instead of the
|
|
``last_run.findings_created`` historical scan count. Without
|
|
this, a scan that creates 372 findings + a subsequent bulk-
|
|
fix that resolves all of them leaves the badge displaying
|
|
"372 findings" while the Findings tab Pending filter shows 0
|
|
— confusing UX flagged on the Library Maintenance page.
|
|
"""
|
|
self._ensure_jobs_loaded()
|
|
|
|
# Single query → per-job pending count dict. O(1) lookup per
|
|
# job instead of N round trips.
|
|
pending_by_job = self._get_pending_count_by_job()
|
|
|
|
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(),
|
|
# Per-setting choice lists so the UI can render a dropdown
|
|
# instead of a free-text box (e.g. canonical source_selection).
|
|
'setting_options': dict(getattr(job, 'setting_options', {}) or {}),
|
|
'last_run': last_run,
|
|
'next_run': next_run,
|
|
'is_running': self._current_job_id == job_id,
|
|
'pending_findings_count': pending_by_job.get(job_id, 0),
|
|
})
|
|
return jobs_info
|
|
|
|
def _get_pending_count_by_job(self) -> dict:
|
|
"""Return ``{job_id: pending_count}`` for every job that has
|
|
any pending findings. Single SQL aggregation."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT job_id, COUNT(*) FROM repair_findings
|
|
WHERE status = 'pending'
|
|
GROUP BY job_id
|
|
""")
|
|
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
except Exception as e:
|
|
logger.debug("Error counting pending findings per job: %s", e)
|
|
return {}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
def start(self):
|
|
if self.running:
|
|
logger.warning("Repair worker already running")
|
|
return
|
|
self.running = True
|
|
self.should_stop = False
|
|
self._stop_event.clear()
|
|
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
|
|
self._stop_event.set()
|
|
if self.thread:
|
|
self.thread.join(timeout=2)
|
|
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._stop_event.is_set():
|
|
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, forced=True)
|
|
if self._sleep_or_stop(2):
|
|
break
|
|
continue
|
|
|
|
if not self.enabled:
|
|
self._current_job_id = None
|
|
self._current_job_name = None
|
|
if self._sleep_or_stop(2):
|
|
break
|
|
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
|
|
if self._sleep_or_stop(10):
|
|
break
|
|
continue
|
|
|
|
# Run the selected job
|
|
self._run_job(next_job)
|
|
|
|
# Brief pause between jobs
|
|
if self._sleep_or_stop(5):
|
|
break
|
|
|
|
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
|
|
if self._sleep_or_stop(30):
|
|
break
|
|
|
|
logger.info("Repair worker thread finished")
|
|
|
|
@staticmethod
|
|
def _hours_since(finished_at_iso: str, now_utc: datetime) -> float:
|
|
"""Hours between a stored ``finished_at`` and ``now_utc``, both in UTC.
|
|
|
|
``finished_at`` is written by SQLite's CURRENT_TIMESTAMP, which is ALWAYS
|
|
UTC (and naive). #885: the scheduler compared it against ``datetime.now()``
|
|
(naive LOCAL), so the local↔UTC offset leaked into the elapsed time. For a
|
|
zone AHEAD of UTC (Australia/Sydney = +11) every job looked ~11h stale and
|
|
fired every poll; behind UTC (the Americas) it just waited too long. Parse
|
|
the naive timestamp AS UTC and subtract a UTC ``now`` so scheduling is
|
|
timezone-independent."""
|
|
dt = datetime.fromisoformat(finished_at_iso)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return (now_utc - dt).total_seconds() / 3600
|
|
|
|
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(timezone.utc)
|
|
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']
|
|
if not interval_hours or interval_hours <= 0:
|
|
continue # Skip jobs with invalid interval
|
|
|
|
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:
|
|
elapsed_hours = self._hours_since(last_run['finished_at'], now)
|
|
|
|
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, forced: bool = False):
|
|
"""Execute a single job and record the run.
|
|
|
|
When forced=True, the user explicitly triggered this via "Run Now" —
|
|
the job runs even if the master worker is paused, and wait_if_paused()
|
|
does not block.
|
|
"""
|
|
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 — prefer config_manager (same source as web_server)
|
|
if self._config_manager:
|
|
raw = self._config_manager.get('soulseek.transfer_path', './Transfer')
|
|
else:
|
|
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 as e:
|
|
logger.debug("on_job_start callback failed: %s", e)
|
|
|
|
# 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 as e:
|
|
logger.debug("on_job_progress callback failed: %s", e)
|
|
|
|
# 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,
|
|
stop_event=self._stop_event,
|
|
is_paused=(lambda: False) if forced else (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 as e:
|
|
logger.debug("on_job_finish callback failed: %s", e)
|
|
|
|
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 _sleep_or_stop(self, seconds: float, step: float = 0.2) -> bool:
|
|
"""Sleep in small chunks so shutdown interrupts quickly."""
|
|
if seconds <= 0:
|
|
return self._stop_event.is_set()
|
|
remaining = seconds
|
|
while remaining > 0 and not self._stop_event.is_set():
|
|
chunk = min(step, remaining)
|
|
self._stop_event.wait(chunk)
|
|
remaining -= chunk
|
|
return self._stop_event.is_set()
|
|
|
|
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) -> bool:
|
|
"""Create a repair finding in the database.
|
|
|
|
Returns:
|
|
True — a NEW pending row was inserted.
|
|
False — dedup-skipped (an equivalent row already exists with
|
|
status pending/resolved/dismissed) OR a DB error
|
|
occurred. Callers should only increment their
|
|
``findings_created`` counter when this returns True
|
|
so the badge / scan log reports REAL new findings,
|
|
not silently-skipped duplicates.
|
|
"""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Dedup check: skip if same finding already exists (pending, resolved, OR dismissed)
|
|
cursor.execute("""
|
|
SELECT id FROM repair_findings
|
|
WHERE job_id = ? AND finding_type = ?
|
|
AND status IN ('pending', 'resolved', 'dismissed')
|
|
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 False # Already exists or was already fixed
|
|
|
|
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()
|
|
return True
|
|
except Exception as e:
|
|
logger.debug("Error creating finding: %s", e)
|
|
return False
|
|
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, fix_action: str = None) -> dict:
|
|
"""Execute the appropriate fix action for a finding, then mark it resolved.
|
|
|
|
Args:
|
|
finding_id: ID of the finding to fix
|
|
fix_action: Optional action override (e.g. 'staging' or 'delete' for orphan files)
|
|
"""
|
|
# Refresh transfer folder from config before each fix — same logic as _run_next_job
|
|
if self._config_manager:
|
|
raw = self._config_manager.get('soulseek.transfer_path', './Transfer')
|
|
self.transfer_folder = self._resolve_path(raw)
|
|
|
|
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
|
|
|
|
# Pass fix_action through to handler via details
|
|
if fix_action:
|
|
details['_fix_action'] = fix_action
|
|
|
|
# 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,
|
|
'missing_lyrics': self._fix_missing_lyrics,
|
|
'missing_replaygain': self._fix_missing_replaygain,
|
|
'empty_folder': self._fix_empty_folder,
|
|
'expired_download': self._fix_expired_download,
|
|
'metadata_gap': self._fix_metadata_gap,
|
|
'duplicate_tracks': self._fix_duplicates,
|
|
'single_album_redundant': self._fix_single_album_redundant,
|
|
'mbid_mismatch': self._fix_mbid_mismatch,
|
|
'album_mbid_mismatch': self._fix_album_mbid_mismatch,
|
|
'album_tag_inconsistency': self._fix_album_tag_inconsistency,
|
|
'incomplete_album': self._fix_incomplete_album,
|
|
'path_mismatch': self._fix_path_mismatch,
|
|
'missing_lossy_copy': self._fix_missing_lossy_copy,
|
|
'unwanted_content': self._fix_unwanted_content,
|
|
'unknown_artist': self._fix_unknown_artist,
|
|
'acoustid_mismatch': self._fix_acoustid_mismatch,
|
|
'quality_upgrade': self._fix_quality_upgrade,
|
|
'missing_discography_track': self._fix_discography_backfill,
|
|
'library_retag': self._fix_library_retag,
|
|
'short_preview_track': self._fix_short_preview_track,
|
|
}
|
|
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_discography_backfill(self, entity_type, entity_id, file_path, details):
|
|
"""Add missing discography track to wishlist."""
|
|
track_data = details.get('track_data')
|
|
if not track_data:
|
|
return {'success': False, 'error': 'No track data in finding'}
|
|
try:
|
|
success = self.db.add_to_wishlist(
|
|
spotify_track_data=track_data,
|
|
failure_reason='Discography backfill — missing from library',
|
|
source_type='repair',
|
|
source_info={'job': 'discography_backfill', 'artist': details.get('artist_name', '')}
|
|
)
|
|
track_name = track_data.get('name', '?')
|
|
if success:
|
|
return {'success': True, 'action': 'added_to_wishlist',
|
|
'message': f"Added '{track_name}' to wishlist"}
|
|
return {'success': False, 'error': f"Could not add '{track_name}' to wishlist (may already exist)"}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def _fix_quality_upgrade(self, entity_type, entity_id, file_path, details):
|
|
"""Apply a Quality Upgrade finding (user-approved; the old Quality
|
|
Scanner did this without review). Action via ``details['_fix_action']``:
|
|
|
|
'redownload' (default): add the matched higher-quality version to the
|
|
wishlist (with album context) for a profile-gated re-download.
|
|
The low-quality file stays in place — it's replaced only after the
|
|
better version actually imports (safe pattern; auto-delete-on-
|
|
import is handled separately).
|
|
'delete': remove the low-quality file + its DB row outright.
|
|
'ignore' is handled in the UI by dismissing the finding — never here.
|
|
"""
|
|
fix_action = details.get('_fix_action', 'redownload')
|
|
|
|
if fix_action == 'delete':
|
|
if file_path:
|
|
resolved = _resolve_file_path(
|
|
file_path, self.transfer_folder,
|
|
config_manager=self._config_manager)
|
|
if resolved and os.path.exists(resolved):
|
|
try:
|
|
os.remove(resolved)
|
|
self._cleanup_empty_parents(resolved)
|
|
except Exception as e:
|
|
logger.warning("Could not delete low-quality file %s: %s",
|
|
resolved, e)
|
|
if entity_id:
|
|
try:
|
|
conn = self.db._get_connection()
|
|
conn.cursor().execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'DB delete failed: {e}'}
|
|
return {'success': True, 'action': 'deleted_file',
|
|
'message': f'Deleted low-quality file: '
|
|
f'{os.path.basename(file_path or "")}'}
|
|
|
|
track_data = details.get('matched_track_data')
|
|
if not track_data:
|
|
return {'success': False, 'error': 'No matched track in finding'}
|
|
try:
|
|
success = self.db.add_to_wishlist(
|
|
spotify_track_data=track_data,
|
|
failure_reason=f"Quality upgrade — current file is {details.get('current_format', 'low quality')}",
|
|
source_type='repair',
|
|
source_info={
|
|
'job': 'quality_upgrade',
|
|
'original_file_path': file_path,
|
|
'original_format': details.get('current_format'),
|
|
'original_bitrate': details.get('current_bitrate'),
|
|
'album_title': details.get('album_title'),
|
|
'match_confidence': details.get('match_confidence'),
|
|
'provider': details.get('provider'),
|
|
},
|
|
)
|
|
track_name = track_data.get('name', '?')
|
|
if success:
|
|
return {'success': True, 'action': 'added_to_wishlist',
|
|
'message': f"Added '{track_name}' to wishlist for re-download"}
|
|
return {'success': False, 'error': f"Could not add '{track_name}' to wishlist (may already exist or be blocklisted)"}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def _fix_dead_file(self, entity_type, entity_id, file_path, details):
|
|
"""Fix a dead file reference. Action depends on details['_fix_action']:
|
|
'redownload' (default) — add to wishlist + remove DB entry
|
|
'remove' — just remove the dead DB entry without re-downloading
|
|
"""
|
|
if not entity_id:
|
|
return {'success': False, 'error': 'No track ID associated with this finding'}
|
|
|
|
fix_action = details.get('_fix_action', 'redownload')
|
|
|
|
# Simple removal — just delete the dead track record
|
|
if fix_action == 'remove':
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT title FROM tracks WHERE id = ?", (entity_id,))
|
|
row = cursor.fetchone()
|
|
track_name = row['title'] if row else 'Unknown'
|
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
|
|
conn.commit()
|
|
return {'success': True, 'action': 'removed',
|
|
'message': f'Removed "{track_name}" from database'}
|
|
except Exception as e:
|
|
logger.error("Dead file removal failed for track %s: %s", entity_id, e)
|
|
return {'success': False, 'error': str(e)}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
# Default: re-download flow
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Fetch full track + album + artist data from DB
|
|
cursor.execute("""
|
|
SELECT t.id, t.title, t.track_number, t.duration, t.bitrate,
|
|
t.spotify_track_id, t.itunes_track_id, t.deezer_id, t.isrc,
|
|
ar.name AS artist_name, ar.spotify_artist_id,
|
|
al.title AS album_title, al.spotify_album_id,
|
|
al.record_type, al.track_count, al.year, al.thumb_url AS album_thumb
|
|
FROM tracks t
|
|
LEFT JOIN artists ar ON ar.id = t.artist_id
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE t.id = ?
|
|
""", (entity_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
return {'success': False, 'error': 'Track not found in database'}
|
|
|
|
track_name = row['title'] or details.get('title', 'Unknown')
|
|
artist_name = row['artist_name'] or details.get('artist', 'Unknown Artist')
|
|
album_title = row['album_title'] or details.get('album', '')
|
|
|
|
# Best available ID for wishlist (spotify preferred, then itunes, deezer, fallback)
|
|
wishlist_id = (row['spotify_track_id']
|
|
or row['itunes_track_id']
|
|
or row['deezer_id']
|
|
or f"redownload_{entity_id}")
|
|
|
|
# Build album images list
|
|
album_images = []
|
|
album_thumb = row['album_thumb'] or details.get('album_thumb_url')
|
|
if album_thumb:
|
|
album_images = [{'url': album_thumb}]
|
|
|
|
# Build wishlist-compatible track data
|
|
spotify_track_data = {
|
|
'id': wishlist_id,
|
|
'name': track_name,
|
|
'artists': [{'name': artist_name}],
|
|
'album': {
|
|
'name': album_title or track_name,
|
|
'id': row['spotify_album_id'] or '',
|
|
'release_date': str(row['year']) if row['year'] else '',
|
|
'images': album_images,
|
|
'album_type': row['record_type'] or 'album',
|
|
'total_tracks': row['track_count'] or 0,
|
|
'artists': [{'name': artist_name}],
|
|
},
|
|
'duration_ms': row['duration'] or 0,
|
|
'track_number': row['track_number'] or 1,
|
|
'disc_number': 1,
|
|
'explicit': False,
|
|
'external_urls': {},
|
|
'popularity': 0,
|
|
'preview_url': None,
|
|
'uri': f"spotify:track:{row['spotify_track_id']}" if row['spotify_track_id'] else '',
|
|
'is_local': False,
|
|
}
|
|
|
|
source_info = {
|
|
'original_path': file_path or details.get('original_path', ''),
|
|
'album_title': album_title,
|
|
'artist': artist_name,
|
|
'reason': 'dead_file_redownload',
|
|
}
|
|
|
|
added = self.db.add_to_wishlist(
|
|
spotify_track_data,
|
|
failure_reason='Dead file — re-download requested',
|
|
source_type='redownload',
|
|
source_info=source_info,
|
|
)
|
|
|
|
if not added:
|
|
return {'success': False, 'error': 'Failed to add to wishlist (may already exist)'}
|
|
|
|
# Remove dead track entry from DB
|
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
|
|
conn.commit()
|
|
|
|
return {'success': True, 'action': 'added_to_wishlist',
|
|
'message': f'Added "{track_name}" to wishlist for re-download'}
|
|
except Exception as e:
|
|
logger.error("Dead file re-download failed for track %s: %s", entity_id, e)
|
|
return {'success': False, 'error': str(e)}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _fix_short_preview_track(self, entity_type, entity_id, file_path, details):
|
|
"""Approve a preview-clip finding: delete the ~30s preview file, drop its DB row, and
|
|
re-add the track to the wishlist (full payload) so the real version downloads. Mirrors
|
|
the dead-file 'redownload' payload + the acoustid-mismatch file delete. (Tools #937-adj)
|
|
"""
|
|
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("""
|
|
SELECT t.id, t.title, t.track_number, t.duration, t.bitrate,
|
|
t.spotify_track_id, t.itunes_track_id, t.deezer_id, t.isrc,
|
|
ar.name AS artist_name, ar.spotify_artist_id,
|
|
al.title AS album_title, al.spotify_album_id,
|
|
al.record_type, al.track_count, al.year, al.thumb_url AS album_thumb
|
|
FROM tracks t
|
|
LEFT JOIN artists ar ON ar.id = t.artist_id
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE t.id = ?
|
|
""", (entity_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return {'success': False, 'error': 'Track not found in database'}
|
|
|
|
track_name = row['title'] or details.get('title', 'Unknown')
|
|
artist_name = row['artist_name'] or details.get('artist', 'Unknown Artist')
|
|
album_title = row['album_title'] or details.get('album', '')
|
|
|
|
wishlist_id = (row['spotify_track_id']
|
|
or row['itunes_track_id']
|
|
or row['deezer_id']
|
|
or f"preview_redl_{entity_id}")
|
|
|
|
# Prefer the finding's stored art (the scan captures the metadata source's CDN image)
|
|
# over the library album thumb, which is often empty for un-enriched HiFi previews.
|
|
album_images = []
|
|
album_thumb = details.get('album_thumb_url') or row['album_thumb']
|
|
if album_thumb:
|
|
album_images = [{'url': album_thumb}]
|
|
|
|
spotify_track_data = {
|
|
'id': wishlist_id,
|
|
'name': track_name,
|
|
'artists': [{'name': artist_name}],
|
|
'album': {
|
|
'name': album_title or track_name,
|
|
'id': row['spotify_album_id'] or '',
|
|
'release_date': str(row['year']) if row['year'] else '',
|
|
'images': album_images,
|
|
'album_type': row['record_type'] or 'album',
|
|
'total_tracks': row['track_count'] or 0,
|
|
'artists': [{'name': artist_name}],
|
|
},
|
|
'duration_ms': int((details.get('expected_duration_s') or 0) * 1000) or (row['duration'] or 0),
|
|
'track_number': row['track_number'] or 1,
|
|
'disc_number': 1,
|
|
'explicit': False,
|
|
'external_urls': {},
|
|
'popularity': 0,
|
|
'preview_url': None,
|
|
'uri': f"spotify:track:{row['spotify_track_id']}" if row['spotify_track_id'] else '',
|
|
'is_local': False,
|
|
}
|
|
|
|
source_info = {
|
|
'original_path': file_path or details.get('original_path', ''),
|
|
'album_title': album_title,
|
|
'artist': artist_name,
|
|
'reason': 'preview_clip_redownload',
|
|
}
|
|
|
|
added = self.db.add_to_wishlist(
|
|
spotify_track_data,
|
|
failure_reason='Preview clip — re-downloading full track',
|
|
source_type='redownload',
|
|
source_info=source_info,
|
|
)
|
|
if not added:
|
|
return {'success': False, 'error': 'Failed to add to wishlist (may already exist or be blocklisted)'}
|
|
|
|
# Delete the preview file (path resolved like the other delete tools).
|
|
deleted_file = False
|
|
target_path = file_path or details.get('original_path')
|
|
if target_path:
|
|
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
|
resolved = _resolve_file_path(target_path, self.transfer_folder,
|
|
download_folder=download_folder,
|
|
config_manager=self._config_manager)
|
|
if resolved and os.path.exists(resolved):
|
|
try:
|
|
os.remove(resolved)
|
|
deleted_file = True
|
|
except Exception as e:
|
|
logger.warning("Could not delete preview file %s: %s", resolved, e)
|
|
|
|
# Drop the DB row so the track shows as missing.
|
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
|
|
conn.commit()
|
|
|
|
return {'success': True, 'action': 'added_to_wishlist',
|
|
'message': (f'Deleted preview clip and re-wishlisted "{track_name}" for full download'
|
|
if deleted_file else
|
|
f'Re-wishlisted "{track_name}" (preview file already gone)')}
|
|
except Exception as e:
|
|
logger.error("Preview-clip fix failed for track %s: %s", entity_id, e)
|
|
return {'success': False, 'error': str(e)}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def _fix_orphan_file(self, entity_type, entity_id, file_path, details):
|
|
"""Handle an orphan file — move to staging or delete based on user choice.
|
|
|
|
The fix_action is passed via details['_fix_action']:
|
|
'staging' — move file to the staging folder for import
|
|
'delete' — delete file from disk
|
|
If no action specified, returns an error asking the user to choose.
|
|
"""
|
|
fix_action = details.get('_fix_action', '')
|
|
if fix_action not in ('staging', 'delete'):
|
|
return {'success': False, 'error': 'Please choose an action: move to staging or delete',
|
|
'needs_action': True}
|
|
|
|
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, config_manager=self._config_manager) or file_path
|
|
|
|
if not os.path.exists(resolved):
|
|
return {'success': True, 'action': 'already_gone',
|
|
'message': 'File was already removed'}
|
|
|
|
if fix_action == 'staging':
|
|
# Move to staging folder
|
|
staging_path = './Staging'
|
|
if self._config_manager:
|
|
staging_path = self._config_manager.get('import.staging_path', './Staging')
|
|
staging_path = self._resolve_path(staging_path)
|
|
os.makedirs(staging_path, exist_ok=True)
|
|
|
|
dest = os.path.join(staging_path, os.path.basename(resolved))
|
|
# Avoid overwriting existing files in staging
|
|
if os.path.exists(dest):
|
|
base, ext = os.path.splitext(os.path.basename(resolved))
|
|
counter = 1
|
|
while os.path.exists(dest):
|
|
dest = os.path.join(staging_path, f"{base} ({counter}){ext}")
|
|
counter += 1
|
|
|
|
import shutil
|
|
shutil.move(resolved, dest)
|
|
|
|
# Clean up empty parent directories
|
|
self._cleanup_empty_parents(resolved)
|
|
|
|
return {'success': True, 'action': 'moved_to_staging',
|
|
'message': 'Moved to staging folder for import'}
|
|
|
|
elif fix_action == 'delete':
|
|
os.remove(resolved)
|
|
self._cleanup_empty_parents(resolved)
|
|
return {'success': True, 'action': 'deleted_file',
|
|
'message': 'Deleted orphan file from disk'}
|
|
|
|
except OSError as e:
|
|
return {'success': False, 'error': f'Failed to handle orphan file: {e}'}
|
|
|
|
def _cleanup_empty_parents(self, file_path):
|
|
"""Remove empty parent directories up to 3 levels, never removing the transfer folder."""
|
|
try:
|
|
transfer_norm = os.path.normpath(self.transfer_folder)
|
|
parent = os.path.dirname(file_path)
|
|
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
|
|
|
|
def _fix_track_number(self, entity_type, entity_id, file_path, details):
|
|
"""Fix track number in file tags, rename file, and update DB."""
|
|
correct_num = details.get('correct_track_num')
|
|
if correct_num is None:
|
|
return {'success': False, 'error': 'No correct track number in finding details'}
|
|
|
|
# If we have an entity_id (track DB ID), update DB directly
|
|
if entity_id:
|
|
try:
|
|
self.db.update_track_fields(int(entity_id), {'track_number': int(correct_num)})
|
|
except Exception as e:
|
|
logger.debug("DB track number update failed for entity %s: %s", entity_id, e)
|
|
|
|
# Fix the file tag (the primary fix — works even without entity_id)
|
|
if not file_path:
|
|
return {'success': False, 'error': 'No file path associated with this finding'}
|
|
|
|
# Resolve file path for cross-environment compat (Docker)
|
|
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, config_manager=self._config_manager) or file_path
|
|
|
|
if not os.path.isfile(resolved):
|
|
return {'success': False, 'error': f'File not found: {os.path.basename(file_path)}'}
|
|
|
|
try:
|
|
from core.repair_jobs.track_number_repair import _fix_track_number_tag, _fix_filename_track_number
|
|
|
|
# Write corrected track number to file tags
|
|
total_tracks = details.get('total_tracks')
|
|
if not total_tracks:
|
|
# Fallback: read current total from file to preserve it
|
|
try:
|
|
from core.repair_jobs.track_number_repair import _read_track_number_tag
|
|
from mutagen import File as MutagenFile
|
|
audio = MutagenFile(resolved)
|
|
if audio:
|
|
_, total_tracks = _read_track_number_tag(audio)
|
|
except Exception as e:
|
|
logger.debug("Failed to read total_tracks tag from file: %s", e)
|
|
total_tracks = int(total_tracks or 0)
|
|
_fix_track_number_tag(resolved, int(correct_num), total_tracks)
|
|
|
|
# Rename file if it has a track number prefix
|
|
fname = os.path.basename(resolved)
|
|
new_path = _fix_filename_track_number(resolved, fname, int(correct_num))
|
|
|
|
# Update DB file path if renamed
|
|
if new_path:
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?",
|
|
(new_path, file_path))
|
|
if cursor.rowcount == 0:
|
|
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?",
|
|
(new_path, resolved))
|
|
conn.commit()
|
|
except Exception as e:
|
|
logger.debug("Failed to update DB file_path after rename: %s", e)
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
return {'success': True, 'action': 'fixed_track_number',
|
|
'message': f'Updated track number to {correct_num}'}
|
|
except Exception as e:
|
|
logger.error("Error fixing track number for %s: %s", file_path, e)
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def _fix_artist_art(self, album_id, details):
|
|
"""Apply the found ARTIST image to the album's artist (DB thumb only —
|
|
artist art has no per-file embed). Pache711: independently applyable
|
|
from the album art on the same finding."""
|
|
artist_url = details.get('found_artist_url')
|
|
if not artist_url:
|
|
return {'success': False, 'error': 'No artist image found in finding details'}
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"UPDATE artists SET thumb_url = ?, updated_at = CURRENT_TIMESTAMP "
|
|
"WHERE id = (SELECT artist_id FROM albums WHERE id = ?)",
|
|
(artist_url, album_id))
|
|
conn.commit()
|
|
if cursor.rowcount == 0:
|
|
return {'success': False, 'error': 'Artist not found for this album'}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
return {'success': True, 'action': 'applied_artist_art',
|
|
'message': 'Applied artist image'}
|
|
|
|
def _fix_missing_cover_art(self, entity_type, entity_id, file_path, details):
|
|
"""Apply found artwork. ``_fix_action`` selects the target (Pache711):
|
|
'album' (default — DB thumb + embed into files + cover.jpg), 'artist'
|
|
(the artist's DB image), or 'both'. Defaulting to 'album' keeps the
|
|
plain "Apply Art" button behaving exactly as before."""
|
|
target = (details.get('_fix_action') or 'album').strip().lower()
|
|
if target not in ('album', 'artist', 'both'):
|
|
target = 'album'
|
|
|
|
album_id = details.get('album_id') or entity_id
|
|
if not album_id:
|
|
return {'success': False, 'error': 'No album ID associated with this finding'}
|
|
|
|
# Artist-only path: nothing to do with album files.
|
|
if target == 'artist':
|
|
return self._fix_artist_art(album_id, details)
|
|
|
|
artist_result = None
|
|
if target == 'both':
|
|
artist_result = self._fix_artist_art(album_id, details)
|
|
|
|
artwork_url = details.get('found_artwork_url')
|
|
# sidecar_from_embedded: the album already has embedded art and just needs
|
|
# a cover.jpg sidecar — the apply writes it from the existing embedded art,
|
|
# so no API artwork_url is required (Sokhi #813).
|
|
sidecar_from_embedded = bool(details.get('sidecar_from_embedded'))
|
|
if not artwork_url and not sidecar_from_embedded:
|
|
# 'both' but no album art — report the artist outcome if that ran.
|
|
if artist_result is not None:
|
|
return artist_result
|
|
return {'success': False, 'error': 'No artwork URL found in finding details'}
|
|
|
|
conn = None
|
|
track_paths = []
|
|
album_title = details.get('album_title')
|
|
artist_name = details.get('artist')
|
|
mbid = details.get('musicbrainz_release_id')
|
|
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': False, 'error': 'Album not found in database'}
|
|
|
|
# Pull album metadata + local track paths so we can write art to disk.
|
|
cursor.execute("""
|
|
SELECT al.title, ar.name, al.musicbrainz_release_id
|
|
FROM albums al LEFT JOIN artists ar ON ar.id = al.artist_id
|
|
WHERE al.id = ?
|
|
""", (album_id,))
|
|
meta_row = cursor.fetchone()
|
|
if meta_row:
|
|
album_title = album_title or meta_row[0]
|
|
artist_name = artist_name or meta_row[1]
|
|
mbid = mbid or meta_row[2]
|
|
cursor.execute("""
|
|
SELECT file_path FROM tracks
|
|
WHERE album_id = ? AND file_path IS NOT NULL AND file_path != ''
|
|
""", (album_id,))
|
|
track_paths = [r[0] for r in cursor.fetchall()]
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
# Resolve container/host path mismatches, keep only files that exist.
|
|
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
|
resolved = []
|
|
for p in track_paths:
|
|
rp = _resolve_file_path(p, self.transfer_folder, download_folder, config_manager=self._config_manager) or p
|
|
if os.path.isfile(rp):
|
|
resolved.append(rp)
|
|
|
|
if not resolved:
|
|
# Media-server-only album (no local files): DB thumbnail is all we can set.
|
|
msg = 'Applied cover art to album (database only — no local files found)'
|
|
if artist_result is not None and artist_result.get('success'):
|
|
msg += ' + applied artist image'
|
|
return {'success': True, 'action': 'applied_cover_art', 'message': msg}
|
|
|
|
from core.metadata.art_apply import apply_art_to_album_files
|
|
metadata = {
|
|
'artist': artist_name, 'album_artist': artist_name,
|
|
'album': album_title, 'album_art_url': artwork_url,
|
|
'musicbrainz_release_id': mbid,
|
|
}
|
|
album_info = {
|
|
'album_name': album_title, 'album_image_url': artwork_url,
|
|
'musicbrainz_release_id': mbid,
|
|
}
|
|
# Use the RESOLVED file's directory — NOT details['album_folder'], which
|
|
# is the raw DB path (e.g. Jellyfin's /data/music) and frequently does
|
|
# NOT exist inside the SoulSync container (only the resolved /app/...
|
|
# path does). Passing the raw folder made os.path.isdir() fail in
|
|
# apply_art_to_album_files, silently skipping the cover.jpg write while
|
|
# embedding (which uses the resolved paths) still worked — Sokhi's
|
|
# "embeds art but never writes cover.jpgs".
|
|
folder = os.path.dirname(resolved[0])
|
|
art_result = apply_art_to_album_files(resolved, metadata, album_info, folder=folder)
|
|
|
|
embedded = art_result.get('embedded', 0)
|
|
if art_result.get('read_only_fs'):
|
|
# The music folder is genuinely read-only at the OS level (the
|
|
# write raised EROFS). Most common cause is a docker ':ro' volume,
|
|
# but it can also be a read-only host mount (NFS/SMB exported ro),
|
|
# a mergerfs/union read-only branch, or the library mounted from
|
|
# another container as read-only — chmod can't change any of these.
|
|
return {'success': False, 'action': 'applied_cover_art',
|
|
'error': ('Your music folder is READ-ONLY — the container cannot '
|
|
'write to it (chmod cannot change this). Check that the '
|
|
"volume isn't mapped ':ro', and that the underlying host "
|
|
'mount (NFS/SMB/mergerfs) is read-write, then recreate the '
|
|
'container. (Database thumbnail was still updated.)'),
|
|
'art_result': art_result}
|
|
skipped = art_result.get('skipped', 0)
|
|
failed = art_result.get('failed', 0)
|
|
cover_written = art_result.get('cover_written')
|
|
|
|
wrote_parts = []
|
|
if embedded:
|
|
wrote_parts.append(f'embedded into {embedded}/{len(resolved)} file(s)')
|
|
if cover_written:
|
|
wrote_parts.append('wrote cover.jpg')
|
|
|
|
if wrote_parts:
|
|
msg = 'Applied cover art: ' + ' + '.join(wrote_parts)
|
|
elif failed:
|
|
# Real per-file write failures that were NOT a read-only mount
|
|
# (genuine EROFS is handled above) — almost always file/folder
|
|
# permissions or a locked file.
|
|
msg = (f'Updated database thumbnail, but could not write art to '
|
|
f'{failed} file(s) — check file/folder permissions')
|
|
elif skipped:
|
|
# Every file already had embedded art and no new cover.jpg was
|
|
# needed — nothing to do, NOT a failure. This is the case that made
|
|
# the old "(read-only?)" message fire on perfectly writable
|
|
# libraries (Boulder on Windows, Sokhi): the files were simply
|
|
# already arted, so embedded==0 and cover_written==False.
|
|
msg = f'Cover art already present on all {skipped} file(s) — database thumbnail updated'
|
|
else:
|
|
# No file art applied and nothing found to write.
|
|
msg = 'Updated database thumbnail (no file artwork was applied)'
|
|
if artist_result is not None and artist_result.get('success'):
|
|
msg += ' + applied artist image'
|
|
return {'success': True, 'action': 'applied_cover_art', 'message': msg, 'art_result': art_result}
|
|
|
|
def _fix_missing_lyrics(self, entity_type, entity_id, file_path, details):
|
|
"""Apply a missing-lyrics finding: fetch + write the .lrc sidecar and
|
|
embed the lyrics, via the same LyricsClient the import pipeline uses."""
|
|
raw_path = details.get('file_path') or file_path
|
|
if not raw_path:
|
|
return {'success': False, 'error': 'No file path in finding'}
|
|
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
|
resolved = _resolve_file_path(raw_path, self.transfer_folder, download_folder,
|
|
config_manager=self._config_manager) or raw_path
|
|
if not os.path.isfile(resolved):
|
|
return {'success': False, 'error': f'File not found on disk: {os.path.basename(raw_path)}'}
|
|
try:
|
|
from core.lyrics_client import lyrics_client
|
|
duration = details.get('duration')
|
|
ok = lyrics_client.create_lrc_file(
|
|
resolved,
|
|
details.get('track_title') or '',
|
|
details.get('artist') or '',
|
|
album_name=details.get('album_title'),
|
|
duration_seconds=int(duration) if duration else None,
|
|
)
|
|
except Exception as e:
|
|
logger.error("Lyrics fix failed for %s: %s", os.path.basename(raw_path), e)
|
|
return {'success': False, 'error': str(e)}
|
|
if not ok:
|
|
# Lyrics vanished between scan and apply (rare) — report, don't crash.
|
|
return {'success': False, 'error': 'Could not fetch lyrics (no longer available?)'}
|
|
return {'success': True, 'action': 'applied_lyrics', 'message': 'Wrote lyrics (.lrc) + embedded'}
|
|
|
|
def _fix_missing_replaygain(self, entity_type, entity_id, file_path, details):
|
|
"""Apply a missing-ReplayGain finding: run the same ffmpeg ebur128 loudness
|
|
analysis the import pipeline uses and write the RG tags in place (#437)."""
|
|
raw_path = details.get('file_path') or file_path
|
|
if not raw_path:
|
|
return {'success': False, 'error': 'No file path in finding'}
|
|
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
|
resolved = _resolve_file_path(raw_path, self.transfer_folder, download_folder,
|
|
config_manager=self._config_manager) or raw_path
|
|
if not os.path.isfile(resolved):
|
|
return {'success': False, 'error': f'File not found on disk: {os.path.basename(raw_path)}'}
|
|
try:
|
|
from core.replaygain import (analyze_track, write_replaygain_tags,
|
|
is_ffmpeg_available, RG_REFERENCE_LUFS)
|
|
if not is_ffmpeg_available():
|
|
return {'success': False, 'error': 'ffmpeg not available — cannot analyze ReplayGain'}
|
|
lufs, peak_dbfs = analyze_track(resolved)
|
|
gain_db = RG_REFERENCE_LUFS - lufs # same formula as the import pipeline
|
|
ok = write_replaygain_tags(resolved, gain_db, peak_dbfs)
|
|
except Exception as e:
|
|
logger.error("ReplayGain fix failed for %s: %s", os.path.basename(raw_path), e)
|
|
return {'success': False, 'error': str(e)}
|
|
if not ok:
|
|
return {'success': False, 'error': 'Could not write ReplayGain tags'}
|
|
return {'success': True, 'action': 'applied_replaygain',
|
|
'message': f'Wrote ReplayGain ({gain_db:+.2f} dB)'}
|
|
|
|
def _fix_empty_folder(self, entity_type, entity_id, file_path, details):
|
|
"""Apply an empty-folder finding: re-check the folder is still empty/junk-
|
|
only (anything that gained a real file since the scan is left alone), then
|
|
remove it. The library root + symlinked dirs are refused."""
|
|
from core.repair_jobs.empty_folder_cleaner import remove_empty_folder
|
|
raw = details.get('folder_path') or file_path
|
|
if not raw:
|
|
return {'success': False, 'error': 'No folder path in finding'}
|
|
resolved = self._resolve_path(raw) if hasattr(self, '_resolve_path') else raw
|
|
res = remove_empty_folder(
|
|
resolved,
|
|
junk_files=details.get('junk_files') or [],
|
|
remove_junk=bool(details.get('remove_junk', True)),
|
|
remove_disposable=bool(details.get('remove_disposable', False)),
|
|
root=self.transfer_folder,
|
|
listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink,
|
|
remove_file=os.remove, rmdir=os.rmdir,
|
|
)
|
|
if not res.get('removed'):
|
|
return {'success': False, 'error': res.get('error') or 'Could not remove folder'}
|
|
_name = os.path.basename(resolved.rstrip('/\\')) or resolved
|
|
return {'success': True, 'action': 'removed_empty_folder',
|
|
'message': f'Removed empty folder: {_name}'}
|
|
|
|
def _fix_expired_download(self, entity_type, entity_id, file_path, details):
|
|
"""Apply an expired-download finding: delete the file + library row +
|
|
history entry, via the same helper the cleaner's auto mode uses."""
|
|
from core.repair_jobs.expired_download_cleaner import delete_origin_download
|
|
entry = {'id': details.get('history_id') or entity_id,
|
|
'file_path': details.get('file_path') or file_path}
|
|
if not entry['id']:
|
|
return {'success': False, 'error': 'No history id in finding'}
|
|
res = delete_origin_download(self.db, entry, self._config_manager)
|
|
if res.get('error'):
|
|
return {'success': False, 'action': 'deleted_expired',
|
|
'error': f"Could not delete file: {res['error']}"}
|
|
verb = 'deleted file + entry' if res.get('file_deleted') else 'removed entry (file already gone)'
|
|
return {'success': True, 'action': 'deleted_expired', 'message': f'Expired download — {verb}'}
|
|
|
|
def _fix_library_retag(self, entity_type, entity_id, file_path, details):
|
|
"""Apply a library re-tag finding: write each track's planned tags in
|
|
place (core.tag_writer.write_tags_to_file) + optionally embed/refresh
|
|
cover art. Only ADDS/overwrites the planned fields — no moves/renames."""
|
|
tracks = details.get('tracks') or []
|
|
if not tracks:
|
|
return {'success': False, 'error': 'No tracks to re-tag in finding'}
|
|
|
|
# Resolve container/host path mismatches, then delegate to the shared
|
|
# apply path the job's auto-fix mode also uses.
|
|
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
|
resolved_plans = []
|
|
for t in tracks:
|
|
raw = t.get('file_path')
|
|
if not raw:
|
|
continue
|
|
rp = _resolve_file_path(raw, self.transfer_folder, download_folder,
|
|
config_manager=self._config_manager) or raw
|
|
plan = {'file_path': rp, 'db_data': t.get('db_data') or {}}
|
|
if t.get('full_meta'):
|
|
plan['full_meta'] = t['full_meta']
|
|
if t.get('lyrics_meta'):
|
|
plan['lyrics_meta'] = t['lyrics_meta'] # read-only lyrics query metadata
|
|
resolved_plans.append(plan)
|
|
|
|
from core.repair_jobs.library_retag import apply_track_plans
|
|
res = apply_track_plans(resolved_plans, details.get('cover_action'), details.get('cover_url'),
|
|
full=(details.get('depth') == 'full'),
|
|
lyrics_action=details.get('lyrics_action', False))
|
|
|
|
if res['written'] == 0 and not res['cover_written'] and not res.get('lyrics_written'):
|
|
return {'success': False,
|
|
'error': 'Nothing could be written — files unreachable or read-only?'}
|
|
msg = f"Re-tagged {res['written']} track(s)"
|
|
if res['failed']:
|
|
msg += f" ({res['failed']} failed)"
|
|
if res['cover_written']:
|
|
msg += ' + refreshed cover.jpg'
|
|
return {'success': True, 'action': 'library_retag', 'message': msg, **res}
|
|
|
|
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 selected or 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'}
|
|
|
|
# If user specified which track to keep, use that
|
|
keep_id = details.get('_fix_action')
|
|
if keep_id:
|
|
best = next((t for t in tracks if str(t.get('track_id') or t.get('id')) == str(keep_id)), None)
|
|
if not best:
|
|
return {'success': False, 'error': f'Selected track ID {keep_id} not found in duplicates'}
|
|
best_id = keep_id
|
|
else:
|
|
# Auto-pick the keeper: lossless format first (so a FLAC beats an
|
|
# MP3 even when the FLAC's bitrate is missing in the DB), then
|
|
# bitrate, duration, and track number as tie-breakers.
|
|
from core.library.duplicate_keep import pick_duplicate_to_keep
|
|
best = pick_duplicate_to_keep(tracks)
|
|
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, config_manager=self._config_manager)
|
|
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_single_album_redundant(self, entity_type, entity_id, file_path, details):
|
|
"""Remove the single/EP version, keeping the album version."""
|
|
single_info = details.get('single_track', {})
|
|
album_info = details.get('album_track', {})
|
|
single_id = single_info.get('id') or entity_id
|
|
single_path = single_info.get('file_path') or file_path
|
|
|
|
if not single_id:
|
|
return {'success': False, 'error': 'No single track ID to remove'}
|
|
|
|
# Verify the album track still exists before removing the single
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
album_id = album_info.get('id')
|
|
if album_id:
|
|
cursor.execute("SELECT id FROM tracks WHERE id = ?", (album_id,))
|
|
if not cursor.fetchone():
|
|
return {'success': False, 'error': 'Album version no longer exists in library — keeping single'}
|
|
|
|
# Remove single from DB
|
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (single_id,))
|
|
conn.commit()
|
|
removed = cursor.rowcount
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
if removed == 0:
|
|
return {'success': True, 'action': 'already_removed', 'message': 'Single track was already removed'}
|
|
|
|
# Delete single file from disk
|
|
file_deleted = False
|
|
if single_path:
|
|
download_folder = None
|
|
if self._config_manager:
|
|
download_folder = self._config_manager.get('soulseek.download_path', '')
|
|
try:
|
|
resolved = _resolve_file_path(single_path, self.transfer_folder, download_folder, config_manager=self._config_manager)
|
|
if resolved and os.path.exists(resolved):
|
|
os.remove(resolved)
|
|
file_deleted = True
|
|
# Clean up empty parent directories
|
|
transfer_norm = os.path.normpath(self.transfer_folder)
|
|
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
|
|
|
|
album_name = album_info.get('album', 'unknown album')
|
|
msg = f'Removed single, album version on "{album_name}" kept'
|
|
if file_deleted:
|
|
msg += ' (file deleted)'
|
|
return {'success': True, 'action': 'removed_single', 'message': msg}
|
|
|
|
def _fix_unwanted_content(self, entity_type, entity_id, file_path, details):
|
|
"""Remove unwanted content (live, commentary, interview, spoken word) from library."""
|
|
track_info = details.get('track', {})
|
|
track_id = track_info.get('id') or entity_id
|
|
track_path = track_info.get('file_path') or file_path
|
|
type_label = details.get('type_label', 'Unwanted')
|
|
|
|
if not track_id:
|
|
return {'success': False, 'error': 'No track ID to remove'}
|
|
|
|
# Remove from DB
|
|
conn = None
|
|
album_id = track_info.get('album_id')
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (track_id,))
|
|
conn.commit()
|
|
removed = cursor.rowcount
|
|
|
|
# Check if album is now empty — clean it up too
|
|
if removed and album_id:
|
|
cursor.execute("SELECT COUNT(*) FROM tracks WHERE album_id = ?", (album_id,))
|
|
remaining = cursor.fetchone()[0]
|
|
if remaining == 0:
|
|
cursor.execute("DELETE FROM albums WHERE id = ?", (album_id,))
|
|
conn.commit()
|
|
logger.info("Cleaned up empty album (id=%s) after removing last track", album_id)
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'DB error: {e}'}
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
if removed == 0:
|
|
return {'success': True, 'action': 'already_removed', 'message': 'Track was already removed'}
|
|
|
|
# Delete file from disk
|
|
file_deleted = False
|
|
if track_path:
|
|
download_folder = None
|
|
if self._config_manager:
|
|
download_folder = self._config_manager.get('soulseek.download_path', '')
|
|
try:
|
|
resolved = _resolve_file_path(track_path, self.transfer_folder, download_folder, config_manager=self._config_manager)
|
|
if resolved and os.path.exists(resolved):
|
|
os.remove(resolved)
|
|
file_deleted = True
|
|
# Clean up empty parent directories
|
|
transfer_norm = os.path.normpath(self.transfer_folder)
|
|
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'{type_label} track removed from library'
|
|
if file_deleted:
|
|
msg += ' (file deleted)'
|
|
return {'success': True, 'action': 'removed_content', 'message': msg}
|
|
|
|
def _fix_unknown_artist(self, entity_type, entity_id, file_path, details):
|
|
"""Fix an Unknown Artist track — re-tag, move to correct path, update DB."""
|
|
track_id = details.get('track_id')
|
|
corrected_artist = details.get('corrected_artist', '')
|
|
corrected_album = details.get('corrected_album', '')
|
|
corrected_title = details.get('corrected_title', '')
|
|
corrected_track_number = details.get('corrected_track_number')
|
|
corrected_year = details.get('corrected_year', '')
|
|
cover_url = details.get('cover_url', '')
|
|
expected_path = details.get('expected_path', '')
|
|
|
|
if not corrected_artist or not track_id:
|
|
return {'success': False, 'error': 'Missing corrected artist or track ID'}
|
|
|
|
# Resolve file
|
|
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else ''
|
|
resolved = _resolve_file_path(file_path, self.transfer_folder, download_folder, config_manager=self._config_manager) if file_path else None
|
|
if not resolved or not os.path.exists(resolved):
|
|
return {'success': False, 'error': f'File not found: {file_path}'}
|
|
|
|
# Step 1: Re-tag file
|
|
try:
|
|
from core.tag_writer import write_tags_to_file
|
|
db_data = {
|
|
'title': corrected_title,
|
|
'artist_name': corrected_artist,
|
|
'album_title': corrected_album,
|
|
'year': corrected_year,
|
|
'track_number': corrected_track_number,
|
|
}
|
|
write_tags_to_file(resolved, db_data, embed_cover=bool(cover_url), cover_url=cover_url or None)
|
|
except Exception as e:
|
|
logger.warning(f"Tag write failed during unknown artist fix: {e}")
|
|
|
|
# Step 2: Move file if expected path differs
|
|
final_path = resolved
|
|
if expected_path:
|
|
expected_abs = os.path.normpath(os.path.join(self.transfer_folder, expected_path))
|
|
if os.path.normpath(resolved).lower() != expected_abs.lower():
|
|
try:
|
|
os.makedirs(os.path.dirname(expected_abs), exist_ok=True)
|
|
if sys.platform in ('win32', 'darwin') and os.path.exists(expected_abs):
|
|
tmp = expected_abs + '.tmp_rename'
|
|
shutil.move(resolved, tmp)
|
|
shutil.move(tmp, expected_abs)
|
|
else:
|
|
shutil.move(resolved, expected_abs)
|
|
final_path = expected_abs
|
|
|
|
# Move sidecars
|
|
src_dir = os.path.dirname(resolved)
|
|
dst_dir = os.path.dirname(expected_abs)
|
|
src_stem = os.path.splitext(os.path.basename(resolved))[0]
|
|
dst_stem = os.path.splitext(os.path.basename(expected_abs))[0]
|
|
for ext in ('.lrc', '.jpg', '.jpeg', '.png', '.txt'):
|
|
s = os.path.join(src_dir, src_stem + ext)
|
|
if os.path.isfile(s):
|
|
d = os.path.join(dst_dir, dst_stem + ext)
|
|
if not os.path.exists(d):
|
|
try:
|
|
shutil.move(s, d)
|
|
except Exception as e:
|
|
logger.debug("Failed to move sidecar %s: %s", s, e)
|
|
|
|
# Clean up empty dirs
|
|
self._cleanup_empty_parents(resolved)
|
|
except Exception as e:
|
|
logger.error(f"File move failed: {e}")
|
|
|
|
# Step 3: Update DB
|
|
try:
|
|
conn = self.db._get_connection()
|
|
try:
|
|
cursor = conn.cursor()
|
|
# Find or create artist
|
|
cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (corrected_artist,))
|
|
row = cursor.fetchone()
|
|
new_artist_id = row[0] if row else None
|
|
if not new_artist_id:
|
|
safe_artist_name = re.sub(
|
|
r'[^A-Za-z0-9_.-]+',
|
|
'_',
|
|
corrected_artist.strip() or 'unknown'
|
|
)
|
|
new_artist_id = f"artist_local_{safe_artist_name}_{uuid.uuid4().hex[:8]}"
|
|
cursor.execute(
|
|
"INSERT INTO artists (id, name) VALUES (?, ?)",
|
|
(new_artist_id, corrected_artist),
|
|
)
|
|
|
|
cursor.execute("UPDATE tracks SET artist_id = ?, file_path = ? WHERE id = ?",
|
|
(new_artist_id, final_path, track_id))
|
|
if corrected_track_number:
|
|
cursor.execute("UPDATE tracks SET track_number = ? WHERE id = ?",
|
|
(corrected_track_number, track_id))
|
|
album_id = details.get('album_id')
|
|
if album_id:
|
|
if corrected_album:
|
|
cursor.execute("UPDATE albums SET title = ? WHERE id = ?", (corrected_album, album_id))
|
|
if corrected_year and corrected_year.isdigit():
|
|
cursor.execute("UPDATE albums SET year = ? WHERE id = ?", (int(corrected_year), album_id))
|
|
cursor.execute("UPDATE albums SET artist_id = ? WHERE id = ?", (new_artist_id, album_id))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'DB update failed: {e}'}
|
|
|
|
return {'success': True, 'action': 'fixed_unknown_artist',
|
|
'message': f'Fixed: {corrected_artist} - {corrected_title}'}
|
|
|
|
def _fix_acoustid_mismatch(self, entity_type, entity_id, file_path, details):
|
|
"""Fix an AcoustID mismatch. Actions:
|
|
'retag' (default): Update DB title/artist to match the actual audio content
|
|
'redownload': Add the expected (correct) track to wishlist and delete the wrong file
|
|
'delete': Just delete the wrong file and DB record
|
|
"""
|
|
fix_action = details.get('_fix_action', 'retag')
|
|
track_id = entity_id
|
|
|
|
if fix_action == 'delete':
|
|
# Delete file + DB record
|
|
if file_path:
|
|
resolved = _resolve_file_path(file_path, self.transfer_folder, config_manager=self._config_manager)
|
|
if resolved and os.path.exists(resolved):
|
|
try:
|
|
os.remove(resolved)
|
|
except Exception as e:
|
|
logger.warning("Could not delete file %s: %s", resolved, e)
|
|
if track_id:
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (track_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'DB delete failed: {e}'}
|
|
return {'success': True, 'action': 'deleted',
|
|
'message': f'Deleted wrong file: {os.path.basename(file_path or "")}'}
|
|
|
|
if fix_action == 'redownload':
|
|
# Add expected track to wishlist, then delete the wrong file
|
|
expected_title = details.get('expected_title', '')
|
|
expected_artist = details.get('expected_artist', '')
|
|
album_title = details.get('album_title', '')
|
|
if expected_title and expected_artist:
|
|
try:
|
|
track_data = {
|
|
'id': f'acoustid_fix_{uuid.uuid4().hex[:8]}',
|
|
'name': expected_title,
|
|
'artists': [{'name': expected_artist}],
|
|
'album': {'name': album_title} if album_title else {'name': expected_title},
|
|
}
|
|
self.db.add_to_wishlist(
|
|
spotify_track_data=track_data,
|
|
failure_reason='AcoustID mismatch — re-downloading correct track',
|
|
source_type='repair',
|
|
)
|
|
logger.info("Added '%s' by '%s' to wishlist for re-download",
|
|
expected_title, expected_artist)
|
|
except Exception as e:
|
|
logger.warning("Could not add to wishlist: %s", e)
|
|
# Delete wrong file
|
|
if file_path:
|
|
resolved = _resolve_file_path(file_path, self.transfer_folder, config_manager=self._config_manager)
|
|
if resolved and os.path.exists(resolved):
|
|
try:
|
|
os.remove(resolved)
|
|
except Exception as e:
|
|
logger.debug("Failed to remove wrong file %s: %s", resolved, e)
|
|
if track_id:
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (track_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception as e:
|
|
logger.debug("Failed to delete wrong track row from DB: %s", e)
|
|
return {'success': True, 'action': 'redownload',
|
|
'message': f'Added "{expected_title}" to wishlist, removed wrong file'}
|
|
|
|
if fix_action == 'relocate':
|
|
# #704: retag fixes the file's tags but leaves it in the WRONG
|
|
# artist/album folder. AcoustID gives only title+artist (no reliable
|
|
# album), so move the retagged file into staging and let auto-import
|
|
# re-file it correctly with full metadata. Drop the stale tracks row.
|
|
resolved = _resolve_file_path(file_path, self.transfer_folder,
|
|
config_manager=self._config_manager)
|
|
if not resolved or not os.path.exists(resolved):
|
|
return {'success': False, 'error': f'File not found: {file_path}'}
|
|
staging_path = './Staging'
|
|
if self._config_manager:
|
|
staging_path = self._config_manager.get('import.staging_path', './Staging')
|
|
staging_path = self._resolve_path(staging_path)
|
|
try:
|
|
os.makedirs(staging_path, exist_ok=True)
|
|
except OSError as e:
|
|
return {'success': False, 'error': f'Staging folder unavailable: {e}'}
|
|
|
|
aid_title = details.get('acoustid_title', '')
|
|
aid_artist = details.get('acoustid_artist', '')
|
|
tag_updates = {}
|
|
if aid_title:
|
|
tag_updates['title'] = aid_title
|
|
if aid_artist:
|
|
tag_updates['artist_name'] = aid_artist
|
|
tag_updates['artists_list'] = _split_acoustid_credit(aid_artist)
|
|
|
|
def _drop_row():
|
|
if not track_id:
|
|
return
|
|
conn = self.db._get_connection()
|
|
try:
|
|
conn.cursor().execute("DELETE FROM tracks WHERE id = ?", (track_id,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
from core.repair_jobs.relocate import relocate_mismatch_to_staging
|
|
from core.tag_writer import write_tags_to_file
|
|
from core.imports.file_ops import safe_move_file
|
|
try:
|
|
dest = relocate_mismatch_to_staging(
|
|
resolved, staging_path, tag_updates,
|
|
write_tags=write_tags_to_file, move_file=safe_move_file,
|
|
drop_db_row=_drop_row, exists=os.path.exists)
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'Relocate failed: {e}'}
|
|
self._cleanup_empty_parents(resolved) # remove the now-empty wrong folder
|
|
return {'success': True, 'action': 'relocated',
|
|
'message': f'Moved to staging for re-import: {os.path.basename(dest)}'}
|
|
|
|
# Default: retag — update DB record to match the actual audio content
|
|
aid_title = details.get('acoustid_title', '')
|
|
aid_artist = details.get('acoustid_artist', '')
|
|
if not aid_title:
|
|
return {'success': False, 'error': 'No AcoustID title available to retag'}
|
|
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
# Update track title
|
|
cursor.execute("UPDATE tracks SET title = ? WHERE id = ?", (aid_title, track_id))
|
|
# Update artist if we have one and it differs
|
|
if aid_artist:
|
|
cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (aid_artist,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
cursor.execute("UPDATE tracks SET artist_id = ? WHERE id = ?", (row[0], track_id))
|
|
else:
|
|
safe_artist_name = re.sub(
|
|
r'[^A-Za-z0-9_.-]+',
|
|
'_',
|
|
aid_artist.strip() or 'unknown'
|
|
)
|
|
new_artist_id = f"artist_local_{safe_artist_name}_{uuid.uuid4().hex[:8]}"
|
|
# Include server_source to match the active media server context
|
|
active_server = 'plex'
|
|
if self._config_manager:
|
|
active_server = self._config_manager.get('active_media_server', 'plex')
|
|
cursor.execute(
|
|
"INSERT INTO artists (id, name, server_source) VALUES (?, ?, ?)",
|
|
(new_artist_id, aid_artist, active_server),
|
|
)
|
|
cursor.execute("UPDATE tracks SET artist_id = ? WHERE id = ?",
|
|
(new_artist_id, track_id))
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'DB update failed: {e}'}
|
|
|
|
# Write corrected tags to the actual audio file
|
|
if file_path:
|
|
resolved = _resolve_file_path(file_path, self.transfer_folder, config_manager=self._config_manager)
|
|
if resolved and os.path.exists(resolved):
|
|
try:
|
|
from core.tag_writer import write_tags_to_file
|
|
tag_updates = {'title': aid_title}
|
|
if aid_artist:
|
|
tag_updates['artist_name'] = aid_artist
|
|
# Issue #587 — derive a per-artist list from
|
|
# AcoustID's credit string when it carries
|
|
# multiple contributors. The post-download
|
|
# enrichment pipeline preserves multi-value
|
|
# ARTISTS tags via the user's
|
|
# `write_multi_artist` setting; the repair
|
|
# path was bypassing that and writing a
|
|
# single-string TPE1 only. Now respects the
|
|
# same setting via the writer's new
|
|
# `artists_list` derivation.
|
|
tag_updates['artists_list'] = _split_acoustid_credit(aid_artist)
|
|
write_tags_to_file(resolved, tag_updates)
|
|
logger.info("Wrote corrected tags to file: %s", resolved)
|
|
except Exception as tag_err:
|
|
logger.warning("Could not write tags to file %s: %s", resolved, tag_err)
|
|
|
|
return {'success': True, 'action': 'retagged',
|
|
'message': f'Updated to: "{aid_title}" by {aid_artist}'}
|
|
|
|
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, config_manager=self._config_manager)
|
|
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 _fix_album_mbid_mismatch(self, entity_type, entity_id, file_path, details):
|
|
"""Rewrite the dissenting track's album MBID to match the consensus.
|
|
|
|
The detector flagged this track because its embedded
|
|
MUSICBRAINZ_ALBUMID disagreed with the consensus across the
|
|
album's other tracks. Fix is to rewrite the dissenter's tag —
|
|
does NOT touch the other tracks (they're already in agreement).
|
|
"""
|
|
consensus_mbid = details.get('consensus_mbid')
|
|
if not consensus_mbid:
|
|
return {'success': False, 'error': 'No consensus MBID in finding details'}
|
|
if not file_path:
|
|
return {'success': False, 'error': 'No file path associated with this finding'}
|
|
|
|
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,
|
|
config_manager=self._config_manager)
|
|
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 _write_album_mbid_to_file
|
|
ok = _write_album_mbid_to_file(resolved, consensus_mbid)
|
|
if ok:
|
|
wrong = (details.get('wrong_mbid') or '')[:8]
|
|
consensus_short = consensus_mbid[:8]
|
|
title = details.get('title', 'track')
|
|
return {
|
|
'success': True,
|
|
'action': 'rewrote_album_mbid',
|
|
'message': (
|
|
f'Updated album MBID on "{title}" '
|
|
f'({wrong}… → {consensus_short}…)'
|
|
),
|
|
}
|
|
return {'success': False, 'error': 'Could not write album MBID — unsupported format or write failed'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'Failed to rewrite album MBID: {str(e)}'}
|
|
|
|
def _fix_album_tag_inconsistency(self, entity_type, entity_id, file_path, details):
|
|
"""Normalize inconsistent tags across all tracks in an album to the canonical (majority) value."""
|
|
inconsistencies = details.get('inconsistencies', [])
|
|
tracks = details.get('tracks', [])
|
|
if not inconsistencies or not tracks:
|
|
return {'success': False, 'error': 'No inconsistency data in finding'}
|
|
|
|
from mutagen import File as MutagenFile
|
|
from core.repair_jobs.album_tag_consistency import _read_tag, _write_tag
|
|
|
|
# Build field → canonical value map
|
|
canonical_map = {inc['field']: inc['canonical'] for inc in inconsistencies}
|
|
|
|
fixed_files = 0
|
|
errors = 0
|
|
changes = []
|
|
|
|
for track_info in tracks:
|
|
track_file = track_info.get('file_path', '')
|
|
if not track_file:
|
|
continue
|
|
|
|
download_folder = None
|
|
if self._config_manager:
|
|
download_folder = self._config_manager.get('soulseek.download_path', '')
|
|
resolved = _resolve_file_path(track_file, self.transfer_folder, download_folder, config_manager=self._config_manager)
|
|
if not resolved or not os.path.exists(resolved):
|
|
continue
|
|
|
|
try:
|
|
audio = MutagenFile(resolved, easy=False)
|
|
if audio is None:
|
|
continue
|
|
|
|
# Apply all field fixes in one open/save cycle
|
|
file_changed = False
|
|
for field, canonical in canonical_map.items():
|
|
current = _read_tag(audio, field)
|
|
if current and current != canonical:
|
|
if _write_tag(audio, field, canonical):
|
|
file_changed = True
|
|
changes.append(f'{field}: "{current}" → "{canonical}" in {os.path.basename(resolved)}')
|
|
|
|
if file_changed:
|
|
audio.save()
|
|
fixed_files += 1
|
|
except Exception as e:
|
|
logger.error(f"Error fixing tag consistency for {resolved}: {e}")
|
|
errors += 1
|
|
|
|
if fixed_files > 0:
|
|
return {
|
|
'success': True,
|
|
'action': 'normalized_tags',
|
|
'message': f'Fixed {fixed_files} file(s): {"; ".join(changes[:3])}{"..." if len(changes) > 3 else ""}',
|
|
}
|
|
elif errors > 0:
|
|
return {'success': False, 'error': f'Failed to fix {errors} file(s)'}
|
|
else:
|
|
return {'success': True, 'action': 'already_consistent', 'message': 'All tags already consistent'}
|
|
|
|
# --- Album Completeness Auto-Fill ---
|
|
|
|
@staticmethod
|
|
def _quality_score(file_path, bitrate):
|
|
"""Return numeric quality score from file extension + bitrate.
|
|
|
|
Lossless formats (FLAC/WAV/ALAC/AIFF) → 9999.
|
|
Lossy → bitrate value (e.g. 320 for MP3-320).
|
|
"""
|
|
ext = os.path.splitext(file_path or '')[1].lstrip('.').upper() if file_path else ''
|
|
if ext in ('FLAC', 'WAV', 'ALAC', 'AIFF', 'AIF'):
|
|
return 9999
|
|
br = bitrate or 0
|
|
try:
|
|
return int(str(br).replace('k', '').replace('K', '').strip())
|
|
except (ValueError, TypeError):
|
|
return 0
|
|
|
|
@staticmethod
|
|
def _detect_filename_pattern(file_paths):
|
|
"""Detect naming convention from existing track filenames.
|
|
|
|
Returns a format string like '{num:02d} - {title}' or '{num} {title}'.
|
|
"""
|
|
patterns_found = {'dash': 0, 'dot': 0, 'space': 0, 'none': 0}
|
|
zero_padded = 0
|
|
total = 0
|
|
|
|
for fp in file_paths:
|
|
if not fp:
|
|
continue
|
|
basename = os.path.splitext(os.path.basename(fp))[0]
|
|
total += 1
|
|
# Check for leading number patterns
|
|
m = re.match(r'^(\d+)\s*[-–—]\s*(.+)', basename)
|
|
if m:
|
|
patterns_found['dash'] += 1
|
|
if m.group(1).startswith('0'):
|
|
zero_padded += 1
|
|
continue
|
|
m = re.match(r'^(\d+)\.\s*(.+)', basename)
|
|
if m:
|
|
patterns_found['dot'] += 1
|
|
if m.group(1).startswith('0'):
|
|
zero_padded += 1
|
|
continue
|
|
m = re.match(r'^(\d+)\s+(.+)', basename)
|
|
if m:
|
|
patterns_found['space'] += 1
|
|
if m.group(1).startswith('0'):
|
|
zero_padded += 1
|
|
continue
|
|
patterns_found['none'] += 1
|
|
|
|
pad = zero_padded > total / 2 if total else True
|
|
num_fmt = '{num:02d}' if pad else '{num}'
|
|
|
|
best = max(patterns_found, key=patterns_found.get)
|
|
if best == 'dash':
|
|
return num_fmt + ' - {title}'
|
|
elif best == 'dot':
|
|
return num_fmt + '. {title}'
|
|
elif best == 'space':
|
|
return num_fmt + ' {title}'
|
|
# Default
|
|
return '{num:02d} - {title}'
|
|
|
|
def _build_unresolvable_album_folder_error(self, attempt, sample_db_path):
|
|
"""Render a diagnostic error string for the Album Completeness
|
|
"couldn't find existing track on disk" failure mode.
|
|
|
|
Pre-fix this returned a flat
|
|
"Could not determine album folder from existing tracks"
|
|
which left users (especially Navidrome / Jellyfin Docker setups
|
|
where the resolver can't auto-discover library mounts) with no
|
|
way to know what to fix. The new message names the active media
|
|
server, shows one sample DB-recorded path, and lists the base
|
|
directories the resolver actually probed.
|
|
|
|
Args:
|
|
attempt: ``ResolveAttempt`` from the last resolver call.
|
|
May be ``None`` if no attempt was recorded (defensive).
|
|
sample_db_path: One example ``tracks.file_path`` value from
|
|
the album. Helps the user see what their media server is
|
|
reporting so they know what to mount / configure.
|
|
"""
|
|
active_server = 'unknown'
|
|
if self._config_manager is not None:
|
|
try:
|
|
getter = getattr(self._config_manager, 'get_active_media_server', None)
|
|
if callable(getter):
|
|
active_server = getter() or 'unknown'
|
|
else:
|
|
active_server = self._config_manager.get('active_media_server', 'unknown') or 'unknown'
|
|
except Exception as e:
|
|
logger.debug("active media server lookup failed: %s", e)
|
|
|
|
lines = [
|
|
"Could not find any existing track from this album on disk.",
|
|
f"Active media server: {active_server}.",
|
|
]
|
|
if sample_db_path:
|
|
lines.append(f"Example DB-recorded path: {sample_db_path}")
|
|
if attempt is not None:
|
|
if attempt.base_dirs_tried:
|
|
joined = ', '.join(attempt.base_dirs_tried)
|
|
lines.append(f"Probed base directories: {joined}")
|
|
else:
|
|
lines.append("No base directories were available to probe.")
|
|
lines.append(
|
|
"Fix: Settings → Library → Music Paths → add the path where "
|
|
"this container can read your library files."
|
|
)
|
|
return ' '.join(lines)
|
|
|
|
def _fix_incomplete_album(self, entity_type, entity_id, file_path, details):
|
|
"""Auto-fill an incomplete album by finding missing tracks in the library.
|
|
|
|
For each missing track:
|
|
1. Search library for matching tracks
|
|
2. Quality gate — candidate must meet album's minimum quality
|
|
3. Single source (1-track album) → MOVE file; multi-track → COPY
|
|
4. Retag the file with correct album metadata
|
|
5. If no candidate found or quality too low → add to wishlist
|
|
"""
|
|
album_id = details.get('album_id')
|
|
missing_tracks = details.get('missing_tracks', [])
|
|
album_title = details.get('album_title', 'Unknown Album')
|
|
artist_name = details.get('artist', 'Unknown Artist')
|
|
spotify_album_id = details.get('spotify_album_id', '')
|
|
|
|
if not album_id:
|
|
return {'success': False, 'error': 'Missing album_id in finding details'}
|
|
|
|
# If missing_tracks list is empty (scanner couldn't identify them), try to fetch now
|
|
if not missing_tracks:
|
|
missing_tracks = self._refetch_missing_tracks(album_id, details)
|
|
if not missing_tracks:
|
|
# Refetch found 0 missing — album is now complete (stale finding)
|
|
return {'success': True, 'action': 'auto_resolve',
|
|
'message': f'Album "{album_title}" is now complete — no missing tracks found'}
|
|
|
|
# Phase 1: Gather context from existing album tracks
|
|
existing_tracks = self.db.get_tracks_by_album(album_id)
|
|
if not existing_tracks:
|
|
return {'success': False, 'error': 'No existing tracks found for this album — cannot determine album folder or quality'}
|
|
|
|
# Compute quality floor from existing tracks
|
|
quality_scores = [self._quality_score(t.file_path, t.bitrate) for t in existing_tracks]
|
|
album_quality_floor = min(quality_scores) if quality_scores else 0
|
|
|
|
# Infer album folder from existing track file paths
|
|
download_folder = None
|
|
if self._config_manager:
|
|
download_folder = self._config_manager.get('soulseek.download_path', '')
|
|
|
|
album_folder = None
|
|
last_attempt = None
|
|
sample_db_path = None
|
|
for t in existing_tracks:
|
|
from core.library.path_resolver import resolve_library_file_path_with_diagnostic
|
|
resolved, attempt = resolve_library_file_path_with_diagnostic(
|
|
t.file_path, transfer_folder=self.transfer_folder,
|
|
download_folder=download_folder, config_manager=self._config_manager,
|
|
)
|
|
last_attempt = attempt
|
|
if sample_db_path is None and isinstance(t.file_path, str) and t.file_path:
|
|
sample_db_path = t.file_path
|
|
if resolved and os.path.exists(resolved):
|
|
album_folder = os.path.dirname(resolved)
|
|
break
|
|
|
|
if not album_folder:
|
|
return {'success': False,
|
|
'error': self._build_unresolvable_album_folder_error(last_attempt, sample_db_path)}
|
|
|
|
# Detect filename pattern
|
|
resolved_paths = []
|
|
for t in existing_tracks:
|
|
rp = _resolve_file_path(t.file_path, self.transfer_folder, download_folder, config_manager=self._config_manager)
|
|
if rp:
|
|
resolved_paths.append(rp)
|
|
filename_pattern = self._detect_filename_pattern(resolved_paths)
|
|
|
|
# Filter out tracks that have been added since the scan (stale finding)
|
|
owned_track_numbers = {t.track_number for t in existing_tracks if t.track_number}
|
|
missing_tracks = [mt for mt in missing_tracks
|
|
if mt.get('track_number') not in owned_track_numbers]
|
|
if not missing_tracks:
|
|
return {'success': True, 'action': 'auto_resolve',
|
|
'message': f'Album "{album_title}" is now complete — all tracks present'}
|
|
|
|
# Phase 2-4: Process each missing track
|
|
fixed_count = 0
|
|
wishlisted_count = 0
|
|
skipped_count = 0
|
|
track_details = []
|
|
existing_track_ids = {t.id for t in existing_tracks}
|
|
|
|
for mt in missing_tracks:
|
|
track_name = mt.get('name', '')
|
|
track_number = mt.get('track_number', 0)
|
|
disc_number = mt.get('disc_number', 1)
|
|
track_artists = mt.get('artists', [])
|
|
source = mt.get('source', '') or 'spotify'
|
|
source_track_id = mt.get('source_track_id', '') or mt.get('track_id', '') or mt.get('spotify_track_id', '')
|
|
spotify_track_id = mt.get('spotify_track_id', '') or (source_track_id if source == 'spotify' else '')
|
|
artist_search = track_artists[0] if track_artists else artist_name
|
|
|
|
if not track_name:
|
|
skipped_count += 1
|
|
track_details.append({'track': track_name, 'status': 'skipped', 'reason': 'no track name'})
|
|
continue
|
|
|
|
if not _album_fill_target_artist_allows_track(artist_name, track_artists):
|
|
skipped_count += 1
|
|
logger.warning(
|
|
"Album auto-fill skipped '%s': source artist(s) %s do not match target album artist '%s'",
|
|
track_name, track_artists, artist_name,
|
|
)
|
|
track_details.append({
|
|
'track': track_name,
|
|
'status': 'skipped',
|
|
'reason': 'source artist does not match target album artist',
|
|
'source_artists': track_artists,
|
|
'target_artist': artist_name,
|
|
})
|
|
continue
|
|
|
|
# Search library for this track
|
|
candidates = self.db.search_tracks(title=track_name, artist=artist_search, limit=20)
|
|
|
|
# Filter: exclude tracks already in target album, require title similarity
|
|
best_candidate = None
|
|
best_score = -1
|
|
|
|
for cand in candidates:
|
|
if cand.id in existing_track_ids:
|
|
continue
|
|
if str(cand.album_id) == str(album_id):
|
|
continue
|
|
|
|
# Fuzzy title match
|
|
title_sim = SequenceMatcher(None, track_name.lower(), cand.title.lower()).ratio()
|
|
if title_sim < 0.70:
|
|
continue
|
|
|
|
# Artist match (more lenient)
|
|
cand_artist = getattr(cand, 'artist_name', '') or ''
|
|
candidate_artist_fields = [
|
|
cand_artist,
|
|
getattr(cand, 'track_artist', '') or '',
|
|
]
|
|
expected_artist_names = track_artists or [artist_name]
|
|
if not any(
|
|
_album_fill_artist_names_match(expected, candidate)
|
|
for expected in expected_artist_names
|
|
for candidate in candidate_artist_fields
|
|
):
|
|
logger.debug(
|
|
"Album auto-fill rejected candidate '%s' by '%s' for expected artist(s) %s",
|
|
getattr(cand, 'title', ''),
|
|
cand_artist,
|
|
expected_artist_names,
|
|
)
|
|
continue
|
|
|
|
# Quality gate
|
|
cand_quality = self._quality_score(cand.file_path, cand.bitrate)
|
|
if cand_quality < album_quality_floor:
|
|
continue
|
|
|
|
# Score: prefer higher quality, then better title match
|
|
score = cand_quality * 1000 + title_sim * 100
|
|
if score > best_score:
|
|
best_score = score
|
|
best_candidate = cand
|
|
|
|
if best_candidate:
|
|
# Phase 3: File operation
|
|
result = self._perform_album_fill(
|
|
best_candidate, album_id, album_title, artist_name,
|
|
track_name, track_number, disc_number,
|
|
album_folder, filename_pattern, download_folder
|
|
)
|
|
if result.get('success'):
|
|
fixed_count += 1
|
|
track_details.append({
|
|
'track': track_name,
|
|
'status': 'fixed',
|
|
'action': result.get('action', ''),
|
|
'message': result.get('message', '')
|
|
})
|
|
# Add the candidate ID to existing so we don't reuse it
|
|
existing_track_ids.add(best_candidate.id)
|
|
continue
|
|
else:
|
|
# File operation failed — fall through to wishlist
|
|
logger.warning("File operation failed for '%s': %s", track_name, result.get('error'))
|
|
|
|
# Phase 4: Wishlist fallback
|
|
if source_track_id:
|
|
try:
|
|
# Build album images from finding thumb URL
|
|
album_images = []
|
|
album_thumb = details.get('album_thumb_url', '')
|
|
if album_thumb:
|
|
album_images = [{'url': album_thumb, 'height': 300, 'width': 300}]
|
|
|
|
wishlist_data = {
|
|
'id': source_track_id,
|
|
'name': track_name,
|
|
'artists': [{'name': a} for a in track_artists] if track_artists else [{'name': artist_name}],
|
|
'album': {
|
|
'name': album_title,
|
|
'id': spotify_album_id or details.get('itunes_album_id', '') or details.get('deezer_album_id', ''),
|
|
'images': album_images,
|
|
'release_date': '',
|
|
'album_type': 'album',
|
|
'total_tracks': details.get('expected_tracks', 0),
|
|
},
|
|
'duration_ms': mt.get('duration_ms', 0),
|
|
'track_number': track_number,
|
|
'disc_number': disc_number,
|
|
'uri': f"{source}:track:{source_track_id}" if source and source_track_id else '',
|
|
}
|
|
source_info = {
|
|
'album_title': album_title,
|
|
'artist': artist_name,
|
|
'track_number': track_number,
|
|
'disc_number': disc_number,
|
|
'spotify_album_id': spotify_album_id,
|
|
'source': source,
|
|
'source_track_id': source_track_id,
|
|
'is_album': True,
|
|
'reason': 'album_completeness_auto_fill',
|
|
}
|
|
self.db.add_to_wishlist(
|
|
wishlist_data,
|
|
failure_reason='Missing from incomplete album',
|
|
source_type='album',
|
|
source_info=source_info,
|
|
)
|
|
wishlisted_count += 1
|
|
track_details.append({
|
|
'track': track_name,
|
|
'status': 'wishlisted',
|
|
'reason': 'no suitable candidate in library' if not best_candidate else 'quality too low'
|
|
})
|
|
except Exception as e:
|
|
logger.debug("Failed to add '%s' to wishlist: %s", track_name, e)
|
|
skipped_count += 1
|
|
track_details.append({'track': track_name, 'status': 'skipped', 'reason': f'wishlist error: {e}'})
|
|
else:
|
|
skipped_count += 1
|
|
track_details.append({'track': track_name, 'status': 'skipped', 'reason': 'no source_track_id for wishlist'})
|
|
|
|
# Build result message
|
|
parts = []
|
|
if fixed_count:
|
|
parts.append(f'{fixed_count} track(s) filled')
|
|
if wishlisted_count:
|
|
parts.append(f'{wishlisted_count} added to wishlist')
|
|
if skipped_count:
|
|
parts.append(f'{skipped_count} skipped')
|
|
message = f'Album "{album_title}": ' + ', '.join(parts) if parts else 'No tracks processed'
|
|
|
|
success = fixed_count > 0 or wishlisted_count > 0
|
|
return {
|
|
'success': success,
|
|
'action': 'auto_fill_album',
|
|
'message': message,
|
|
'fixed': fixed_count,
|
|
'wishlisted': wishlisted_count,
|
|
'skipped': skipped_count,
|
|
'details': track_details,
|
|
}
|
|
|
|
def _refetch_missing_tracks(self, album_id, details):
|
|
"""Re-fetch missing track list from APIs when the stored list is empty."""
|
|
configured_primary_source = get_primary_source()
|
|
spotify_album_id = details.get('spotify_album_id', '')
|
|
itunes_album_id = details.get('itunes_album_id', '')
|
|
deezer_album_id = details.get('deezer_album_id', '')
|
|
discogs_album_id = details.get('discogs_album_id', '')
|
|
hydrabase_album_id = details.get('hydrabase_album_id', '')
|
|
primary_source = details.get('primary_source') or configured_primary_source
|
|
logger.debug(
|
|
"Refetch missing tracks for album %s: primary=%s spotify=%s itunes=%s deezer=%s discogs=%s hydrabase=%s",
|
|
album_id, primary_source, spotify_album_id, itunes_album_id, deezer_album_id, discogs_album_id,
|
|
hydrabase_album_id
|
|
)
|
|
|
|
# Get track numbers we already own
|
|
owned_numbers = set()
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT track_number FROM tracks WHERE album_id = ? AND track_number IS NOT NULL",
|
|
(album_id,)
|
|
)
|
|
for row in cursor.fetchall():
|
|
owned_numbers.add(row[0])
|
|
except Exception:
|
|
return []
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
current_source = primary_source
|
|
api_tracks = None
|
|
album_sources = {
|
|
'spotify': spotify_album_id,
|
|
'itunes': itunes_album_id,
|
|
'deezer': deezer_album_id,
|
|
'discogs': discogs_album_id,
|
|
'hydrabase': hydrabase_album_id,
|
|
}
|
|
|
|
for source in get_source_priority(primary_source):
|
|
fid = album_sources.get(source, '')
|
|
if not fid:
|
|
continue
|
|
try:
|
|
api_tracks = get_album_tracks_for_source(source, fid)
|
|
if api_tracks and 'items' in (api_tracks or {}):
|
|
current_source = source
|
|
break
|
|
except Exception as e:
|
|
logger.debug("Refetch: %s album tracks failed for %s: %s", source, fid, e)
|
|
|
|
if not api_tracks or 'items' not in api_tracks:
|
|
return []
|
|
|
|
missing = []
|
|
for item in api_tracks['items']:
|
|
tn = item.get('track_number')
|
|
if tn and tn not in owned_numbers:
|
|
track_artists = []
|
|
for a in item.get('artists', []):
|
|
if isinstance(a, dict):
|
|
track_artists.append(a.get('name', ''))
|
|
elif isinstance(a, str):
|
|
track_artists.append(a)
|
|
missing.append({
|
|
'track_number': tn,
|
|
'name': item.get('name', ''),
|
|
'disc_number': item.get('disc_number', 1),
|
|
'source': current_source or 'spotify',
|
|
'source_track_id': item.get('id', ''),
|
|
'track_id': item.get('id', ''),
|
|
'spotify_track_id': item.get('id', ''),
|
|
'duration_ms': item.get('duration_ms', 0),
|
|
'artists': track_artists,
|
|
})
|
|
return missing
|
|
|
|
def _perform_album_fill(self, candidate, album_id, album_title, artist_name,
|
|
track_name, track_number, disc_number,
|
|
album_folder, filename_pattern, download_folder):
|
|
"""Move or copy a candidate track into the album folder and update DB."""
|
|
try:
|
|
def _fallback_server_source():
|
|
if getattr(candidate, 'server_source', None):
|
|
return candidate.server_source
|
|
if self._config_manager:
|
|
getter = getattr(self._config_manager, 'get_active_media_server', None)
|
|
if callable(getter):
|
|
return getter() or 'plex'
|
|
return self._config_manager.get('active_media_server', 'plex')
|
|
return 'plex'
|
|
|
|
def _resolve_target_context(cursor):
|
|
cursor.execute(
|
|
"""
|
|
SELECT artist_id, server_source
|
|
FROM tracks
|
|
WHERE album_id = ?
|
|
ORDER BY track_number, title
|
|
LIMIT 1
|
|
""",
|
|
(album_id,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if row:
|
|
return row[0] or candidate.artist_id, row[1] or _fallback_server_source()
|
|
|
|
try:
|
|
cursor.execute(
|
|
"SELECT artist_id, server_source FROM albums WHERE id = ? LIMIT 1",
|
|
(album_id,),
|
|
)
|
|
except sqlite3.OperationalError:
|
|
row = None
|
|
else:
|
|
row = cursor.fetchone()
|
|
|
|
if row:
|
|
return row[0] or candidate.artist_id, row[1] or _fallback_server_source()
|
|
|
|
return candidate.artist_id, _fallback_server_source()
|
|
|
|
# Resolve source file
|
|
src_path = _resolve_file_path(candidate.file_path, self.transfer_folder, download_folder, config_manager=self._config_manager)
|
|
if not src_path or not os.path.exists(src_path):
|
|
return {'success': False, 'error': f'Source file not found: {candidate.file_path}'}
|
|
|
|
# Determine source type: single (1-track album) vs multi-track
|
|
source_album_tracks = self.db.get_tracks_by_album(candidate.album_id)
|
|
is_single_source = len(source_album_tracks) <= 1
|
|
|
|
# Build target filename
|
|
src_ext = os.path.splitext(src_path)[1] # e.g. '.flac'
|
|
# Sanitize title for filesystem
|
|
safe_title = re.sub(r'[<>:"/\\|?*]', '', track_name).strip()
|
|
target_name = filename_pattern.format(num=track_number, title=safe_title) + src_ext
|
|
target_path = os.path.join(album_folder, target_name)
|
|
|
|
# Avoid overwriting existing files
|
|
if os.path.exists(target_path):
|
|
return {'success': False, 'error': f'Target file already exists: {target_path}'}
|
|
|
|
# Ensure album folder exists
|
|
os.makedirs(album_folder, exist_ok=True)
|
|
|
|
conn = None
|
|
try:
|
|
if is_single_source:
|
|
# MOVE: relocate file and update DB record
|
|
shutil.move(src_path, target_path)
|
|
action = 'moved'
|
|
|
|
# Update existing DB record to point to new album and path
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
target_artist_id, target_server_source = _resolve_target_context(cursor)
|
|
cursor.execute("""
|
|
UPDATE tracks
|
|
SET album_id = ?, artist_id = ?, title = ?,
|
|
file_path = ?, track_number = ?, server_source = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""", (album_id, target_artist_id, track_name,
|
|
target_path, track_number, target_server_source, candidate.id))
|
|
|
|
# Clean up the source single's album if it's now empty
|
|
cursor.execute("SELECT COUNT(*) FROM tracks WHERE album_id = ?", (candidate.album_id,))
|
|
remaining = cursor.fetchone()[0]
|
|
if remaining == 0:
|
|
cursor.execute("DELETE FROM albums WHERE id = ?", (candidate.album_id,))
|
|
|
|
conn.commit()
|
|
|
|
# Clean up empty source directories
|
|
self._cleanup_empty_dirs(os.path.dirname(src_path))
|
|
else:
|
|
# COPY: duplicate file, create new DB record
|
|
shutil.copy2(src_path, target_path)
|
|
action = 'copied'
|
|
source_track_id = re.sub(
|
|
r'[^A-Za-z0-9_.-]+',
|
|
'_',
|
|
str(getattr(candidate, 'id', 'unknown'))
|
|
)
|
|
new_track_id = f"album_fill_{source_track_id}_{uuid.uuid4().hex[:8]}"
|
|
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
target_artist_id, target_server_source = _resolve_target_context(cursor)
|
|
|
|
cursor.execute("""
|
|
INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration,
|
|
file_path, bitrate, server_source, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
""", (new_track_id, album_id, target_artist_id, track_name, track_number,
|
|
candidate.duration, target_path, candidate.bitrate, target_server_source))
|
|
conn.commit()
|
|
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
# Enhance the file with full metadata pipeline (same as fresh downloads)
|
|
# Clears existing tags, writes standard + source IDs, embeds cover art
|
|
self._enhance_placed_track(
|
|
target_path, album_id, album_title, artist_name,
|
|
track_name, track_number, disc_number
|
|
)
|
|
|
|
return {
|
|
'success': True,
|
|
'action': action,
|
|
'message': f'{action.title()} "{track_name}" from {"single" if is_single_source else "compilation"}'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Error filling track '%s': %s", track_name, e, exc_info=True)
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def _cleanup_empty_dirs(self, directory):
|
|
"""Remove empty parent directories up to 3 levels, never removing transfer folder."""
|
|
if not directory:
|
|
return
|
|
transfer_norm = os.path.normpath(self.transfer_folder)
|
|
parent = directory
|
|
for _ in range(3):
|
|
if (parent and os.path.isdir(parent)
|
|
and os.path.normpath(parent) != transfer_norm
|
|
and not os.listdir(parent)):
|
|
try:
|
|
os.rmdir(parent)
|
|
except OSError:
|
|
break
|
|
parent = os.path.dirname(parent)
|
|
else:
|
|
break
|
|
|
|
def _enhance_placed_track(self, file_path, album_id, album_title, artist_name,
|
|
track_name, track_number, disc_number):
|
|
"""Run full metadata enhancement on a placed track.
|
|
|
|
Uses the injected _enhance_file_metadata from web_server.py (same pipeline
|
|
as fresh downloads) — clears tags, writes standard metadata, embeds source
|
|
IDs from MusicBrainz/Deezer/etc., and embeds cover art.
|
|
|
|
Falls back to basic tag_writer if the enhancer isn't available.
|
|
"""
|
|
# Fetch album metadata from DB for building synthetic context
|
|
album_year = None
|
|
album_genres = []
|
|
album_thumb = None
|
|
album_track_count = None
|
|
spotify_album_id = None
|
|
conn_meta = None
|
|
try:
|
|
conn_meta = self.db._get_connection()
|
|
cursor_meta = conn_meta.cursor()
|
|
cursor_meta.execute(
|
|
"SELECT year, genres, thumb_url, track_count, spotify_album_id FROM albums WHERE id = ?",
|
|
(album_id,)
|
|
)
|
|
album_row = cursor_meta.fetchone()
|
|
if album_row:
|
|
album_year = album_row[0]
|
|
if album_row[1]:
|
|
try:
|
|
parsed = json.loads(album_row[1])
|
|
if isinstance(parsed, list):
|
|
album_genres = parsed
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
album_thumb = album_row[2]
|
|
album_track_count = album_row[3]
|
|
spotify_album_id = album_row[4] if len(album_row) > 4 else None
|
|
except Exception as e:
|
|
logger.debug("Failed to load album metadata for retag: %s", e)
|
|
finally:
|
|
if conn_meta:
|
|
conn_meta.close()
|
|
|
|
# Try full enhancement pipeline if available AND enabled in config
|
|
# _enhance_file_metadata returns True without writing when enhancement is disabled,
|
|
# so we must check the config ourselves to avoid skipping the basic fallback
|
|
enhancement_enabled = (
|
|
self._enhance_file_metadata is not None
|
|
and self._config_manager
|
|
and self._config_manager.get('metadata_enhancement.enabled', True)
|
|
)
|
|
if enhancement_enabled:
|
|
try:
|
|
# Build synthetic context dicts (same pattern as _execute_retag in web_server.py)
|
|
context = {
|
|
'original_search_result': {
|
|
'spotify_clean_title': track_name,
|
|
'title': track_name,
|
|
'disc_number': disc_number,
|
|
'artists': [{'name': artist_name}],
|
|
},
|
|
'spotify_album': {
|
|
'id': spotify_album_id or '',
|
|
'name': album_title,
|
|
'release_date': str(album_year) if album_year else '',
|
|
'total_tracks': album_track_count or 1,
|
|
'image_url': album_thumb or '',
|
|
},
|
|
'track_info': {
|
|
'id': '', # No specific track ID available
|
|
},
|
|
}
|
|
artist = {
|
|
'name': artist_name,
|
|
'id': '',
|
|
'genres': album_genres[:2] if album_genres else [],
|
|
}
|
|
album_info = {
|
|
'is_album': True,
|
|
'album_name': album_title,
|
|
'track_number': track_number,
|
|
'total_tracks': album_track_count or 1,
|
|
'disc_number': disc_number,
|
|
'clean_track_name': track_name,
|
|
'album_image_url': album_thumb or '',
|
|
}
|
|
|
|
result = self._enhance_file_metadata(file_path, context, artist, album_info)
|
|
if result:
|
|
logger.info("Full metadata enhancement applied to '%s'", track_name)
|
|
return
|
|
else:
|
|
logger.warning("Full enhancement returned False for '%s', falling back to basic tags", track_name)
|
|
except Exception as e:
|
|
logger.warning("Full enhancement failed for '%s': %s — falling back to basic tags", track_name, e)
|
|
|
|
# Fallback: basic tag writer (title, artist, album, track#, disc#, year, genre, cover art)
|
|
# Used when: enhancer not injected, metadata enhancement disabled, or enhancer failed
|
|
try:
|
|
from core.tag_writer import write_tags_to_file
|
|
tag_data = {
|
|
'title': track_name,
|
|
'artist': artist_name,
|
|
'album_artist': artist_name,
|
|
'album': album_title,
|
|
'track_number': track_number,
|
|
'disc_number': disc_number,
|
|
}
|
|
if album_year:
|
|
tag_data['year'] = album_year
|
|
if album_genres:
|
|
tag_data['genre'] = ', '.join(album_genres[:5])
|
|
if album_track_count:
|
|
tag_data['total_tracks'] = album_track_count
|
|
|
|
write_tags_to_file(file_path, tag_data,
|
|
embed_cover=bool(album_thumb),
|
|
cover_url=album_thumb)
|
|
logger.info("Basic tag enhancement applied to '%s'", track_name)
|
|
except Exception as e:
|
|
logger.warning("Retagging failed for '%s' (file still placed): %s", file_path, e)
|
|
|
|
def _fix_path_mismatch(self, entity_type, entity_id, file_path, details):
|
|
"""Move a file from its current location to the expected template path."""
|
|
rel_from = details.get('from', '')
|
|
rel_to = details.get('to', '')
|
|
if not rel_from or not rel_to:
|
|
logger.warning("Path mismatch fix: missing from/to in details")
|
|
return {'success': False, 'error': 'Missing from/to paths in finding details'}
|
|
|
|
transfer = self.transfer_folder
|
|
src = os.path.normpath(os.path.join(transfer, rel_from))
|
|
dst = os.path.normpath(os.path.join(transfer, rel_to))
|
|
|
|
logger.info("Path mismatch fix: src=%s dst=%s transfer=%s", src, dst, transfer)
|
|
|
|
# Safety: both paths must be inside transfer folder
|
|
transfer_norm = os.path.normpath(transfer)
|
|
if not src.startswith(transfer_norm + os.sep) or not dst.startswith(transfer_norm + os.sep):
|
|
logger.warning("Path mismatch fix: path escapes transfer folder. src=%s, dst=%s, transfer=%s", src, dst, transfer_norm)
|
|
return {'success': False, 'error': 'Path escapes transfer folder'}
|
|
|
|
if not os.path.isfile(src):
|
|
# Source may have been moved already — check if destination already exists
|
|
if os.path.isfile(dst):
|
|
return {'success': True, 'action': 'already_moved', 'message': 'File already at expected location'}
|
|
logger.warning("Path mismatch fix: source file not found: %s", src)
|
|
return {'success': False, 'error': f'Source file not found: {rel_from}'}
|
|
|
|
if os.path.exists(dst) and not os.path.samefile(src, dst):
|
|
logger.warning("Path mismatch fix: destination already exists (different file): %s", dst)
|
|
return {'success': False, 'error': 'Destination already exists (different file)'}
|
|
|
|
try:
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
|
|
# Case rename on case-insensitive FS
|
|
if sys.platform in ('win32', 'darwin') and os.path.exists(dst):
|
|
tmp = dst + '.tmp_rename'
|
|
shutil.move(src, tmp)
|
|
shutil.move(tmp, dst)
|
|
else:
|
|
shutil.move(src, dst)
|
|
|
|
# Move sidecar files (.lrc, cover art, etc.)
|
|
src_dir = os.path.dirname(src)
|
|
dst_dir = os.path.dirname(dst)
|
|
src_stem = os.path.splitext(os.path.basename(src))[0]
|
|
dst_stem = os.path.splitext(os.path.basename(dst))[0]
|
|
sidecar_exts = {'.lrc', '.jpg', '.jpeg', '.png', '.nfo', '.txt', '.cue'}
|
|
for ext in sidecar_exts:
|
|
sidecar_src = os.path.join(src_dir, src_stem + ext)
|
|
if os.path.isfile(sidecar_src):
|
|
sidecar_dst = os.path.join(dst_dir, dst_stem + ext)
|
|
if not os.path.exists(sidecar_dst):
|
|
try:
|
|
shutil.move(sidecar_src, sidecar_dst)
|
|
except Exception as e:
|
|
logger.debug("Failed to move sidecar %s: %s", sidecar_src, e)
|
|
|
|
# Update DB file path
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
# Try exact match
|
|
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?", (dst, src))
|
|
if cursor.rowcount == 0:
|
|
cursor.execute("UPDATE tracks SET file_path = ? WHERE file_path = ?",
|
|
(dst, os.path.normpath(src)))
|
|
if cursor.rowcount == 0:
|
|
# Suffix match for cross-environment paths (Docker vs host)
|
|
try:
|
|
rel_suffix = os.path.relpath(src, transfer).replace('\\', '/')
|
|
escaped = rel_suffix.replace('^', '^^').replace('%', '^%').replace('_', '^_')
|
|
cursor.execute(
|
|
"UPDATE tracks SET file_path = ? WHERE file_path LIKE ? ESCAPE '^'",
|
|
(dst, '%/' + escaped))
|
|
except Exception as e:
|
|
logger.debug("Suffix-match DB path update failed: %s", e)
|
|
conn.commit()
|
|
except Exception as e:
|
|
logger.debug("DB path update failed for %s: %s", src, e)
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
# Clean up empty source directories
|
|
parent = os.path.dirname(src)
|
|
for _ in range(5):
|
|
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': 'moved_file',
|
|
'message': f'Moved to {rel_to}'}
|
|
except Exception as e:
|
|
logger.error("Failed to move %s -> %s: %s", src, dst, e)
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def _fix_missing_lossy_copy(self, entity_type, entity_id, file_path, details):
|
|
"""Convert a FLAC file to the configured lossy codec using ffmpeg.
|
|
|
|
Always reads codec/bitrate from current settings (not finding details)
|
|
so the user can change their preference after scanning.
|
|
"""
|
|
if not file_path:
|
|
return {'success': False, 'error': 'No file path associated with this finding'}
|
|
|
|
# Read fresh from current settings — not from finding details
|
|
codec = 'mp3'
|
|
bitrate = '320'
|
|
if self._config_manager:
|
|
codec = self._config_manager.get('lossy_copy.codec', 'mp3').lower()
|
|
bitrate = self._config_manager.get('lossy_copy.bitrate', '320')
|
|
# Opus max per-channel bitrate is 256kbps — cap to avoid encoding failures
|
|
if codec == 'opus' and int(bitrate) > 256:
|
|
bitrate = '256'
|
|
quality_label = f'{codec.upper()}-{bitrate}'
|
|
|
|
codec_configs = {
|
|
'mp3': ('libmp3lame', '.mp3', ['-id3v2_version', '3']),
|
|
'opus': ('libopus', '.opus', ['-map', '0:a', '-vbr', 'on']),
|
|
'aac': ('aac', '.m4a', ['-movflags', '+faststart']),
|
|
}
|
|
|
|
if codec not in codec_configs:
|
|
return {'success': False, 'error': f'Unknown codec: {codec}'}
|
|
|
|
ffmpeg_codec, out_ext, extra_args = codec_configs[codec]
|
|
|
|
# Find ffmpeg
|
|
import shutil
|
|
ffmpeg_bin = shutil.which('ffmpeg')
|
|
if not ffmpeg_bin:
|
|
local = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'tools', 'ffmpeg')
|
|
if os.path.isfile(local):
|
|
ffmpeg_bin = local
|
|
else:
|
|
return {'success': False, 'error': 'ffmpeg not found'}
|
|
|
|
# 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, config_manager=self._config_manager) or file_path
|
|
|
|
if not os.path.exists(resolved):
|
|
return {'success': False, 'error': f'Source file not found: {file_path}'}
|
|
|
|
out_path = os.path.splitext(resolved)[0] + out_ext
|
|
if os.path.exists(out_path):
|
|
return {'success': True, 'action': 'already_exists',
|
|
'message': f'{quality_label} copy already exists'}
|
|
|
|
import subprocess
|
|
try:
|
|
cmd = [
|
|
ffmpeg_bin, '-i', resolved,
|
|
'-codec:a', ffmpeg_codec,
|
|
'-b:a', f'{bitrate}k',
|
|
'-map_metadata', '0',
|
|
] + extra_args + ['-y', out_path]
|
|
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
|
|
if proc.returncode != 0 or not os.path.isfile(out_path) or os.path.getsize(out_path) == 0:
|
|
if os.path.exists(out_path):
|
|
try:
|
|
os.remove(out_path)
|
|
except Exception as e:
|
|
logger.debug("Failed to remove out_path after ffmpeg failure: %s", e)
|
|
return {'success': False, 'error': f'ffmpeg conversion failed: {proc.stderr[:200] if proc.stderr else "unknown error"}'}
|
|
|
|
# Update QUALITY tag
|
|
try:
|
|
from mutagen import File as MutagenFile
|
|
audio = MutagenFile(out_path)
|
|
if audio is not None:
|
|
if codec == 'mp3':
|
|
from mutagen.id3 import TXXX
|
|
audio.tags.add(TXXX(encoding=3, desc='QUALITY', text=[quality_label]))
|
|
elif codec == 'opus':
|
|
audio['QUALITY'] = [quality_label]
|
|
elif codec == 'aac':
|
|
from mutagen.mp4 import MP4FreeForm
|
|
audio['----:com.apple.iTunes:QUALITY'] = [MP4FreeForm(quality_label.encode('utf-8'))]
|
|
audio.save()
|
|
except Exception as e:
|
|
logger.debug("Failed to write QUALITY tag on lossy copy: %s", e)
|
|
|
|
# Embed cover art from source FLAC
|
|
if codec in ('opus', 'aac'):
|
|
try:
|
|
from mutagen import File as MutagenFile
|
|
from mutagen.flac import FLAC as MutagenFLAC
|
|
source_audio = MutagenFLAC(resolved)
|
|
if source_audio and source_audio.pictures:
|
|
pic = source_audio.pictures[0]
|
|
dest_audio = MutagenFile(out_path)
|
|
if dest_audio is not None:
|
|
if codec == 'opus':
|
|
import base64, struct
|
|
from mutagen.oggopus import OggOpus
|
|
if isinstance(dest_audio, OggOpus):
|
|
picture_data = (
|
|
struct.pack('>II', pic.type, len(pic.mime.encode('utf-8')))
|
|
+ pic.mime.encode('utf-8')
|
|
+ struct.pack('>I', len(pic.desc.encode('utf-8')))
|
|
+ pic.desc.encode('utf-8')
|
|
+ struct.pack('>IIII', pic.width, pic.height, pic.depth, pic.colors)
|
|
+ struct.pack('>I', len(pic.data))
|
|
+ pic.data
|
|
)
|
|
dest_audio['METADATA_BLOCK_PICTURE'] = [base64.b64encode(picture_data).decode('ascii')]
|
|
dest_audio.save()
|
|
elif codec == 'aac':
|
|
from mutagen.mp4 import MP4Cover
|
|
fmt = MP4Cover.FORMAT_JPEG if 'jpeg' in pic.mime else MP4Cover.FORMAT_PNG
|
|
dest_audio['covr'] = [MP4Cover(pic.data, imageformat=fmt)]
|
|
dest_audio.save()
|
|
except Exception as e:
|
|
logger.debug("Failed to embed cover art in lossy copy: %s", e)
|
|
|
|
# Blasphemy Mode — uses the job's own setting, not the global lossy_copy one
|
|
delete_original = False
|
|
if self._config_manager:
|
|
job_settings = self._config_manager.get('repair.jobs.lossy_converter.settings', {})
|
|
if isinstance(job_settings, dict):
|
|
delete_original = job_settings.get('delete_original', False)
|
|
|
|
if delete_original:
|
|
try:
|
|
from mutagen import File as MutagenFile
|
|
test = MutagenFile(out_path)
|
|
if test is not None:
|
|
os.remove(resolved)
|
|
# Update DB path using original DB format
|
|
new_db_path = os.path.splitext(file_path)[0] + out_ext
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"UPDATE tracks SET file_path = ? WHERE id = ?",
|
|
(new_db_path, entity_id)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception as e:
|
|
logger.debug("Failed to update DB path after lossy conversion: %s", e)
|
|
return {'success': True, 'action': 'converted_and_deleted',
|
|
'message': f'Converted to {quality_label} and deleted original'}
|
|
except Exception as e:
|
|
logger.debug("Blasphemy mode error: %s", e)
|
|
|
|
return {'success': True, 'action': 'converted',
|
|
'message': f'Created {quality_label} copy'}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
if os.path.exists(out_path):
|
|
try:
|
|
os.remove(out_path)
|
|
except Exception as e:
|
|
logger.debug("Failed to remove out_path after timeout: %s", e)
|
|
return {'success': False, 'error': 'Conversion timed out (120s)'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': f'Conversion error: {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_fix_findings(self, job_id: str = None, severity: str = None,
|
|
finding_ids: List[int] = None, fix_action: str = None) -> dict:
|
|
"""Fix all pending fixable findings matching filters. Returns {fixed, failed, skipped}.
|
|
|
|
Args:
|
|
fix_action: Optional action for findings that need user choice (e.g. orphan files)
|
|
"""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# Build query for pending fixable findings
|
|
fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch',
|
|
'missing_cover_art', 'missing_lyrics', 'expired_download', 'metadata_gap', 'duplicate_tracks',
|
|
'single_album_redundant', 'mbid_mismatch',
|
|
'album_mbid_mismatch',
|
|
'album_tag_inconsistency',
|
|
'incomplete_album', 'path_mismatch',
|
|
'missing_lossy_copy', 'missing_replaygain', 'empty_folder',
|
|
'missing_discography_track', 'acoustid_mismatch',
|
|
'quality_upgrade', 'short_preview_track')
|
|
placeholders = ','.join(['?'] * len(fixable_types))
|
|
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
|
|
params = list(fixable_types)
|
|
|
|
if finding_ids:
|
|
id_placeholders = ','.join(['?'] * len(finding_ids))
|
|
where_parts.append(f"id IN ({id_placeholders})")
|
|
params.extend(finding_ids)
|
|
if job_id:
|
|
where_parts.append("job_id = ?")
|
|
params.append(job_id)
|
|
if severity:
|
|
where_parts.append("severity = ?")
|
|
params.append(severity)
|
|
|
|
where = f"WHERE {' AND '.join(where_parts)}"
|
|
cursor.execute(f"SELECT id FROM repair_findings {where}", params)
|
|
ids_to_fix = [row[0] for row in cursor.fetchall()]
|
|
conn.close()
|
|
conn = None
|
|
|
|
fixed = 0
|
|
failed = 0
|
|
errors = []
|
|
for fid in ids_to_fix:
|
|
result = self.fix_finding(fid, fix_action=fix_action)
|
|
if result.get('success'):
|
|
fixed += 1
|
|
else:
|
|
error_msg = result.get('error', 'unknown error')
|
|
logger.warning("Fix failed for finding #%s: %s", fid, error_msg)
|
|
errors.append({'id': fid, 'error': error_msg})
|
|
failed += 1
|
|
|
|
return {'fixed': fixed, 'failed': failed, 'total': len(ids_to_fix), 'errors': errors}
|
|
except Exception as e:
|
|
logger.error("Error bulk fixing findings: %s", e, exc_info=True)
|
|
return {'fixed': 0, 'failed': 0, 'total': 0, 'error': str(e)}
|
|
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 clear_findings(self, job_id: str = None, status: str = None) -> int:
|
|
"""Delete findings from the database. Optionally filter by job_id and/or status. Returns count deleted."""
|
|
conn = None
|
|
try:
|
|
conn = self.db._get_connection()
|
|
cursor = conn.cursor()
|
|
conditions = []
|
|
params = []
|
|
if job_id:
|
|
conditions.append("job_id = ?")
|
|
params.append(job_id)
|
|
if status:
|
|
conditions.append("status = ?")
|
|
params.append(status)
|
|
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
cursor.execute(f"SELECT COUNT(*) FROM repair_findings{where}", params)
|
|
count = cursor.fetchone()[0]
|
|
cursor.execute(f"DELETE FROM repair_findings{where}", params)
|
|
conn.commit()
|
|
logger.info("Cleared %d findings%s%s", count,
|
|
f" for job {job_id}" if job_id else "",
|
|
f" with status {status}" if status else "")
|
|
return count
|
|
except Exception as e:
|
|
logger.error("Error clearing 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'
|