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)} 的返回结果。
|
* {@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")) {
|
// 仅解析 stdout(codec_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 JSON,stderr 警告不参与
|
||||||
|
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),绝不来自 stdout(null 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 {
|
||||||
|
// 伪 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 {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 工具方法 ==========
|
// ========== 工具方法 ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user