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>
This commit is contained in:
parent
8bb749de9c
commit
0e14ff03ee
1 changed files with 143 additions and 122 deletions
|
|
@ -1,19 +1,22 @@
|
||||||
"""Quality Upgrade Scanner Job — flags library tracks below the user's profile.
|
"""Quality Upgrade Scanner Job — flags library tracks below the user's profile.
|
||||||
|
|
||||||
Scans the whole library (not just Transfer) by resolving each track's DB file
|
Walks the music folders ON DISK (transfer + download + every configured library
|
||||||
path to a real file on disk, probing its ACTUAL measured audio quality (bit
|
path) exactly like the Orphan / Fake-Lossless detectors — those reliably "see"
|
||||||
depth / sample rate / bitrate via mutagen — the SAME `probe_audio_quality` the
|
files because they os.walk real directories instead of trying to resolve the
|
||||||
download import guard uses), and checking it against the user's v3 ranked
|
DB's stored (often relative) paths. For each audio file it probes the ACTUAL
|
||||||
targets with `quality_meets_profile` (strict; fallback ignored — that's a
|
measured audio quality (bit depth / sample rate / bitrate via the same
|
||||||
download-time concession, not a definition of "good enough").
|
`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 track that satisfies none of the targets becomes a finding the user can:
|
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
|
- 'redownload': add the track to the wishlist and delete the low-quality file
|
||||||
- 'delete': remove the low-quality file and its DB record
|
- 'delete': remove the low-quality file (+ DB row when known)
|
||||||
- 'ignore': dismiss the finding (handled in the UI via the dismiss endpoint)
|
- 'ignore': dismiss the finding (handled in the UI via the dismiss endpoint)
|
||||||
|
|
||||||
This unifies the library quality check onto the exact same pipeline as the
|
Each walked file is matched back to its DB track (by path suffix) so the finding
|
||||||
download quality gate — no more extension-only tier guessing.
|
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
|
import os
|
||||||
|
|
@ -24,6 +27,8 @@ from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("repair_job.quality_upgrade")
|
logger = get_logger("repair_job.quality_upgrade")
|
||||||
|
|
||||||
|
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
|
||||||
|
|
||||||
|
|
||||||
@register_job
|
@register_job
|
||||||
class QualityUpgradeScannerJob(RepairJob):
|
class QualityUpgradeScannerJob(RepairJob):
|
||||||
|
|
@ -31,18 +36,17 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
display_name = 'Quality Upgrade Scanner'
|
display_name = 'Quality Upgrade Scanner'
|
||||||
description = 'Flags library tracks below your quality profile'
|
description = 'Flags library tracks below your quality profile'
|
||||||
help_text = (
|
help_text = (
|
||||||
'Scans your music library and checks every track\'s REAL audio quality '
|
'Scans your music folders on disk and checks every track\'s REAL audio '
|
||||||
'(bit depth, sample rate, bitrate — read from the file itself, not just '
|
'quality (bit depth, sample rate, bitrate — read from the file itself, '
|
||||||
'the extension) against your configured quality profile. This is the same '
|
'not just the extension) against your configured quality profile. This is '
|
||||||
'check the download pipeline runs, so a track flagged here is one the '
|
'the same check the download pipeline runs, so a track flagged here is one '
|
||||||
'downloader would also reject.\n\n'
|
'the downloader would also reject.\n\n'
|
||||||
'Each below-profile track is reported as a finding. You can:\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'
|
'• Re-download — add the track to your wishlist and remove the low-quality file\n'
|
||||||
'• Delete — remove the low-quality file entirely\n'
|
'• Delete — remove the low-quality file\n'
|
||||||
'• Ignore — dismiss the finding and keep the file\n\n'
|
'• Ignore — dismiss the finding and keep the file\n\n'
|
||||||
'The scan only reports — it never deletes or re-downloads on its own. '
|
'The scan only reports — it never deletes or re-downloads on its own. '
|
||||||
'Profile targets and fallback come straight from Settings → Quality, so '
|
'Profile targets and fallback come straight from Settings → Quality.'
|
||||||
'adjusting your profile changes what counts as "below quality" here.'
|
|
||||||
)
|
)
|
||||||
icon = 'repair-icon-lossless'
|
icon = 'repair-icon-lossless'
|
||||||
default_enabled = False
|
default_enabled = False
|
||||||
|
|
@ -72,41 +76,52 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
|
|
||||||
from core.imports.file_ops import probe_audio_quality
|
from core.imports.file_ops import probe_audio_quality
|
||||||
|
|
||||||
db_tracks = self._load_db_tracks(context)
|
# --- Collect the music folders to walk (real dirs, abspath'd) ---
|
||||||
if not db_tracks:
|
base_dirs = self._collect_music_dirs(context)
|
||||||
logger.info("No library tracks with file paths found")
|
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
|
return result
|
||||||
|
logger.info("[QualityScan] Walking %d folder(s): %r", len(base_dirs), base_dirs)
|
||||||
|
|
||||||
track_list = sorted(db_tracks.items(), key=lambda x: str(x[0]))
|
# --- Gather audio files (dedup by real path) ---
|
||||||
total = len(track_list)
|
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:
|
if context.report_progress:
|
||||||
context.report_progress(phase=f'Scanning {total} library tracks...', total=total)
|
context.report_progress(phase=f'Checking {total} files...', total=total)
|
||||||
if context.update_progress:
|
if context.update_progress:
|
||||||
context.update_progress(0, total)
|
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
|
probe_failed = 0
|
||||||
_diag_logged = False
|
for i, fpath in enumerate(audio_files):
|
||||||
for i, (track_id, info) in enumerate(track_list):
|
|
||||||
if context.check_stop():
|
if context.check_stop():
|
||||||
return result
|
return result
|
||||||
if i % 20 == 0 and context.wait_if_paused():
|
if i % 20 == 0 and context.wait_if_paused():
|
||||||
return result
|
return result
|
||||||
|
|
||||||
raw_fp = info.get('file_path', '')
|
|
||||||
resolved = self._resolve_path(raw_fp, context)
|
|
||||||
if not resolved:
|
|
||||||
# One-shot diagnostic on the first unresolved track — logs EXACTLY
|
|
||||||
# what the resolver tried (base dirs, cwd) so a path/mount mismatch
|
|
||||||
# is diagnosable instead of a silent "all skipped".
|
|
||||||
if not _diag_logged:
|
|
||||||
_diag_logged = True
|
|
||||||
self._log_resolve_diag(raw_fp, context)
|
|
||||||
result.skipped += 1
|
|
||||||
probe_failed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
result.scanned += 1
|
result.scanned += 1
|
||||||
fname = os.path.basename(resolved)
|
fname = os.path.basename(fpath)
|
||||||
if context.report_progress and i % 25 == 0:
|
if context.report_progress and i % 25 == 0:
|
||||||
context.report_progress(
|
context.report_progress(
|
||||||
scanned=i + 1, total=total,
|
scanned=i + 1, total=total,
|
||||||
|
|
@ -116,29 +131,30 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
aq = probe_audio_quality(resolved)
|
aq = probe_audio_quality(fpath)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Probe failed for %s: %s", fname, e)
|
logger.debug("Probe failed for %s: %s", fname, e)
|
||||||
aq = None
|
aq = None
|
||||||
|
|
||||||
if aq is None:
|
if aq is None:
|
||||||
# File couldn't be read — can't judge quality, leave unflagged.
|
|
||||||
probe_failed += 1
|
probe_failed += 1
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Strict profile check — identical to the download guard.
|
|
||||||
if quality_meets_profile(aq, targets):
|
if quality_meets_profile(aq, targets):
|
||||||
if context.update_progress and (i + 1) % 25 == 0:
|
if context.update_progress and (i + 1) % 25 == 0:
|
||||||
context.update_progress(i + 1, total)
|
context.update_progress(i + 1, total)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Below profile → create a finding.
|
# Below profile → resolve metadata (DB row preferred, file tags fallback).
|
||||||
|
meta = self._lookup_meta(fpath, db_index)
|
||||||
current_label = aq.label()
|
current_label = aq.label()
|
||||||
target_labels = [t.label for t in targets]
|
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:
|
if context.report_progress:
|
||||||
context.report_progress(
|
context.report_progress(
|
||||||
log_line=f'Below quality: {fname} — {current_label}',
|
log_line=f'Below quality: {disp_title} — {current_label}',
|
||||||
log_type='error',
|
log_type='error',
|
||||||
)
|
)
|
||||||
if context.create_finding:
|
if context.create_finding:
|
||||||
|
|
@ -146,13 +162,12 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
job_id=self.job_id,
|
job_id=self.job_id,
|
||||||
finding_type='quality_upgrade',
|
finding_type='quality_upgrade',
|
||||||
severity='info',
|
severity='info',
|
||||||
entity_type='track',
|
entity_type='track' if meta.get('track_id') else 'file',
|
||||||
entity_id=str(track_id),
|
entity_id=str(meta['track_id']) if meta.get('track_id') else None,
|
||||||
file_path=resolved,
|
file_path=fpath,
|
||||||
title=f'Below quality: {info.get("title") or fname} ({current_label})',
|
title=f'Below quality: {disp_title} ({current_label})',
|
||||||
description=(
|
description=(
|
||||||
f'"{info.get("title") or fname}" by '
|
f'"{disp_title}" by {disp_artist} is {current_label}, '
|
||||||
f'{info.get("artist") or "Unknown"} is {current_label}, '
|
|
||||||
f'which does not meet your quality profile '
|
f'which does not meet your quality profile '
|
||||||
f'({", ".join(target_labels[:3])}'
|
f'({", ".join(target_labels[:3])}'
|
||||||
f'{"…" if len(target_labels) > 3 else ""}).'
|
f'{"…" if len(target_labels) > 3 else ""}).'
|
||||||
|
|
@ -164,12 +179,12 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
'current_sample_rate': aq.sample_rate,
|
'current_sample_rate': aq.sample_rate,
|
||||||
'current_bit_depth': aq.bit_depth,
|
'current_bit_depth': aq.bit_depth,
|
||||||
'target_qualities': target_labels,
|
'target_qualities': target_labels,
|
||||||
'expected_title': info.get('title', ''),
|
'expected_title': disp_title,
|
||||||
'expected_artist': info.get('artist', ''),
|
'expected_artist': disp_artist,
|
||||||
'album_title': info.get('album_title', ''),
|
'album_title': meta.get('album', ''),
|
||||||
'track_number': info.get('track_number'),
|
'track_number': meta.get('track_number'),
|
||||||
'album_thumb_url': info.get('album_thumb_url'),
|
'album_thumb_url': meta.get('album_thumb_url'),
|
||||||
'artist_thumb_url': info.get('artist_thumb_url'),
|
'artist_thumb_url': meta.get('artist_thumb_url'),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if inserted:
|
if inserted:
|
||||||
|
|
@ -184,17 +199,42 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
context.update_progress(total, total)
|
context.update_progress(total, total)
|
||||||
|
|
||||||
if probe_failed:
|
if probe_failed:
|
||||||
logger.warning(
|
logger.warning("[QualityScan] %d/%d files could not be probed (unreadable)",
|
||||||
"Quality upgrade scan: %d/%d tracks could not be read/resolved and "
|
probe_failed, total)
|
||||||
"were left unflagged (their quality couldn't be verified).",
|
logger.info("Quality upgrade scan: %d checked, %d below profile, %d skipped",
|
||||||
probe_failed, total)
|
|
||||||
logger.info("Quality upgrade scan: %d scanned, %d below profile, %d skipped",
|
|
||||||
result.scanned, result.findings_created, result.skipped)
|
result.scanned, result.findings_created, result.skipped)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _load_db_tracks(self, context: JobContext) -> dict:
|
def _collect_music_dirs(self, context: JobContext) -> list:
|
||||||
"""Load all library tracks keyed by ID (mirrors AcoustIDScannerJob)."""
|
"""All existing music directories to walk, as absolute paths (dedup)."""
|
||||||
tracks = {}
|
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
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
|
|
@ -210,75 +250,56 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
WHERE t.file_path IS NOT NULL AND t.file_path != ''
|
WHERE t.file_path IS NOT NULL AND t.file_path != ''
|
||||||
""")
|
""")
|
||||||
for row in cursor.fetchall():
|
for row in cursor.fetchall():
|
||||||
track_id = row[0]
|
fp = (row[3] or '').replace('\\', '/')
|
||||||
if track_id is None:
|
if not fp:
|
||||||
continue
|
continue
|
||||||
tracks[str(track_id)] = {
|
parts = fp.split('/')
|
||||||
|
meta = {
|
||||||
|
'track_id': row[0],
|
||||||
'title': row[1] or '',
|
'title': row[1] or '',
|
||||||
'artist': row[2] or '',
|
'artist': row[2] or '',
|
||||||
'file_path': row[3] or '',
|
|
||||||
'track_number': row[4],
|
'track_number': row[4],
|
||||||
'album_title': row[5] or '',
|
'album': row[5] or '',
|
||||||
'album_thumb_url': row[6] or None,
|
'album_thumb_url': row[6] or None,
|
||||||
'artist_thumb_url': row[7] 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:
|
except Exception as e:
|
||||||
logger.error("Error loading tracks from DB: %s", e)
|
logger.error("Error building DB suffix index: %s", e)
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
return tracks
|
return index
|
||||||
|
|
||||||
def _resolve_path(self, file_path, context):
|
def _lookup_meta(self, fpath: str, db_index: dict) -> dict:
|
||||||
"""Resolve a DB file path to an actual file on disk via the shared
|
"""Match a walked file to a DB track via path suffix; fall back to the
|
||||||
resolver (picks up library.music_paths + Plex locations too)."""
|
file's own tags for title/artist/album when nothing matches."""
|
||||||
if not file_path:
|
parts = fpath.replace('\\', '/').split('/')
|
||||||
return None
|
for depth in range(min(3, len(parts)), 0, -1):
|
||||||
if os.path.exists(file_path):
|
suffix = '/'.join(parts[-depth:]).lower()
|
||||||
return file_path
|
hit = db_index.get(suffix)
|
||||||
from core.library.path_resolver import resolve_library_file_path
|
if hit:
|
||||||
return resolve_library_file_path(
|
return hit
|
||||||
file_path,
|
# No DB row — read the file's own tags.
|
||||||
transfer_folder=context.transfer_folder,
|
meta = {'track_id': None}
|
||||||
config_manager=context.config_manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _log_resolve_diag(self, file_path, context):
|
|
||||||
"""Log a detailed diagnostic for the first track whose path can't be
|
|
||||||
resolved — the only reliable way to tell apart a CWD problem, a wrong
|
|
||||||
transfer mount, or genuinely-missing files in this container."""
|
|
||||||
from core.library.path_resolver import resolve_library_file_path_with_diagnostic
|
|
||||||
try:
|
try:
|
||||||
_, attempt = resolve_library_file_path_with_diagnostic(
|
from mutagen import File as MutagenFile
|
||||||
file_path,
|
audio = MutagenFile(fpath, easy=True)
|
||||||
transfer_folder=context.transfer_folder,
|
if audio:
|
||||||
config_manager=context.config_manager,
|
meta['title'] = (audio.get('title') or [None])[0] or ''
|
||||||
)
|
meta['artist'] = (audio.get('artist') or audio.get('albumartist') or [None])[0] or ''
|
||||||
tf = context.transfer_folder
|
meta['album'] = (audio.get('album') or [None])[0] or ''
|
||||||
abs_tf = os.path.abspath(tf) if tf else None
|
|
||||||
logger.warning(
|
|
||||||
"[QualityResolve] unresolved db_path=%r | cwd=%r | transfer_folder=%r "
|
|
||||||
"(abspath=%r, isdir=%s) | base_dirs_tried=%r | abs_join_exists=%s",
|
|
||||||
file_path, os.getcwd(), tf, abs_tf,
|
|
||||||
os.path.isdir(abs_tf) if abs_tf else None,
|
|
||||||
attempt.base_dirs_tried,
|
|
||||||
os.path.exists(os.path.join(abs_tf, file_path)) if abs_tf else None,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("[QualityResolve] diagnostic failed: %s", e)
|
logger.debug("tag read failed for %s: %s", os.path.basename(fpath), e)
|
||||||
|
return meta
|
||||||
|
|
||||||
def estimate_scope(self, context: JobContext) -> int:
|
def estimate_scope(self, context: JobContext) -> int:
|
||||||
conn = None
|
count = 0
|
||||||
try:
|
for base in self._collect_music_dirs(context):
|
||||||
conn = context.db._get_connection()
|
for _root, _dirs, files in os.walk(base):
|
||||||
cursor = conn.cursor()
|
for fname in files:
|
||||||
cursor.execute("""
|
if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS:
|
||||||
SELECT COUNT(*) FROM tracks
|
count += 1
|
||||||
WHERE file_path IS NOT NULL AND file_path != ''
|
return count
|
||||||
""")
|
|
||||||
return cursor.fetchone()[0]
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue