fix: distinguish audio decode errors from metadata warnings

This commit is contained in:
2026-07-20 02:46:50 +08:00
parent 159eb2a3b5
commit 3462881a0a
3 changed files with 417 additions and 48 deletions
@@ -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 {
// 伪 ffprobestdout 输出合法 JSONstderr 输出警告噪声。
// 合并两路会破坏 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 非法 JSONexit=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");
// 伪 ffmpegstderr 输出 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");
// 伪 ffmpegstderr 明确音频解码错误但 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(
@@ -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);
}
}
// ========== 工具方法 ==========
/**