soulsync/core/repair_jobs/quality_upgrade_scanner.py
dev 0e14ff03ee fix(quality-scan): walk music folders on disk instead of resolving DB paths
The quality job kept resolving 0/N because DB-path resolution failed in the
deployed environment for reasons the logs wouldn't surface. Switch to the
mechanism the WORKING file tools use: os.walk the real music folders
(transfer + download + configured library paths, abspath'd) exactly like
orphan_file_detector and fake_lossless_detector — those reliably see files
because they never touch the DB's stored relative paths.

- Walk all existing music dirs, collect audio files (dedup by realpath),
  probe each with the same probe_audio_quality the import guard uses, check
  quality_meets_profile (strict). Below-profile files become findings.
- Match each walked file back to its DB track via a path-suffix index (last
  1-3 components) for real title/artist/album + track id; fall back to the
  file's own tags when no DB row matches (finding filed as 'file').
- Loud diagnostics: logs the folders walked and the audio-file count, and
  warns clearly when no music folder exists to walk.

The fix handler already works with the now-absolute file_path and an optional
entity_id (deletes the file by real path; DB row only when known).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:39:35 +02:00

305 lines
14 KiB
Python

"""Quality Upgrade Scanner Job — flags library tracks below the user's profile.
Walks the music folders ON DISK (transfer + download + every configured library
path) exactly like the Orphan / Fake-Lossless detectors — those reliably "see"
files because they os.walk real directories instead of trying to resolve the
DB's stored (often relative) paths. For each audio file it probes the ACTUAL
measured audio quality (bit depth / sample rate / bitrate via the same
`probe_audio_quality` the download import guard uses) and checks it against the
user's v3 ranked targets with `quality_meets_profile` (strict — fallback
ignored, that's a download-time concession, not a definition of "good enough").
Every file that satisfies none of the targets becomes a finding the user can:
- 'redownload': add the track to the wishlist and delete the low-quality file
- 'delete': remove the low-quality file (+ DB row when known)
- 'ignore': dismiss the finding (handled in the UI via the dismiss endpoint)
Each walked file is matched back to its DB track (by path suffix) so the finding
carries the real title/artist/album + track id; when no DB row matches, the
file's own tags are used and the finding is filed as a loose 'file'.
"""
import os
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger
logger = get_logger("repair_job.quality_upgrade")
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
@register_job
class QualityUpgradeScannerJob(RepairJob):
job_id = 'quality_upgrade_scanner'
display_name = 'Quality Upgrade Scanner'
description = 'Flags library tracks below your quality profile'
help_text = (
'Scans your music folders on disk and checks every track\'s REAL audio '
'quality (bit depth, sample rate, bitrate — read from the file itself, '
'not just the extension) against your configured quality profile. This is '
'the same check the download pipeline runs, so a track flagged here is one '
'the downloader would also reject.\n\n'
'Each below-profile track is reported as a finding. You can:\n'
'• Re-download — add the track to your wishlist and remove the low-quality file\n'
'• Delete — remove the low-quality file\n'
'• Ignore — dismiss the finding and keep the file\n\n'
'The scan only reports — it never deletes or re-downloads on its own. '
'Profile targets and fallback come straight from Settings → Quality.'
)
icon = 'repair-icon-lossless'
default_enabled = False
default_interval_hours = 168
default_settings = {}
auto_fix = False # User chooses fix action per finding
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
# Load the user's v3 ranked targets — the SAME definition the download
# import guard uses. Strict: a track is below-profile when its measured
# quality satisfies NONE of the targets (fallback is not consulted).
from core.quality.selection import targets_from_profile, quality_meets_profile
try:
profile = context.db.get_quality_profile()
except Exception as e:
logger.warning("Could not load quality profile: %s", e)
return result
targets, _fallback = targets_from_profile(profile)
if not targets:
logger.info("Quality profile has no targets — nothing to check against")
return result
logger.info("Quality upgrade scan — profile targets (strict): %s",
[t.label for t in targets])
from core.imports.file_ops import probe_audio_quality
# --- Collect the music folders to walk (real dirs, abspath'd) ---
base_dirs = self._collect_music_dirs(context)
if not base_dirs:
logger.warning(
"[QualityScan] No existing music folder to walk (transfer=%r, cwd=%r). "
"Set soulseek.transfer_path to the real mount or add your library under "
"Settings → Library → Music Paths.",
context.transfer_folder, os.getcwd())
return result
logger.info("[QualityScan] Walking %d folder(s): %r", len(base_dirs), base_dirs)
# --- Gather audio files (dedup by real path) ---
audio_files = []
seen = set()
for base in base_dirs:
for root, _dirs, files in os.walk(base):
if context.check_stop():
return result
for fname in files:
if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS:
fpath = os.path.join(root, fname)
rp = os.path.realpath(fpath)
if rp in seen:
continue
seen.add(rp)
audio_files.append(fpath)
total = len(audio_files)
logger.info("[QualityScan] Found %d audio file(s) to check", total)
if context.report_progress:
context.report_progress(phase=f'Checking {total} files...', total=total)
if context.update_progress:
context.update_progress(0, total)
# --- DB suffix index so a walked file maps back to its track row ---
db_index = self._build_db_suffix_index(context)
probe_failed = 0
for i, fpath in enumerate(audio_files):
if context.check_stop():
return result
if i % 20 == 0 and context.wait_if_paused():
return result
result.scanned += 1
fname = os.path.basename(fpath)
if context.report_progress and i % 25 == 0:
context.report_progress(
scanned=i + 1, total=total,
phase=f'Checking {i + 1} / {total}',
log_line=f'Checking: {fname}',
log_type='info',
)
try:
aq = probe_audio_quality(fpath)
except Exception as e:
logger.debug("Probe failed for %s: %s", fname, e)
aq = None
if aq is None:
probe_failed += 1
result.skipped += 1
continue
if quality_meets_profile(aq, targets):
if context.update_progress and (i + 1) % 25 == 0:
context.update_progress(i + 1, total)
continue
# Below profile → resolve metadata (DB row preferred, file tags fallback).
meta = self._lookup_meta(fpath, db_index)
current_label = aq.label()
target_labels = [t.label for t in targets]
disp_title = meta.get('title') or os.path.splitext(fname)[0]
disp_artist = meta.get('artist') or 'Unknown'
if context.report_progress:
context.report_progress(
log_line=f'Below quality: {disp_title}{current_label}',
log_type='error',
)
if context.create_finding:
inserted = context.create_finding(
job_id=self.job_id,
finding_type='quality_upgrade',
severity='info',
entity_type='track' if meta.get('track_id') else 'file',
entity_id=str(meta['track_id']) if meta.get('track_id') else None,
file_path=fpath,
title=f'Below quality: {disp_title} ({current_label})',
description=(
f'"{disp_title}" by {disp_artist} is {current_label}, '
f'which does not meet your quality profile '
f'({", ".join(target_labels[:3])}'
f'{"" if len(target_labels) > 3 else ""}).'
),
details={
'current_quality': current_label,
'current_format': aq.format,
'current_bitrate': aq.bitrate,
'current_sample_rate': aq.sample_rate,
'current_bit_depth': aq.bit_depth,
'target_qualities': target_labels,
'expected_title': disp_title,
'expected_artist': disp_artist,
'album_title': meta.get('album', ''),
'track_number': meta.get('track_number'),
'album_thumb_url': meta.get('album_thumb_url'),
'artist_thumb_url': meta.get('artist_thumb_url'),
},
)
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
if context.update_progress and (i + 1) % 25 == 0:
context.update_progress(i + 1, total)
if context.update_progress:
context.update_progress(total, total)
if probe_failed:
logger.warning("[QualityScan] %d/%d files could not be probed (unreadable)",
probe_failed, total)
logger.info("Quality upgrade scan: %d checked, %d below profile, %d skipped",
result.scanned, result.findings_created, result.skipped)
return result
def _collect_music_dirs(self, context: JobContext) -> list:
"""All existing music directories to walk, as absolute paths (dedup)."""
cm = context.config_manager
raw = [context.transfer_folder]
if cm:
try:
raw.append(cm.get('soulseek.transfer_path', './Transfer'))
raw.append(cm.get('soulseek.download_path', './downloads'))
mp = cm.get('library.music_paths', []) or []
if isinstance(mp, list):
raw.extend([p for p in mp if isinstance(p, str) and p.strip()])
except Exception as e:
logger.debug("music dir config read failed: %s", e)
out, seen = [], set()
for d in raw:
if not d:
continue
ad = os.path.abspath(d)
if ad in seen:
continue
seen.add(ad)
if os.path.isdir(ad):
out.append(ad)
return out
def _build_db_suffix_index(self, context: JobContext) -> dict:
"""Map normalized path suffixes (last 1-3 components, lowercased) →
track metadata, so a walked absolute file can be matched to its DB row
even when the DB stores a different (relative) path prefix."""
index = {}
conn = None
try:
conn = context.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.id, t.title,
COALESCE(NULLIF(t.track_artist, ''), ar.name) AS artist,
t.file_path, t.track_number,
al.title AS album_title, al.thumb_url, ar.thumb_url
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.file_path IS NOT NULL AND t.file_path != ''
""")
for row in cursor.fetchall():
fp = (row[3] or '').replace('\\', '/')
if not fp:
continue
parts = fp.split('/')
meta = {
'track_id': row[0],
'title': row[1] or '',
'artist': row[2] or '',
'track_number': row[4],
'album': row[5] or '',
'album_thumb_url': row[6] or None,
'artist_thumb_url': row[7] or None,
}
for depth in range(1, min(4, len(parts) + 1)):
suffix = '/'.join(parts[-depth:]).lower()
index.setdefault(suffix, meta)
except Exception as e:
logger.error("Error building DB suffix index: %s", e)
finally:
if conn:
conn.close()
return index
def _lookup_meta(self, fpath: str, db_index: dict) -> dict:
"""Match a walked file to a DB track via path suffix; fall back to the
file's own tags for title/artist/album when nothing matches."""
parts = fpath.replace('\\', '/').split('/')
for depth in range(min(3, len(parts)), 0, -1):
suffix = '/'.join(parts[-depth:]).lower()
hit = db_index.get(suffix)
if hit:
return hit
# No DB row — read the file's own tags.
meta = {'track_id': None}
try:
from mutagen import File as MutagenFile
audio = MutagenFile(fpath, easy=True)
if audio:
meta['title'] = (audio.get('title') or [None])[0] or ''
meta['artist'] = (audio.get('artist') or audio.get('albumartist') or [None])[0] or ''
meta['album'] = (audio.get('album') or [None])[0] or ''
except Exception as e:
logger.debug("tag read failed for %s: %s", os.path.basename(fpath), e)
return meta
def estimate_scope(self, context: JobContext) -> int:
count = 0
for base in self._collect_music_dirs(context):
for _root, _dirs, files in os.walk(base):
for fname in files:
if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS:
count += 1
return count