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
@@ -193,16 +193,20 @@ public class AudioValidationService {
/** /**
* {@link #runProcess(List, int)} 的返回结果。 * {@link #runProcess(List, int)} 的返回结果。
* <p>{@link #stdout} 为子进程标准输出(结构化 JSON 消费路径只解析它);
* {@link #stderr} 为标准错误(仅用于有界诊断,绝不进入 JSON 解析)。</p>
*/ */
private static class ProcessResult { private static class ProcessResult {
final boolean timedOut; final boolean timedOut;
final int exitCode; 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.timedOut = timedOut;
this.exitCode = exitCode; 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) { if (result.exitCode != 0) {
String diag = capDiagnostic(result.output); String diag = capDiagnostic(result.stderr);
log.warn("FFprobe 探测失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag); log.warn("FFprobe 探测失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag);
return ValidationResult.invalid("FFprobe 探测失败: " + diag); return ValidationResult.invalid("FFprobe 探测失败: " + diag);
} }
if (!result.output.contains("audio")) { // 仅解析 stdoutcodec_type 列表),stderr 不参与判定
if (!result.stdout.contains("audio")) {
log.warn("FFprobe 未发现音频流: {}", audioFile); log.warn("FFprobe 未发现音频流: {}", audioFile);
return ValidationResult.invalid("文件中未发现可识别的音频流"); return ValidationResult.invalid("文件中未发现可识别的音频流");
} }
@@ -339,13 +344,14 @@ public class AudioValidationService {
return ProbeMetadata.unavailable("元数据读取超时"); return ProbeMetadata.unavailable("元数据读取超时");
} }
if (result.exitCode != 0) { if (result.exitCode != 0) {
String diag = capDiagnostic(result.output); String diag = capDiagnostic(result.stderr);
log.warn("FFprobe 元数据读取失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag); log.warn("FFprobe 元数据读取失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag);
return ProbeMetadata.unavailable("FFprobe 读取失败: " + diag); return ProbeMetadata.unavailable("FFprobe 读取失败: " + diag);
} }
try { try {
JsonNode root = JSON_MAPPER.readTree(result.output); // 只解析 stdout JSONstderr 警告不参与
JsonNode root = JSON_MAPPER.readTree(result.stdout);
if (!hasAudioStream(root)) { if (!hasAudioStream(root)) {
log.warn("FFprobe 后备未发现音频流: {}", audioFile); log.warn("FFprobe 后备未发现音频流: {}", audioFile);
return ProbeMetadata.unavailable("文件中未发现可识别的音频流"); return ProbeMetadata.unavailable("文件中未发现可识别的音频流");
@@ -389,7 +395,7 @@ public class AudioValidationService {
return ""; return "";
} }
try { try {
JsonNode root = JSON_MAPPER.readTree(result.output); JsonNode root = JSON_MAPPER.readTree(result.stdout);
JsonNode streams = root.path("streams"); JsonNode streams = root.path("streams");
if (streams.isArray()) { if (streams.isArray()) {
for (JsonNode stream : streams) { for (JsonNode stream : streams) {
@@ -467,7 +473,7 @@ public class AudioValidationService {
return EmbeddedArtwork.absent(); return EmbeddedArtwork.absent();
} }
try { try {
JsonNode root = JSON_MAPPER.readTree(result.output); JsonNode root = JSON_MAPPER.readTree(result.stdout);
JsonNode streams = root.path("streams"); JsonNode streams = root.path("streams");
if (streams.isArray()) { if (streams.isArray()) {
for (JsonNode stream : streams) { for (JsonNode stream : streams) {
@@ -529,7 +535,7 @@ public class AudioValidationService {
try { try {
ProcessResult result = runProcess(cmd, timeout); ProcessResult result = runProcess(cmd, timeout);
if (result.timedOut || result.exitCode != 0) { if (result.timedOut || result.exitCode != 0) {
String diag = capDiagnostic(result.output); String diag = capDiagnostic(result.stderr);
log.warn("封面提取失败: {} (exit={}) diag={}", media, result.exitCode, diag); log.warn("封面提取失败: {} (exit={}) diag={}", media, result.exitCode, diag);
deleteQuietly(coverTarget); deleteQuietly(coverTarget);
return false; return false;
@@ -580,28 +586,101 @@ public class AudioValidationService {
*/ */
private ValidationResult runDecode(Path audioFile) { private ValidationResult runDecode(Path audioFile) {
int timeout = resolveDecodeTimeout(); int timeout = resolveDecodeTimeout();
ProcessResult result;
try { try {
ProcessResult result = runProcess(getDecodeCommandArgs(audioFile), timeout); 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) { if (result.timedOut) {
log.warn("FFmpeg 解码超时: {} ({}s)", audioFile, timeout); log.warn("FFmpeg 解码超时: {} ({}s)", audioFile, timeout);
return ValidationResult.invalid("音频解码超时"); return ValidationResult.invalid("音频解码超时");
} }
// 同时检查退出码和 error 级别输出: // FFmpeg 诊断只在 stderr-v error),绝不来自 stdoutnull muxer 无有意义 stdout
// -xerror 下 exitCode != 0 表示 fatal 错误 String diagRaw = result.stderr;
// 但某些 FFmpeg 版本(如 6.1.1)的部分解码错误仅输出到 stderr 而不改变退出码 if (result.exitCode != 0) {
String trimmedOutput = result.output.trim(); String diag = capDiagnostic(diagRaw);
if (result.exitCode != 0 || !trimmedOutput.isEmpty()) { log.warn("FFmpeg 解码失败(非零退出): {} (exit={}) diag={}", audioFile, result.exitCode, diag);
String diag = capDiagnostic(result.output); return ValidationResult.invalid("音频解码失败: " + diag);
log.warn("FFmpeg 解码失败: {} (exit={}) diag={}", audioFile, result.exitCode, 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.invalid("音频解码失败: " + diag);
} }
return ValidationResult.valid(); return ValidationResult.valid();
} catch (IOException e) {
log.warn("FFmpeg 执行失败: {} - {}", audioFile, e.getMessage());
return ValidationResult.invalid("FFmpeg 执行失败: " + e.getMessage());
} }
// ========== 解码诊断分类(集中、可测试) ==========
/**
* 明确属于非音频/元数据层面的诊断关键字(小写匹配)。
* <p>这些诊断(LYRICS、ID3、APIC、封面、tag、atom、BOM、MALFORMED、Invalid Frame 等)
* 不代表音频流损坏,出现时不应拒绝文件。</p>
*/
private static final String[] METADATA_DIAGNOSTIC_MARKERS = {
"lyrics", "id3", "apic", "cover", "attached pic", "tag", "atom",
"bom", "malformed", "invalid frame",
};
/**
* 明确属于音频流/解码器损坏的诊断关键字(小写匹配)。
* <p>出现任一即判定完整解码失败。</p>
*/
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 诊断是否包含明确的音频流/解码器错误。
* <p>集中、可测试的规则:<b>逐行</b>扫描诊断,命中任一 {@link #FATAL_DECODE_MARKERS}
* 即视为致命;命中 {@link #METADATA_DIAGNOSTIC_MARKERS} 的行被视为元数据噪声而忽略。
* 绝不使用简单的 {@code contains("error")} 判定。</p>
*
* @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 {
/** /**
* 运行一个外部进程,并发读取输出,超时处理。 * 运行一个外部进程,并发读取输出,超时处理。
* <p>超时后自动 {@link Process#destroyForcibly()} 并以有界等待回收 * <p>stdout 使用默认诊断上限,stderr 使用 {@link #MAX_DIAGNOSTIC_LENGTH}
* drainer 线程为守护线程,保证不阻塞 JVM 退出。 * 适用于不需要完整机器可读 stdout 的场景(如 FFmpeg 解码)。</p>
* 调用线程的中断不会泄漏子进程:强制终止、有界回收、先 join drainer 确保输出被读取、再关闭流。</p>
*/ */
private static ProcessResult runProcess(List<String> command, int timeoutSeconds) private static ProcessResult runProcess(List<String> command, int timeoutSeconds)
throws IOException { throws IOException {
@@ -637,16 +715,22 @@ public class AudioValidationService {
} }
/** /**
* 运行一个外部进程,并发读取合并后的标准输出,最多保留 {@code maxCapture} 字节 * 运行一个外部进程,<b>分别</b>并发读取 stdout 与 stderr,超时处理
* <p>诊断类调用使用 {@link #MAX_DIAGNOSTIC_LENGTH};需要完整机器可读输出 * <p>不再合并两个管道:stdout 最多保留 {@code stdoutCap} 字节(FFprobe JSON 场景传入
* (如 FFprobe JSON)时传入更大的上限。超出上限的数据仍被读取以排空管道。</p> * 较大上限以容纳完整机器可读输出),stderr 固定保留 {@link #MAX_DIAGNOSTIC_LENGTH} 字节
* 作有界诊断。两个管道都被完整排空(超出上限的数据仍读走以防子进程因管道写满阻塞)。</p>
* <p>超时后 {@link Process#destroyForcibly()} 并有界回收;先 join 两个 drainer 确保输出读尽,
* 再关闭流;调用线程中断被保留并传播,不泄漏子进程。</p>
*/ */
private static ProcessResult runProcess(List<String> command, int timeoutSeconds, int maxCapture) private static ProcessResult runProcess(List<String> command, int timeoutSeconds, int stdoutCap)
throws IOException { throws IOException {
ProcessBuilder pb = new ProcessBuilder(command); ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true); // 关键:不合并 stderr 到 stdout,避免 FFprobe 警告污染结构化 JSON。
Process p = pb.start(); 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 interrupted = false;
boolean finished; boolean finished;
@@ -667,42 +751,50 @@ public class AudioValidationService {
} }
} }
// 先 join drainer 线程确保所有输出读取完毕,再关闭流。 // 先 join 两个 drainer 线程确保输出读取完毕,再关闭流。
// 顺序不能反:若先关闭流,drainer 可能尚未读完输出, // 顺序不能反:若先关闭流,drainer 可能尚未读完输出,
// 导致 fast-exit 子进程的 error 诊断丢失(FFmpeg 6.1.1 等)。 // 导致 fast-exit 子进程的诊断丢失(FFmpeg 6.1.1 等)。
try { 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) { } catch (InterruptedException e) {
interrupted = true; interrupted = true;
} }
// drainer 结束后安全关闭子进程输出 // drainer 结束后安全关闭子进程流
try { try {
p.getInputStream().close(); p.getInputStream().close();
} catch (IOException ignored) { } catch (IOException ignored) {
} }
try {
p.getErrorStream().close();
} catch (IOException ignored) {
}
if (interrupted) { if (interrupted) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
if (!finished) { 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} * 启动守护线程并发读取给定流,保留前 {@code maxCapture} 字节,继续读取剩余数据以排空管道,
* 字节,继续读取剩余数据以排空管道,防止子进程因管道写满而阻塞或收到 SIGPIPE。 * 防止子进程因管道写满而阻塞或收到 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)); ByteArrayOutputStream buffer = new ByteArrayOutputStream(Math.min(maxCapture, BUFFER_SIZE));
Thread t = new Thread(() -> { Thread t = new Thread(() -> {
try { try {
byte[] buf = new byte[BUFFER_SIZE]; byte[] buf = new byte[BUFFER_SIZE];
int n; int n;
InputStream is = p.getInputStream();
while ((n = is.read(buf)) != -1) { while ((n = is.read(buf)) != -1) {
if (buffer.size() < maxCapture) { if (buffer.size() < maxCapture) {
int remaining = maxCapture - buffer.size(); int remaining = maxCapture - buffer.size();
@@ -713,7 +805,7 @@ public class AudioValidationService {
} catch (IOException ignored) { } catch (IOException ignored) {
// 进程终止后正常关闭 // 进程终止后正常关闭
} }
}, "audio-validation-drainer"); }, name);
t.setDaemon(true); t.setDaemon(true);
t.start(); t.start();
return new DrainResult(t, buffer); return new DrainResult(t, buffer);
@@ -593,6 +593,177 @@ class AudioValidationServiceTest {
assertFalse(reencodeArgs.contains("copy"), "重编码路径不应带 -c copy"); 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 { private static Path createM4aWithCover(Path dir, String name) throws Exception {
Path cover = dir.resolve(".cover_src.png"); Path cover = dir.resolve(".cover_src.png");
runProcessLocal(new ProcessBuilder( runProcessLocal(new ProcessBuilder(
@@ -2009,6 +2009,112 @@ class IngestServiceE2ETest {
org.junit.jupiter.api.Assumptions.assumeTrue(cond, msg); 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);
}
}
// ========== 工具方法 ========== // ========== 工具方法 ==========
/** /**