Template
fix: distinguish audio decode errors from metadata warnings
This commit is contained in:
@@ -193,16 +193,20 @@ public class AudioValidationService {
|
||||
|
||||
/**
|
||||
* {@link #runProcess(List, int)} 的返回结果。
|
||||
* <p>{@link #stdout} 为子进程标准输出(结构化 JSON 消费路径只解析它);
|
||||
* {@link #stderr} 为标准错误(仅用于有界诊断,绝不进入 JSON 解析)。</p>
|
||||
*/
|
||||
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);
|
||||
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("音频解码超时");
|
||||
}
|
||||
|
||||
// 同时检查退出码和 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);
|
||||
// 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();
|
||||
} 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()} 并以有界等待回收。
|
||||
* drainer 线程为守护线程,保证不阻塞 JVM 退出。
|
||||
* 调用线程的中断不会泄漏子进程:强制终止、有界回收、先 join drainer 确保输出被读取、再关闭流。</p>
|
||||
* <p>stdout 使用默认诊断上限,stderr 使用 {@link #MAX_DIAGNOSTIC_LENGTH}。
|
||||
* 适用于不需要完整机器可读 stdout 的场景(如 FFmpeg 解码)。</p>
|
||||
*/
|
||||
private static ProcessResult runProcess(List<String> command, int timeoutSeconds)
|
||||
throws IOException {
|
||||
@@ -637,16 +715,22 @@ public class AudioValidationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行一个外部进程,并发读取合并后的标准输出,最多保留 {@code maxCapture} 字节。
|
||||
* <p>诊断类调用使用 {@link #MAX_DIAGNOSTIC_LENGTH};需要完整机器可读输出
|
||||
* (如 FFprobe JSON)时传入更大的上限。超出上限的数据仍被读取以排空管道。</p>
|
||||
* 运行一个外部进程,<b>分别</b>并发读取 stdout 与 stderr,超时处理。
|
||||
* <p>不再合并两个管道:stdout 最多保留 {@code stdoutCap} 字节(FFprobe JSON 场景传入
|
||||
* 较大上限以容纳完整机器可读输出),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 {
|
||||
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);
|
||||
|
||||
@@ -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