diff --git a/core/imports/pipeline.py b/core/imports/pipeline.py index 0ff2d1b4..69ed30b1 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -362,7 +362,14 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta # Monochrome HLS assembly) or a mostly-silent file is caught regardless # of its quality verdict and gets the right reason. Same quarantine + # next-candidate retry pattern as the integrity check. - _skip_audio = _should_skip_quarantine_check(context, 'silence') + # + # Opt-in (default OFF): this is the one check that fully DECODES the file + # with ffmpeg, so it is the most CPU-expensive step in post-processing. + # Most preview/truncation cases are already caught at the source + # (HiFi/Qobuz have their own guards), so it stays off unless the user + # turns it on under Settings → Post-processing. + _audio_guard_enabled = config_manager.get('post_processing.audio_completeness_check', False) + _skip_audio = (not _audio_guard_enabled) or _should_skip_quarantine_check(context, 'silence') audio_reason = None if _skip_audio else detect_broken_audio(file_path) if audio_reason: logger.error(f"[AudioGuard] Rejected {_basename}: {audio_reason}") diff --git a/core/imports/silence.py b/core/imports/silence.py index c4a0744d..ecbe048f 100644 --- a/core/imports/silence.py +++ b/core/imports/silence.py @@ -217,12 +217,61 @@ def detect_incomplete_audio( return incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio) -def detect_broken_audio(file_path: str) -> Optional[str]: +def detect_broken_audio( + file_path: str, + *, + min_ratio: float = DEFAULT_MIN_DURATION_RATIO, + threshold: float = DEFAULT_THRESHOLD, + noise_db: int = DEFAULT_NOISE_DB, + min_silence_s: float = DEFAULT_MIN_SILENCE_S, +) -> Optional[str]: """Combined post-download audio guard: reject a file that is truncated (real audio far shorter than the container) or mostly silence. Returns the first failure reason, or None when the audio looks complete. + + Runs a SINGLE ffmpeg decode pass with both the ``astats`` (truncation) and + ``silencedetect`` (silence) filters chained — one decode of the file feeds + both checks instead of two full decodes. Halves the CPU cost versus running + ``detect_incomplete_audio`` and ``detect_mostly_silent`` back to back. + + Fails open: returns None when ffmpeg/mutagen are unavailable or error, so a + tooling problem never quarantines a legitimate file. """ - reason = detect_incomplete_audio(file_path) + if not _ffmpeg_available(): + logger.debug("audio guard skipped — ffmpeg not available") + return None + + try: + from mutagen import File as MutagenFile + audio = MutagenFile(file_path) + if not (audio and audio.info): + return None + container_s = float(getattr(audio.info, "length", 0) or 0) + sample_rate = int(getattr(audio.info, "sample_rate", 0) or 0) + except Exception as exc: # pragma: no cover - defensive + logger.debug("container probe failed for %s: %s", file_path, exc) + return None + + try: + proc = subprocess.run( + [ + "ffmpeg", "-hide_banner", "-nostats", "-i", file_path, + "-af", f"astats=metadata=1,silencedetect=noise={noise_db}dB:d={min_silence_s}", + "-f", "null", "-", + ], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120, + ) + except (subprocess.SubprocessError, OSError) as exc: + logger.debug("audio guard ffmpeg run failed for %s: %s", file_path, exc) + return None + + stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else "" + + # Truncation check first (real audio far shorter than the container). + measured_s = measured_duration_from_astats(stderr, sample_rate) + reason = incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio) if reason: return reason - return detect_mostly_silent(file_path) + + # Then silence-padding (mostly-silent file). + return is_mostly_silent_reason(stderr, container_s, threshold=threshold) diff --git a/webui/index.html b/webui/index.html index 808324a3..ccc75be9 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5650,6 +5650,13 @@ Maximum drift between the file's actual length and the metadata source's expected length before the file is quarantined. 0 = auto (3s normal, 5s for tracks >10min). Raise this if tracks routinely quarantine for being a few seconds off (live recordings, alternate masterings, etc). Capped at 60s. +
+ + Decodes every downloaded file with ffmpeg to catch fake 30s previews padded to full length and mostly-silent files — the kind whose header lies about the length so mutagen can't catch them. Off by default: this fully decodes each file, the most CPU-heavy post-processing step. Most sources (HiFi, Qobuz) already catch previews on their own, so only turn this on if you see padded/silent files slip through. Applies to all download sources. +
diff --git a/webui/static/settings.js b/webui/static/settings.js index 937f7793..272e5f01 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1249,6 +1249,7 @@ async function loadSettingsData() { document.getElementById('single-to-album-enabled').checked = settings.metadata_enhancement?.single_to_album === true; document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false; document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true; + document.getElementById('audio-completeness-check').checked = settings.post_processing?.audio_completeness_check === true; document.getElementById('duration-tolerance-seconds').value = settings.post_processing?.duration_tolerance_seconds ?? 0; document.getElementById('retry-next-candidate').checked = settings.post_processing?.retry_next_candidate_on_mismatch !== false; document.getElementById('retry-exhaustive').checked = settings.post_processing?.retry_exhaustive === true; @@ -3168,6 +3169,7 @@ async function saveSettings(quiet = false) { }, post_processing: { replaygain_enabled: document.getElementById('replaygain-enabled').checked, + audio_completeness_check: document.getElementById('audio-completeness-check').checked, duration_tolerance_seconds: parseFloat(document.getElementById('duration-tolerance-seconds').value) || 0, retry_next_candidate_on_mismatch: document.getElementById('retry-next-candidate').checked, retry_exhaustive: document.getElementById('retry-exhaustive').checked,