From 3462881a0ab46c132cba20e4ce475f9ea99e4cc4 Mon Sep 17 00:00:00 2001 From: mangmang <362165265@qq.com> Date: Mon, 20 Jul 2026 02:46:50 +0800 Subject: [PATCH] fix: distinguish audio decode errors from metadata warnings --- .../music/service/AudioValidationService.java | 188 +++++++++++++----- .../service/AudioValidationServiceTest.java | 171 ++++++++++++++++ .../music/service/IngestServiceE2ETest.java | 106 ++++++++++ 3 files changed, 417 insertions(+), 48 deletions(-) diff --git a/backend/src/main/java/com/music/service/AudioValidationService.java b/backend/src/main/java/com/music/service/AudioValidationService.java index 3ba951c..d0581c5 100644 --- a/backend/src/main/java/com/music/service/AudioValidationService.java +++ b/backend/src/main/java/com/music/service/AudioValidationService.java @@ -193,16 +193,20 @@ public class AudioValidationService { /** * {@link #runProcess(List, int)} 的返回结果。 + *

{@link #stdout} 为子进程标准输出(结构化 JSON 消费路径只解析它); + * {@link #stderr} 为标准错误(仅用于有界诊断,绝不进入 JSON 解析)。

*/ private static class ProcessResult { final boolean timedOut; final int exitCode; - final String output; + final String stdout; + final String stderr; - ProcessResult(boolean timedOut, int exitCode, String output) { + ProcessResult(boolean timedOut, int exitCode, String stdout, String stderr) { this.timedOut = timedOut; this.exitCode = exitCode; - this.output = output; + this.stdout = stdout != null ? stdout : ""; + this.stderr = stderr != null ? stderr : ""; } } @@ -277,12 +281,13 @@ public class AudioValidationService { } if (result.exitCode != 0) { - String diag = capDiagnostic(result.output); + String diag = capDiagnostic(result.stderr); log.warn("FFprobe 探测失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag); return ValidationResult.invalid("FFprobe 探测失败: " + diag); } - if (!result.output.contains("audio")) { + // 仅解析 stdout(codec_type 列表),stderr 不参与判定 + if (!result.stdout.contains("audio")) { log.warn("FFprobe 未发现音频流: {}", audioFile); return ValidationResult.invalid("文件中未发现可识别的音频流"); } @@ -339,13 +344,14 @@ public class AudioValidationService { return ProbeMetadata.unavailable("元数据读取超时"); } if (result.exitCode != 0) { - String diag = capDiagnostic(result.output); + String diag = capDiagnostic(result.stderr); log.warn("FFprobe 元数据读取失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag); return ProbeMetadata.unavailable("FFprobe 读取失败: " + diag); } try { - JsonNode root = JSON_MAPPER.readTree(result.output); + // 只解析 stdout JSON,stderr 警告不参与 + JsonNode root = JSON_MAPPER.readTree(result.stdout); if (!hasAudioStream(root)) { log.warn("FFprobe 后备未发现音频流: {}", audioFile); return ProbeMetadata.unavailable("文件中未发现可识别的音频流"); @@ -389,7 +395,7 @@ public class AudioValidationService { return ""; } try { - JsonNode root = JSON_MAPPER.readTree(result.output); + JsonNode root = JSON_MAPPER.readTree(result.stdout); JsonNode streams = root.path("streams"); if (streams.isArray()) { for (JsonNode stream : streams) { @@ -467,7 +473,7 @@ public class AudioValidationService { return EmbeddedArtwork.absent(); } try { - JsonNode root = JSON_MAPPER.readTree(result.output); + JsonNode root = JSON_MAPPER.readTree(result.stdout); JsonNode streams = root.path("streams"); if (streams.isArray()) { for (JsonNode stream : streams) { @@ -529,7 +535,7 @@ public class AudioValidationService { try { ProcessResult result = runProcess(cmd, timeout); if (result.timedOut || result.exitCode != 0) { - String diag = capDiagnostic(result.output); + String diag = capDiagnostic(result.stderr); log.warn("封面提取失败: {} (exit={}) diag={}", media, result.exitCode, diag); deleteQuietly(coverTarget); return false; @@ -580,28 +586,101 @@ public class AudioValidationService { */ private ValidationResult runDecode(Path audioFile) { int timeout = resolveDecodeTimeout(); + ProcessResult result; try { - ProcessResult result = runProcess(getDecodeCommandArgs(audioFile), timeout); - if (result.timedOut) { - log.warn("FFmpeg 解码超时: {} ({}s)", audioFile, timeout); - return ValidationResult.invalid("音频解码超时"); - } - - // 同时检查退出码和 error 级别输出: - // -xerror 下 exitCode != 0 表示 fatal 错误 - // 但某些 FFmpeg 版本(如 6.1.1)的部分解码错误仅输出到 stderr 而不改变退出码 - String trimmedOutput = result.output.trim(); - if (result.exitCode != 0 || !trimmedOutput.isEmpty()) { - String diag = capDiagnostic(result.output); - log.warn("FFmpeg 解码失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag); - return ValidationResult.invalid("音频解码失败: " + diag); - } - - return ValidationResult.valid(); + result = runProcess(getDecodeCommandArgs(audioFile), timeout); } catch (IOException e) { + // 启动失败(可执行不存在等) log.warn("FFmpeg 执行失败: {} - {}", audioFile, e.getMessage()); return ValidationResult.invalid("FFmpeg 执行失败: " + e.getMessage()); } + + // 依次处理:超时 → 非零退出码 → exit=0 诊断分类 + if (result.timedOut) { + log.warn("FFmpeg 解码超时: {} ({}s)", audioFile, timeout); + return ValidationResult.invalid("音频解码超时"); + } + + // FFmpeg 诊断只在 stderr(-v error),绝不来自 stdout(null muxer 无有意义 stdout) + String diagRaw = result.stderr; + if (result.exitCode != 0) { + String diag = capDiagnostic(diagRaw); + log.warn("FFmpeg 解码失败(非零退出): {} (exit={}) diag={}", audioFile, result.exitCode, diag); + return ValidationResult.invalid("音频解码失败: " + diag); + } + + // exit=0:部分 FFmpeg 版本(如 6.1.1)在解码错误时仍返回 0,须按诊断内容判定。 + // 仅当出现明确的音频流/解码器错误才拒绝;LYRICS/ID3/APIC/封面/tag/atom/BOM 等 + // 非音频元数据诊断不构成拒绝理由。 + if (hasFatalDecodeDiagnostic(diagRaw)) { + String diag = capDiagnostic(diagRaw); + log.warn("FFmpeg 解码失败(诊断): {} diag={}", audioFile, diag); + return ValidationResult.invalid("音频解码失败: " + diag); + } + + return ValidationResult.valid(); + } + + // ========== 解码诊断分类(集中、可测试) ========== + + /** + * 明确属于非音频/元数据层面的诊断关键字(小写匹配)。 + *

这些诊断(LYRICS、ID3、APIC、封面、tag、atom、BOM、MALFORMED、Invalid Frame 等) + * 不代表音频流损坏,出现时不应拒绝文件。

+ */ + private static final String[] METADATA_DIAGNOSTIC_MARKERS = { + "lyrics", "id3", "apic", "cover", "attached pic", "tag", "atom", + "bom", "malformed", "invalid frame", + }; + + /** + * 明确属于音频流/解码器损坏的诊断关键字(小写匹配)。 + *

出现任一即判定完整解码失败。

+ */ + private static final String[] FATAL_DECODE_MARKERS = { + "invalid sync code", "invalid new backstep", "error while decoding", + "corrupt decoded frame", "corrupt input packet", "decoder error", + "header missing", "failed to decode audio", + }; + + /** + * 判断 exit=0 时的 FFmpeg 诊断是否包含明确的音频流/解码器错误。 + *

集中、可测试的规则:逐行扫描诊断,命中任一 {@link #FATAL_DECODE_MARKERS} + * 即视为致命;命中 {@link #METADATA_DIAGNOSTIC_MARKERS} 的行被视为元数据噪声而忽略。 + * 绝不使用简单的 {@code contains("error")} 判定。

+ * + * @param diagnostic FFmpeg stderr 诊断文本(可能为空) + * @return true 表示存在明确音频解码错误,应拒绝 + */ + static boolean hasFatalDecodeDiagnostic(String diagnostic) { + if (diagnostic == null || diagnostic.trim().isEmpty()) { + return false; + } + for (String rawLine : diagnostic.split("\\r?\\n")) { + String line = rawLine.toLowerCase().trim(); + if (line.isEmpty()) continue; + + // 命中明确的致命音频/解码器错误 → 立即判定失败 + if (containsAny(line, FATAL_DECODE_MARKERS)) { + return true; + } + // 明确的元数据类诊断(LYRICS/ID3/APIC/封面/tag/atom/BOM/MALFORMED/Invalid Frame) + // 显式忽略;其余未命中致命标记的行也一律不构成拒绝理由。 + if (containsAny(line, METADATA_DIAGNOSTIC_MARKERS)) { + continue; + } + } + return false; + } + + /** 判断行是否包含任一关键字(关键字须为小写,line 已小写化)。 */ + private static boolean containsAny(String line, String[] markers) { + for (String marker : markers) { + if (line.contains(marker)) { + return true; + } + } + return false; } /** @@ -627,9 +706,8 @@ public class AudioValidationService { /** * 运行一个外部进程,并发读取输出,超时处理。 - *

超时后自动 {@link Process#destroyForcibly()} 并以有界等待回收。 - * drainer 线程为守护线程,保证不阻塞 JVM 退出。 - * 调用线程的中断不会泄漏子进程:强制终止、有界回收、先 join drainer 确保输出被读取、再关闭流。

+ *

stdout 使用默认诊断上限,stderr 使用 {@link #MAX_DIAGNOSTIC_LENGTH}。 + * 适用于不需要完整机器可读 stdout 的场景(如 FFmpeg 解码)。

*/ private static ProcessResult runProcess(List command, int timeoutSeconds) throws IOException { @@ -637,16 +715,22 @@ public class AudioValidationService { } /** - * 运行一个外部进程,并发读取合并后的标准输出,最多保留 {@code maxCapture} 字节。 - *

诊断类调用使用 {@link #MAX_DIAGNOSTIC_LENGTH};需要完整机器可读输出 - * (如 FFprobe JSON)时传入更大的上限。超出上限的数据仍被读取以排空管道。

+ * 运行一个外部进程,分别并发读取 stdout 与 stderr,超时处理。 + *

不再合并两个管道:stdout 最多保留 {@code stdoutCap} 字节(FFprobe JSON 场景传入 + * 较大上限以容纳完整机器可读输出),stderr 固定保留 {@link #MAX_DIAGNOSTIC_LENGTH} 字节 + * 作有界诊断。两个管道都被完整排空(超出上限的数据仍读走以防子进程因管道写满阻塞)。

+ *

超时后 {@link Process#destroyForcibly()} 并有界回收;先 join 两个 drainer 确保输出读尽, + * 再关闭流;调用线程中断被保留并传播,不泄漏子进程。

*/ - private static ProcessResult runProcess(List command, int timeoutSeconds, int maxCapture) + private static ProcessResult runProcess(List command, int timeoutSeconds, int stdoutCap) throws IOException { ProcessBuilder pb = new ProcessBuilder(command); - pb.redirectErrorStream(true); + // 关键:不合并 stderr 到 stdout,避免 FFprobe 警告污染结构化 JSON。 Process p = pb.start(); - DrainResult drain = startConcurrentDrainer(p, maxCapture); + DrainResult outDrain = startConcurrentDrainer( + p.getInputStream(), stdoutCap, "audio-validation-stdout-drainer"); + DrainResult errDrain = startConcurrentDrainer( + p.getErrorStream(), MAX_DIAGNOSTIC_LENGTH, "audio-validation-stderr-drainer"); boolean interrupted = false; boolean finished; @@ -667,42 +751,50 @@ public class AudioValidationService { } } - // 先 join drainer 线程确保所有输出已读取完毕,再关闭流。 - // 顺序不能反:若先关闭流,drainer 可能尚未读取完输出, - // 导致 fast-exit 子进程的 error 诊断丢失(FFmpeg 6.1.1 等)。 + // 先 join 两个 drainer 线程确保输出读取完毕,再关闭流。 + // 顺序不能反:若先关闭流,drainer 可能尚未读完输出, + // 导致 fast-exit 子进程的诊断丢失(FFmpeg 6.1.1 等)。 try { - drain.thread.join(TimeUnit.SECONDS.toMillis(CLEANUP_WAIT_SECONDS)); + outDrain.thread.join(TimeUnit.SECONDS.toMillis(CLEANUP_WAIT_SECONDS)); + } catch (InterruptedException e) { + interrupted = true; + } + try { + errDrain.thread.join(TimeUnit.SECONDS.toMillis(CLEANUP_WAIT_SECONDS)); } catch (InterruptedException e) { interrupted = true; } - // drainer 结束后安全关闭子进程输出流 + // drainer 结束后安全关闭子进程流 try { p.getInputStream().close(); } catch (IOException ignored) { } + try { + p.getErrorStream().close(); + } catch (IOException ignored) { + } if (interrupted) { Thread.currentThread().interrupt(); } if (!finished) { - return new ProcessResult(true, -1, drain.getOutput()); + return new ProcessResult(true, -1, outDrain.getOutput(), errDrain.getOutput()); } - return new ProcessResult(false, p.exitValue(), drain.getOutput()); + return new ProcessResult(false, p.exitValue(), outDrain.getOutput(), errDrain.getOutput()); } /** - * 启动守护线程并发读取进程标准输出(合并后),保留前 {@code maxCapture} - * 字节,继续读取剩余数据以排空管道,防止子进程因管道写满而阻塞或收到 SIGPIPE。 + * 启动守护线程并发读取给定流,保留前 {@code maxCapture} 字节,继续读取剩余数据以排空管道, + * 防止子进程因管道写满而阻塞或收到 SIGPIPE。 */ - private static DrainResult startConcurrentDrainer(Process p, int maxCapture) { + private static DrainResult startConcurrentDrainer(InputStream is, int maxCapture, String name) { ByteArrayOutputStream buffer = new ByteArrayOutputStream(Math.min(maxCapture, BUFFER_SIZE)); Thread t = new Thread(() -> { try { byte[] buf = new byte[BUFFER_SIZE]; int n; - InputStream is = p.getInputStream(); while ((n = is.read(buf)) != -1) { if (buffer.size() < maxCapture) { int remaining = maxCapture - buffer.size(); @@ -713,7 +805,7 @@ public class AudioValidationService { } catch (IOException ignored) { // 进程终止后正常关闭 } - }, "audio-validation-drainer"); + }, name); t.setDaemon(true); t.start(); return new DrainResult(t, buffer); diff --git a/backend/src/test/java/com/music/service/AudioValidationServiceTest.java b/backend/src/test/java/com/music/service/AudioValidationServiceTest.java index 02b7f4c..97cbb9e 100644 --- a/backend/src/test/java/com/music/service/AudioValidationServiceTest.java +++ b/backend/src/test/java/com/music/service/AudioValidationServiceTest.java @@ -593,6 +593,177 @@ class AudioValidationServiceTest { assertFalse(reencodeArgs.contains("copy"), "重编码路径不应带 -c copy"); } + // ========== 解码诊断分类(集中规则单元测试) ========== + + @Test + void classifier_ignoresMetadataDiagnostics() { + // LYRICS/ID3/APIC/封面/tag/atom/BOM/MALFORMED/Invalid Frame 等元数据诊断不应判失败 + assertFalse(AudioValidationService.hasFatalDecodeDiagnostic( + "[id3v2 @ 0x..] invalid frame in LYRICS with BOM")); + assertFalse(AudioValidationService.hasFatalDecodeDiagnostic( + "[mp3 @ 0x..] Estimating duration from bitrate, this may be inaccurate")); + assertFalse(AudioValidationService.hasFatalDecodeDiagnostic( + "[mov,mp4 @ 0x..] Found unknown-length atom 'skip'")); + assertFalse(AudioValidationService.hasFatalDecodeDiagnostic( + "APIC cover art frame malformed; ignoring")); + assertFalse(AudioValidationService.hasFatalDecodeDiagnostic("")); + assertFalse(AudioValidationService.hasFatalDecodeDiagnostic(null)); + } + + @Test + void classifier_rejectsRealDecoderErrors() { + assertTrue(AudioValidationService.hasFatalDecodeDiagnostic( + "[mp3float @ 0x..] invalid new backstep 123")); + assertTrue(AudioValidationService.hasFatalDecodeDiagnostic( + "[mp3 @ 0x..] Header missing")); + assertTrue(AudioValidationService.hasFatalDecodeDiagnostic( + "Error while decoding stream #0:0: Invalid data found")); + assertTrue(AudioValidationService.hasFatalDecodeDiagnostic( + "[flac @ 0x..] invalid sync code")); + assertTrue(AudioValidationService.hasFatalDecodeDiagnostic( + "corrupt decoded frame in stream 0")); + assertTrue(AudioValidationService.hasFatalDecodeDiagnostic( + "[aac @ 0x..] decoder error")); + assertTrue(AudioValidationService.hasFatalDecodeDiagnostic( + "Failed to decode audio")); + } + + @Test + void classifier_doesNotUseNaiveContainsError() { + // 含 "error" 字样但非明确音频解码错误的行不应被判失败 + assertFalse(AudioValidationService.hasFatalDecodeDiagnostic( + "[http @ 0x..] error resolving proxy (ignored, not a decode error)")); + // 但一旦某行命中明确致命标记,即使其它行是噪声也应失败 + assertTrue(AudioValidationService.hasFatalDecodeDiagnostic( + "some benign warning line\n[mp3float @ 0x..] invalid new backstep 5")); + } + + // ========== stdout/stderr 分离:stderr 警告不污染 JSON ========== + + @Test + void readMetadata_stderrWarningDoesNotPolluteJson() throws Exception { + Path tmpDir = Files.createTempDirectory("val-stderr-"); + try { + // 伪 ffprobe:stdout 输出合法 JSON,stderr 输出警告噪声。 + // 合并两路会破坏 JSON;分离后应仍能解析。 + Path fake = writeScript(tmpDir, "fake_ffprobe.sh", + "#!/bin/sh", + "echo 'ffprobe version 6.1.1 warning: deprecated pixel format' >&2", + "echo '[NULL @ 0x] Some stderr diagnostic noise' >&2", + "cat <<'JSON'", + "{\"streams\":[{\"codec_type\":\"audio\",\"codec_name\":\"aac\"," + + "\"tags\":{\"title\":\"T\",\"artist\":\"A\",\"album\":\"Al\"}}]," + + "\"format\":{\"tags\":{}}}", + "JSON"); + System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, fake.toString()); + + AudioValidationService.ProbeMetadata meta = + service.readMetadata(Paths.get("/tmp/whatever.m4a")); + assertTrue(meta.isAvailable(), "stderr 警告不应破坏 stdout JSON 解析"); + assertEquals("T", meta.getFirst("title")); + assertEquals("A", meta.getFirst("artist")); + assertEquals("Al", meta.getFirst("album")); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void readMetadata_invalidStdoutJsonUnavailable() throws Exception { + Path tmpDir = Files.createTempDirectory("val-stderr-"); + try { + // stdout 非法 JSON(exit=0)→ 解析失败应返回 unavailable,而非崩溃 + Path fake = writeScript(tmpDir, "fake_ffprobe.sh", + "#!/bin/sh", + "echo 'not valid json at all {{{' ", + "exit 0"); + System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, fake.toString()); + + AudioValidationService.ProbeMetadata meta = + service.readMetadata(Paths.get("/tmp/whatever.m4a")); + assertFalse(meta.isAvailable(), "非法 stdout JSON 应返回 unavailable"); + } finally { + deleteDirectory(tmpDir); + } + } + + // ========== 完整解码判定:exit=0 元数据噪声 vs 真实解码错误 ========== + + @Test + void validate_exitZeroWithLyricsBomNoise_passes() throws Exception { + assumeFfmpegFfprobe(); + Path tmpDir = Files.createTempDirectory("val-decode-"); + try { + Path mp3 = createValidMp3(tmpDir, "test.mp3"); + // 伪 ffmpeg:stderr 输出 LYRICS/BOM 元数据噪声但 exit=0 → 应验证通过 + Path fake = writeScript(tmpDir, "fake_ffmpeg.sh", + "#!/bin/sh", + "echo '[id3v2 @ 0x] Incorrect BOM value in LYRICS tag, ignoring' >&2", + "echo '[mp3 @ 0x] Skipping 0 bytes of junk at 0' >&2", + "exit 0"); + System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, fake.toString()); + + AudioValidationService.ValidationResult r = service.validate(mp3); + assertTrue(r.isValid(), + "exit=0 且仅 LYRICS/BOM 元数据噪声应验证通过,实际诊断: " + r.getDiagnostic()); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void validate_exitZeroWithRealDecodeError_fails() throws Exception { + assumeFfmpegFfprobe(); + Path tmpDir = Files.createTempDirectory("val-decode-"); + try { + Path mp3 = createValidMp3(tmpDir, "test.mp3"); + // 伪 ffmpeg:stderr 明确音频解码错误但 exit=0 → 应验证失败 + Path fake = writeScript(tmpDir, "fake_ffmpeg.sh", + "#!/bin/sh", + "echo '[mp3float @ 0x] invalid new backstep 182' >&2", + "echo 'Error while decoding stream #0:0' >&2", + "exit 0"); + System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, fake.toString()); + + AudioValidationService.ValidationResult r = service.validate(mp3); + assertFalse(r.isValid(), "exit=0 但含明确解码错误应验证失败"); + assertTrue(r.getDiagnostic().contains("音频解码失败"), r.getDiagnostic()); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void validate_nonZeroExit_failsWithBoundedDiagnostic() throws Exception { + assumeFfmpegFfprobe(); + Path tmpDir = Files.createTempDirectory("val-decode-"); + try { + Path mp3 = createValidMp3(tmpDir, "test.mp3"); + // 伪 ffmpeg:非零退出码 → 必须验证失败并保留有界诊断 + Path fake = writeScript(tmpDir, "fake_ffmpeg.sh", + "#!/bin/sh", + "echo 'fatal: conversion failed' >&2", + "exit 1"); + System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, fake.toString()); + + AudioValidationService.ValidationResult r = service.validate(mp3); + assertFalse(r.isValid(), "非零退出码应验证失败"); + assertTrue(r.getDiagnostic().contains("音频解码失败"), r.getDiagnostic()); + assertTrue(r.getDiagnostic().length() <= AudioValidationService.MAX_DIAGNOSTIC_LENGTH + 64, + "诊断应有界"); + } finally { + deleteDirectory(tmpDir); + } + } + + /** 写入可执行 shell 脚本(每行一个参数),返回路径。 */ + private static Path writeScript(Path dir, String name, String... lines) throws Exception { + Path p = dir.resolve(name); + Files.write(p, Arrays.asList(lines), StandardCharsets.UTF_8); + p.toFile().setExecutable(true); + return p; + } + private static Path createM4aWithCover(Path dir, String name) throws Exception { Path cover = dir.resolve(".cover_src.png"); runProcessLocal(new ProcessBuilder( diff --git a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java index c49f5ab..d6428a3 100644 --- a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java +++ b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java @@ -2009,6 +2009,112 @@ class IngestServiceE2ETest { org.junit.jupiter.api.Assumptions.assumeTrue(cond, msg); } + // ========== 解码 stderr 元数据噪声不误判 Unreadable ========== + + @Test + void decodeMetadataWarning_doesNotClassifyUnreadable() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-decode-noise-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + // 合法带封面 MP3 + Path src = createTaggedMp3(inputDir, "song.mp3", + "Noise Title", "Noise Artist", "Noise Album", true); + + // ffmpeg 包装脚本:完整解码步骤(-f null -)先跑真实 ffmpeg, + // 再向 stderr 追加 LYRICS/BOM 元数据噪声但保持 exit=0。 + // 其它步骤(如封面提取)透传真实 ffmpeg 行为。 + Path wrapper = tmpDir.resolve("ffmpeg-noise.sh"); + String realFfmpeg = resolveFfmpegPath(); + Files.write(wrapper, Arrays.asList( + "#!/bin/sh", + "REAL_FF=\"" + realFfmpeg + "\"", + "\"$REAL_FF\" \"$@\"", + "rc=$?", + // 仅当这是完整解码验证调用(-f null -)时注入元数据噪声 + "for a in \"$@\"; do", + " if [ \"$a\" = \"null\" ]; then", + " echo '[id3v2 @ 0x] Incorrect BOM value in LYRICS tag, ignoring' >&2", + " echo '[mp3 @ 0x] Skipping junk atom' >&2", + " fi", + "done", + "exit $rc" + )); + wrapper.toFile().setExecutable(true); + + IngestService service = buildServiceWithValidation(); + String oldBin = System.setProperty("mangtool.ffmpeg.bin", + wrapper.toAbsolutePath().toString()); + try { + AtomicInteger ingested = new AtomicInteger(); + AtomicInteger unreadable = new AtomicInteger(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + ingested, new AtomicInteger(), + new AtomicInteger(), unreadable, + new AtomicInteger(), new AtomicInteger()); + + assertEquals("ingested", result, + "解码期 LYRICS/BOM 元数据噪声不应导致 Unreadable,实际: " + result); + assertEquals(0, unreadable.get(), "不应计入 unreadable"); + assertEquals(1, ingested.get()); + } finally { + if (oldBin != null) { + System.setProperty("mangtool.ffmpeg.bin", oldBin); + } else { + System.clearProperty("mangtool.ffmpeg.bin"); + } + } + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void trulyCorruptAudio_stillClassifiedUnreadable() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-decode-corrupt-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + // 合法带封面 MP3,随后截断破坏音频数据但保留 ID3 头(jaudiotagger 仍可读标签) + Path src = createTaggedMp3(inputDir, "corrupt.mp3", + "Corrupt Title", "Corrupt Artist", "Corrupt Album", true); + long truncated = Math.min(Files.size(src), 800); + try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(src.toFile(), "rw")) { + raf.setLength(truncated); + } + + IngestService service = buildServiceWithValidation(); + AtomicInteger ingested = new AtomicInteger(); + AtomicInteger unreadable = new AtomicInteger(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + ingested, new AtomicInteger(), + new AtomicInteger(), unreadable, + new AtomicInteger(), new AtomicInteger()); + + assertEquals("rejected:unreadable", result, "真实损坏音频仍应归类 Unreadable"); + assertEquals(1, unreadable.get()); + assertEquals(0, ingested.get()); + assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("corrupt.mp3")), + "损坏文件应进入 Rejected/Unreadable"); + } finally { + deleteDirectory(tmpDir); + } + } + // ========== 工具方法 ========== /**