From 526ed227c7ec85d29d792686034ee12d9bc3c51c Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 15 Jun 2026 17:17:44 +0200 Subject: [PATCH] feat(quality-scan): run the same ffmpeg real-audio guard as downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/repair_jobs/quality_upgrade_scanner.py | 102 ++++++++++++++------ 1 file changed, 73 insertions(+), 29 deletions(-) diff --git a/core/repair_jobs/quality_upgrade_scanner.py b/core/repair_jobs/quality_upgrade_scanner.py index c0f2d930..8dee87ac 100644 --- a/core/repair_jobs/quality_upgrade_scanner.py +++ b/core/repair_jobs/quality_upgrade_scanner.py @@ -36,11 +36,20 @@ class QualityUpgradeScannerJob(RepairJob): 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' + 'Scans your music library folder and verifies every track with the SAME ' + 'two-stage check the download/import pipeline runs:\n' + '1. Real-audio guard — ffmpeg actually decodes the file (truncation + ' + 'silence detection) to catch broken/incomplete audio the header hides.\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' '• Re-download — add the track to your wishlist and 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 # ("check my library folder"). DB matching after a reset is unreliable and # would wrongly skip everything. Turn ON to ignore non-DB files. - default_settings = {'library_tracks_only': False} - setting_options = {'library_tracks_only': [True, False]} + default_settings = {'library_tracks_only': False, 'deep_audio_verify': True} + setting_options = {'library_tracks_only': [True, False], + 'deep_audio_verify': [True, False]} auto_fix = False # User chooses fix action per finding def scan(self, context: JobContext) -> JobResult: @@ -81,6 +91,10 @@ class QualityUpgradeScannerJob(RepairJob): [t.label for t in targets]) 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) --- 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 # 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. - 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 not_in_library = 0 @@ -155,53 +175,77 @@ class QualityUpgradeScannerJob(RepairJob): 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: aq = probe_audio_quality(fpath) except Exception as e: logger.debug("Probe failed for %s: %s", fname, e) 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 result.skipped += 1 continue - - if quality_meets_profile(aq, targets): + elif not 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: context.update_progress(i + 1, total) continue - # Below profile → build the finding from the resolved metadata. - current_label = aq.label() + # Build the finding (broken audio OR below profile). 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 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: - context.report_progress( - log_line=f'Below quality: {disp_title} — {current_label}', - log_type='error', - ) + context.report_progress(log_line=_title, log_type='error') if context.create_finding: inserted = context.create_finding( job_id=self.job_id, finding_type='quality_upgrade', - severity='info', + severity=_severity, 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 ""}).' - ), + title=_title, + description=_desc, details={ + 'quality_issue': issue, + 'broken_audio_reason': broken_reason or '', 'current_quality': current_label, - 'current_format': aq.format, - 'current_bitrate': aq.bitrate, - 'current_sample_rate': aq.sample_rate, - 'current_bit_depth': aq.bit_depth, + 'current_format': aq.format if aq is not None else '', + 'current_bitrate': aq.bitrate if aq is not None else None, + 'current_sample_rate': aq.sample_rate if aq is not None else None, + 'current_bit_depth': aq.bit_depth if aq is not None else None, 'target_qualities': target_labels, 'expected_title': disp_title, 'expected_artist': disp_artist,