feat(quality-scan): run the same ffmpeg real-audio guard as downloads
The scan was only doing the header-based quality gate (mutagen) — fast but shallow. The download/import pipeline ALSO runs detect_broken_audio first, which uses ffmpeg to actually DECODE the file (astats truncation check + silencedetect) to verify the REAL audio, not just the metadata. That's the whole point of unifying onto the download quality pipeline. - Each file now runs both stages: (1) ffmpeg AudioGuard (detect_broken_audio), (2) header quality gate (probe_audio_quality + quality_meets_profile). A finding is created for broken/incomplete audio OR below-profile quality, with quality_issue + broken_audio_reason in details and a 'warning' severity for broken audio vs 'info' for below-profile. - New setting deep_audio_verify (default True) toggles the ffmpeg decode pass; off = fast header-only. Slower full scan is expected — it decodes every file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bb1a1222f8
commit
526ed227c7
1 changed files with 73 additions and 29 deletions
|
|
@ -36,11 +36,20 @@ 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 folders on disk and checks every track\'s REAL audio '
|
'Scans your music library folder and verifies every track with the SAME '
|
||||||
'quality (bit depth, sample rate, bitrate — read from the file itself, '
|
'two-stage check the download/import pipeline runs:\n'
|
||||||
'not just the extension) against your configured quality profile. This is '
|
'1. Real-audio guard — ffmpeg actually decodes the file (truncation + '
|
||||||
'the same check the download pipeline runs, so a track flagged here is one '
|
'silence detection) to catch broken/incomplete audio the header hides.\n'
|
||||||
'the downloader would also reject.\n\n'
|
'2. Quality gate — measured bit depth / sample rate / bitrate vs your '
|
||||||
|
'configured quality profile.\n'
|
||||||
|
'A track flagged here is one the downloader would also reject. Because it '
|
||||||
|
'decodes each file, a full scan takes real time (seconds per track), not '
|
||||||
|
'milliseconds — that\'s the deep verification doing its job.\n\n'
|
||||||
|
'Settings:\n'
|
||||||
|
'- deep_audio_verify (default on): run the ffmpeg decode guard. Turn off '
|
||||||
|
'for a fast header-only pass.\n'
|
||||||
|
'- library_tracks_only (default off): only check files matched to a '
|
||||||
|
'library DB track.\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\n'
|
'• Delete — remove the low-quality file\n'
|
||||||
|
|
@ -56,8 +65,9 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
# audio file in the Music Library output folder, which is what users expect
|
# audio file in the Music Library output folder, which is what users expect
|
||||||
# ("check my library folder"). DB matching after a reset is unreliable and
|
# ("check my library folder"). DB matching after a reset is unreliable and
|
||||||
# would wrongly skip everything. Turn ON to ignore non-DB files.
|
# would wrongly skip everything. Turn ON to ignore non-DB files.
|
||||||
default_settings = {'library_tracks_only': False}
|
default_settings = {'library_tracks_only': False, 'deep_audio_verify': True}
|
||||||
setting_options = {'library_tracks_only': [True, False]}
|
setting_options = {'library_tracks_only': [True, False],
|
||||||
|
'deep_audio_verify': [True, False]}
|
||||||
auto_fix = False # User chooses fix action per finding
|
auto_fix = False # User chooses fix action per finding
|
||||||
|
|
||||||
def scan(self, context: JobContext) -> JobResult:
|
def scan(self, context: JobContext) -> JobResult:
|
||||||
|
|
@ -81,6 +91,10 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
[t.label for t in targets])
|
[t.label for t in targets])
|
||||||
|
|
||||||
from core.imports.file_ops import probe_audio_quality
|
from core.imports.file_ops import probe_audio_quality
|
||||||
|
# Same real-file AudioGuard the download/import pipeline runs: ffmpeg
|
||||||
|
# DECODES the file (astats + silencedetect) to catch truncated or
|
||||||
|
# mostly-silent audio the header can't reveal.
|
||||||
|
from core.imports.silence import detect_broken_audio
|
||||||
|
|
||||||
# --- Collect the music folders to walk (real dirs, abspath'd) ---
|
# --- Collect the music folders to walk (real dirs, abspath'd) ---
|
||||||
base_dirs = self._collect_music_dirs(context)
|
base_dirs = self._collect_music_dirs(context)
|
||||||
|
|
@ -123,7 +137,13 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
# residue after a DB reset) — those are orphans, not library tracks, and
|
# residue after a DB reset) — those are orphans, not library tracks, and
|
||||||
# belong to the Orphan File Detector, not a quality upgrade scan. Default
|
# belong to the Orphan File Detector, not a quality upgrade scan. Default
|
||||||
# ON so the scan reflects the user's actual library, not download junk.
|
# ON so the scan reflects the user's actual library, not download junk.
|
||||||
library_only = self._get_settings(context).get('library_tracks_only', True)
|
_settings = self._get_settings(context)
|
||||||
|
library_only = _settings.get('library_tracks_only', False)
|
||||||
|
# Deep verify = run the ffmpeg AudioGuard (real decode) per file, exactly
|
||||||
|
# like the download pipeline. Slower than a header read but it's the
|
||||||
|
# whole point: it verifies the REAL audio, not just the metadata. On by
|
||||||
|
# default; can be turned off for a fast header-only pass.
|
||||||
|
deep_verify = _settings.get('deep_audio_verify', True)
|
||||||
|
|
||||||
probe_failed = 0
|
probe_failed = 0
|
||||||
not_in_library = 0
|
not_in_library = 0
|
||||||
|
|
@ -155,53 +175,77 @@ class QualityUpgradeScannerJob(RepairJob):
|
||||||
log_type='info',
|
log_type='info',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# === Real-file verification — the SAME two stages the download /
|
||||||
|
# import pipeline runs on every file ===
|
||||||
|
# 1) AudioGuard: ffmpeg DECODES the audio (astats / silencedetect)
|
||||||
|
# to catch truncated or mostly-silent files the header hides.
|
||||||
|
# 2) Quality gate: measured quality (mutagen) vs the ranked profile.
|
||||||
|
try:
|
||||||
|
broken_reason = detect_broken_audio(fpath) if deep_verify else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("AudioGuard failed for %s: %s", fname, e)
|
||||||
|
broken_reason = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
aq = probe_audio_quality(fpath)
|
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 broken_reason:
|
||||||
|
issue = 'broken_audio'
|
||||||
|
current_label = aq.label() if aq is not None else 'unknown'
|
||||||
|
elif aq is None:
|
||||||
|
# Header unreadable → can't judge quality; leave it unflagged.
|
||||||
probe_failed += 1
|
probe_failed += 1
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
continue
|
continue
|
||||||
|
elif not quality_meets_profile(aq, targets):
|
||||||
if quality_meets_profile(aq, targets):
|
issue = 'below_profile'
|
||||||
|
current_label = aq.label()
|
||||||
|
else:
|
||||||
|
# Decodes fully AND meets the profile → genuinely good.
|
||||||
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 → build the finding from the resolved metadata.
|
# Build the finding (broken audio OR below profile).
|
||||||
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_title = meta.get('title') or os.path.splitext(fname)[0]
|
||||||
disp_artist = meta.get('artist') or 'Unknown'
|
disp_artist = meta.get('artist') or 'Unknown'
|
||||||
|
if issue == 'broken_audio':
|
||||||
|
_title = f'Broken/incomplete audio: {disp_title}'
|
||||||
|
_desc = (f'"{disp_title}" by {disp_artist} failed real-audio '
|
||||||
|
f'verification (ffmpeg): {broken_reason}')
|
||||||
|
_severity = 'warning'
|
||||||
|
else:
|
||||||
|
_title = f'Below quality: {disp_title} ({current_label})'
|
||||||
|
_desc = (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 ""}).')
|
||||||
|
_severity = 'info'
|
||||||
|
|
||||||
if context.report_progress:
|
if context.report_progress:
|
||||||
context.report_progress(
|
context.report_progress(log_line=_title, log_type='error')
|
||||||
log_line=f'Below quality: {disp_title} — {current_label}',
|
|
||||||
log_type='error',
|
|
||||||
)
|
|
||||||
if context.create_finding:
|
if context.create_finding:
|
||||||
inserted = context.create_finding(
|
inserted = context.create_finding(
|
||||||
job_id=self.job_id,
|
job_id=self.job_id,
|
||||||
finding_type='quality_upgrade',
|
finding_type='quality_upgrade',
|
||||||
severity='info',
|
severity=_severity,
|
||||||
entity_type='track' if meta.get('track_id') else 'file',
|
entity_type='track' if meta.get('track_id') else 'file',
|
||||||
entity_id=str(meta['track_id']) if meta.get('track_id') else None,
|
entity_id=str(meta['track_id']) if meta.get('track_id') else None,
|
||||||
file_path=fpath,
|
file_path=fpath,
|
||||||
title=f'Below quality: {disp_title} ({current_label})',
|
title=_title,
|
||||||
description=(
|
description=_desc,
|
||||||
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={
|
details={
|
||||||
|
'quality_issue': issue,
|
||||||
|
'broken_audio_reason': broken_reason or '',
|
||||||
'current_quality': current_label,
|
'current_quality': current_label,
|
||||||
'current_format': aq.format,
|
'current_format': aq.format if aq is not None else '',
|
||||||
'current_bitrate': aq.bitrate,
|
'current_bitrate': aq.bitrate if aq is not None else None,
|
||||||
'current_sample_rate': aq.sample_rate,
|
'current_sample_rate': aq.sample_rate if aq is not None else None,
|
||||||
'current_bit_depth': aq.bit_depth,
|
'current_bit_depth': aq.bit_depth if aq is not None else None,
|
||||||
'target_qualities': target_labels,
|
'target_qualities': target_labels,
|
||||||
'expected_title': disp_title,
|
'expected_title': disp_title,
|
||||||
'expected_artist': disp_artist,
|
'expected_artist': disp_artist,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue