Template
fix: distinguish audio decode errors from metadata warnings
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 工具方法 ==========
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user