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);
+ }
+ }
+
// ========== 工具方法 ==========
/**