Validate audio integrity before ingestion

This commit is contained in:
2026-07-19 11:50:49 +08:00
parent 2648cec97b
commit 761956f047
6 changed files with 1454 additions and 14 deletions
@@ -0,0 +1,492 @@
package com.music.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* {@link AudioValidationService} 的单元与集成测试。
*
* <p>依赖 FFprobe/FFmpeg 的测试通过 {@link #assumeFfmpegFfprobe()} 守卫,
* 工具不可用时自动跳过。</p>
*/
class AudioValidationServiceTest {
private static boolean FFMPEG_AVAILABLE;
private final AudioValidationService service = new AudioValidationService();
@BeforeAll
static void checkFfmpegFfprobe() throws Exception {
try {
ProcessBuilder pb = new ProcessBuilder("ffmpeg", "-version");
pb.redirectErrorStream(true);
Process p = pb.start();
boolean ok = p.waitFor(5, TimeUnit.SECONDS);
FFMPEG_AVAILABLE = ok && p.exitValue() == 0;
if (!ok) p.destroyForcibly();
} catch (Exception e) {
FFMPEG_AVAILABLE = false;
}
if (FFMPEG_AVAILABLE) {
// ffprobe 通常随 ffmpeg 一起安装
try {
ProcessBuilder pb = new ProcessBuilder("ffprobe", "-version");
pb.redirectErrorStream(true);
Process p = pb.start();
boolean ok = p.waitFor(5, TimeUnit.SECONDS);
FFMPEG_AVAILABLE = ok && p.exitValue() == 0;
if (!ok) p.destroyForcibly();
} catch (Exception e) {
FFMPEG_AVAILABLE = false;
}
}
}
private static void assumeFfmpegFfprobe() {
assumeTrue(FFMPEG_AVAILABLE, "此测试需要 ffmpeg 和 ffprobe");
}
@AfterEach
void clearProperties() {
System.clearProperty(AudioValidationService.FFPROBE_BIN_PROPERTY);
System.clearProperty(AudioValidationService.FFMPEG_BIN_PROPERTY);
System.clearProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY);
System.clearProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY);
}
// ========== 可执行路径解析 ==========
@Test
void getFfprobeCommand_defaultIsFfprobe() {
assertEquals("ffprobe", service.getFfprobeCommand());
}
@Test
void getFfprobeCommand_respectsSystemProperty() {
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/custom/ffprobe");
assertEquals("/custom/ffprobe", service.getFfprobeCommand());
}
@Test
void getFfmpegCommand_defaultIsFfmpeg() {
assertEquals("ffmpeg", service.getFfmpegCommand());
}
@Test
void getFfmpegCommand_respectsSystemProperty() {
System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/custom/ffmpeg");
assertEquals("/custom/ffmpeg", service.getFfmpegCommand());
}
// ========== 超时解析 ==========
@Test
void resolveProbeTimeout_default() {
assertEquals(30, service.resolveProbeTimeout());
}
@Test
void resolveProbeTimeout_respectsSystemProperty() {
System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "15");
assertEquals(15, service.resolveProbeTimeout());
}
@Test
void resolveProbeTimeout_ignoresInvalidProperty() {
System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "not-a-number");
assertEquals(30, service.resolveProbeTimeout());
}
@Test
void resolveDecodeTimeout_default() {
assertEquals(300, service.resolveDecodeTimeout());
}
@Test
void resolveDecodeTimeout_respectsSystemProperty() {
System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "60");
assertEquals(60, service.resolveDecodeTimeout());
}
@Test
void resolveDecodeTimeout_ignoresInvalidProperty() {
System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "");
assertEquals(300, service.resolveDecodeTimeout());
}
// ========== 超时解析(零值/负值回退) ==========
@Test
void resolveProbeTimeout_negativeFallsBackToDefault() {
System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "-1");
assertEquals(30, service.resolveProbeTimeout());
}
@Test
void resolveProbeTimeout_zeroFallsBackToDefault() {
System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "0");
assertEquals(30, service.resolveProbeTimeout());
}
@Test
void resolveDecodeTimeout_negativeFallsBackToDefault() {
System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "-5");
assertEquals(300, service.resolveDecodeTimeout());
}
@Test
void resolveDecodeTimeout_zeroFallsBackToDefault() {
System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "0");
assertEquals(300, service.resolveDecodeTimeout());
}
// ========== ValidationResult ==========
@Test
void validationResult_diagnosticCappedAtMaxLength() {
String longDiag = new String(new char[600]).replace('\0', 'X');
AudioValidationService.ValidationResult r =
new AudioValidationService.ValidationResult(false, longDiag);
assertEquals(512, r.getDiagnostic().length());
assertFalse(r.isValid());
}
@Test
void validationResult_nullDiagnosticBecomesEmpty() {
AudioValidationService.ValidationResult r =
new AudioValidationService.ValidationResult(true, null);
assertTrue(r.isValid());
assertEquals("", r.getDiagnostic());
}
// ========== 集成测试(需要 FFmpeg + FFprobe ==========
@Test
void validMp3_passesValidation() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "test.mp3");
AudioValidationService.ValidationResult r = service.validate(mp3);
assertTrue(r.isValid(), "有效 MP3 应通过验证,诊断: " + r.getDiagnostic());
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void nonAudioFile_failsProbe() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path garbage = tmpDir.resolve("notaudio.mp3");
Files.write(garbage, new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9});
AudioValidationService.ValidationResult r = service.validate(garbage);
assertFalse(r.isValid(), "非音频文件应验证失败");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void decode_failsWhenFfmpegExitsNonZero() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 使用 /bin/false 模拟 ffmpeg 解码失败(ffprobe 使用真实工具先通过探测)
System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/bin/false");
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(), "ffmpeg 退出码非零时应验证失败");
assertTrue(r.getDiagnostic().contains("音频解码失败"),
"诊断信息应包含解码失败提示,实际: " + r.getDiagnostic());
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void decode_failsWhenOutputNonEmptyEvenIfExitZero() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 创建一个模拟脚本:输出错误诊断到 stderr 但退出码为 0
// 这模拟了 FFmpeg 6.1.1 中部分解码错误(如 invalid residual
// 仅输出到 error 级别而不设置非零退出码的情况
Path fakeFfmpeg = tmpDir.resolve("fake_ffmpeg.sh");
Files.write(fakeFfmpeg, Arrays.asList(
"#!/bin/sh",
"echo 'decoder error: invalid residual' >&2",
"exit 0"
));
fakeFfmpeg.toFile().setExecutable(true);
System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, fakeFfmpeg.toString());
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(),
"ffmpeg 退出码 0 但有 error 输出时应验证失败");
assertTrue(r.getDiagnostic().contains("音频解码失败"),
"诊断信息应包含解码失败提示,实际: " + r.getDiagnostic());
assertTrue(r.getDiagnostic().contains("invalid residual"),
"诊断信息应包含原始的 error 输出,实际: " + r.getDiagnostic());
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void truncatedFile_failsProbe() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "trunc_test.mp3");
// 截断到 100 字节,保留 ID3v2 标签头但音频数据已不完整
long truncatedSize = Math.min(Files.size(mp3), 100);
try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(mp3.toFile(), "rw")) {
raf.setLength(truncatedSize);
}
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(), "截断的文件应验证失败");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void missingExecutable_failsGracefully() throws Exception {
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 使用不存在的 ffprobe 路径
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/nonexistent/ffprobe");
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(), "ffprobe 不可用时验证应失败");
assertTrue(r.getDiagnostic().contains("FFprobe 执行失败"));
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void missingFfmpeg_failsDecodeGracefully() throws Exception {
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 使用不存在的 ffmpeg 路径
System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/nonexistent/ffmpeg");
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(), "ffmpeg 不可用时验证应失败");
assertTrue(r.getDiagnostic().contains("FFmpeg 执行失败"));
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 超时集成测试(需要 FFmpeg + FFprobe ==========
@Test
void probe_timesOut() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 创建一个忽略参数并 sleep 30s 的挂起脚本
Path hangScript = tmpDir.resolve("hang.sh");
Files.write(hangScript, Arrays.asList("#!/bin/sh", "sleep 30"));
hangScript.toFile().setExecutable(true);
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, hangScript.toString());
System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "1");
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(), "探测超时应验证失败");
assertTrue(r.getDiagnostic().contains("音频探测超时"),
"诊断应提示超时,实际: " + r.getDiagnostic());
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void decode_timesOut() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 创建一个忽略参数并 sleep 30s 的挂起脚本
Path hangScript = tmpDir.resolve("hang.sh");
Files.write(hangScript, Arrays.asList("#!/bin/sh", "sleep 30"));
hangScript.toFile().setExecutable(true);
System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, hangScript.toString());
System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "1");
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(), "解码超时应验证失败");
assertTrue(r.getDiagnostic().contains("音频解码超时"),
"诊断应提示解码超时,实际: " + r.getDiagnostic());
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void decodeCommand_usesXerrorAndMapArgs() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path mp3 = createValidMp3(tmpDir, "test.mp3");
List<String> args = service.getDecodeCommandArgs(mp3);
assertTrue(args.contains("-xerror"),
"decode 命令应包含 -xerror,实际参数: " + args);
assertTrue(args.contains("-map"),
"decode 命令应包含 -map,实际参数: " + args);
int mapIndex = args.indexOf("-map");
assertTrue(mapIndex >= 0 && mapIndex + 1 < args.size(),
"-map 参数后应有值");
assertEquals("0:a:0", args.get(mapIndex + 1),
"-map 参数应指定 0:a:0");
assertTrue(args.contains("-f"),
"decode 命令应包含 -f");
assertTrue(args.contains("null"),
"解码 muxer 应为 null");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 排空测试(大量输出不阻塞) ==========
@Test
void drainer_doesNotDeadlockWithVerboseOutput() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
// 创建一个输出远大于操作系统管道缓冲区(通常 64KB)的脚本
Path verboseScript = tmpDir.resolve("verbose.sh");
Files.write(verboseScript, Arrays.asList(
"#!/bin/sh",
"# 输出 ~200KB 数据以填满管道缓冲区",
"yes A | head -c 200000",
"exit 0"
));
verboseScript.toFile().setExecutable(true);
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, verboseScript.toString());
System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "10");
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 需要 ffprobe 正常可用供 decode 阶段使用,但 probe 阶段使用 verbose 脚本
AudioValidationService.ValidationResult r = service.validate(mp3);
// verbose 脚本输出大量 'A',不含 "audio",预计探测失败
assertFalse(r.isValid(), "非音频输出应验证失败");
// 诊断信息必须被截断到 MAX_DIAGNOSTIC_LENGTH
assertTrue(r.getDiagnostic().length() <= 512,
"诊断信息不应超过 512 字节,实际: " + r.getDiagnostic().length());
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 中断清理测试 ==========
@Test
void interruptDuringProbe_cleansUpAndReinterrupts() throws Exception {
Path tmpDir = Files.createTempDirectory("val-test-");
try {
Path hangScript = tmpDir.resolve("hang.sh");
Files.write(hangScript, Arrays.asList("#!/bin/sh", "sleep 30"));
hangScript.toFile().setExecutable(true);
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, hangScript.toString());
System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "10");
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 先设置中断标志,再调用 validate
// p.waitFor(timeout, ...) 会在中断标志已设置时立即抛出 InterruptedException
Thread.currentThread().interrupt();
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(), "中断后验证应失败");
// runProcess 应在清理后重新设置中断标志
assertTrue(Thread.interrupted(), "中断标志应被保留并由 Thread.interrupted() 清除");
} finally {
Thread.interrupted(); // 清除主线程残留中断
deleteDirectory(tmpDir);
}
}
// ========== 帮助方法 ==========
/**
* 使用 FFmpeg 创建一个带标签的有效短 MP3 文件。
*/
private static Path createValidMp3(Path dir, String fileName) throws Exception {
Path out = dir.resolve(fileName);
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.2",
"-b:a", "32k",
"-write_xing", "0",
"-id3v2_version", "3",
"-metadata", "title=TestTitle",
"-metadata", "artist=TestArtist",
"-metadata", "album=TestAlbum",
out.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process p = pb.start();
readAllBytes(p.getInputStream());
boolean finished = p.waitFor(15, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
throw new RuntimeException("ffmpeg 创建 MP3 超时");
}
if (p.exitValue() != 0) {
throw new RuntimeException("ffmpeg 创建 MP3 失败 (exit=" + p.exitValue() + ")");
}
return out;
}
private static byte[] readAllBytes(InputStream is) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] tmp = new byte[4096];
int n;
while ((n = is.read(tmp)) != -1) {
buf.write(tmp, 0, n);
}
return buf.toByteArray();
}
private static void deleteDirectory(Path dir) {
try {
if (Files.exists(dir)) {
Files.walk(dir)
.sorted((a, b) -> -a.compareTo(b))
.forEach(p -> {
try { Files.deleteIfExists(p); } catch (Exception ignored) {}
});
}
} catch (Exception ignored) {}
}
}
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
@@ -17,6 +18,7 @@ import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -855,6 +857,359 @@ class IngestServiceE2ETest {
}
}
// ========== 4. 音频完整性验证测试 ==========
@Test
void corruptedAudioWithReadableTags_rejectedAsUnreadable() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-e2e-");
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);
Path src = createTaggedMp3(inputDir, "corrupt.mp3",
"Valid Title", "Valid Artist", "Valid Album");
// 截断文件到极小尺寸以破坏音频数据,但保留文件头部的 ID3v2 标签
// 使 jaudiotagger 仍可读取标签,但 FFprobe/FFmpeg 无法处理
long truncatedSize = Math.min(Files.size(src), 512);
try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(src.toFile(), "rw")) {
raf.setLength(truncatedSize);
}
// 确认 jaudiotagger 仍能读取标签
AudioFile af = AudioFileIO.read(src.toFile());
Tag tag = af.getTag();
assertNotNull(tag);
assertEquals("Valid Title", tag.getFirst(FieldKey.TITLE));
IngestService service = buildServiceWithValidation();
AtomicInteger ingested = new AtomicInteger();
AtomicInteger duplicates = new AtomicInteger();
AtomicInteger missingMeta = new AtomicInteger();
AtomicInteger unreadable = new AtomicInteger();
AtomicInteger convFailed = new AtomicInteger();
AtomicInteger otherRejected = new AtomicInteger();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
ingested, duplicates, missingMeta, unreadable,
convFailed, otherRejected);
assertEquals("rejected:unreadable", result);
assertEquals(1, unreadable.get());
assertEquals(0, ingested.get());
// 源文件应在 Rejected/Unreadable 下
assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("corrupt.mp3")),
"损坏文件应放入 Rejected/Unreadable");
// Library 下不应有文件
assertFalse(Files.list(libDir).findAny().isPresent(),
"Library 应为空");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void validTaggedMp3WithValidation_passesAndIngested() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-e2e-");
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);
Path src = createTaggedMp3(inputDir, "good.mp3",
"Good Title", "Good Artist", "Good Album");
IngestService service = buildServiceWithValidation();
AtomicInteger ingested = new AtomicInteger();
AtomicInteger duplicated = new AtomicInteger();
AtomicInteger missingMeta = new AtomicInteger();
AtomicInteger unreadable = new AtomicInteger();
AtomicInteger convFailed = new AtomicInteger();
AtomicInteger otherRejected = new AtomicInteger();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
ingested, duplicated, missingMeta, unreadable,
convFailed, otherRejected);
assertEquals("ingested", result);
assertEquals(1, ingested.get());
assertEquals(0, unreadable.get());
Path expected = libDir.resolve("Good Artist").resolve("Good Album").resolve("01 - Good Title.mp3");
assertTrue(Files.exists(expected), "有效文件应进入 Library");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void duplicateSkippedBeforeDecodeValidation() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-e2e-");
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);
// 导入第一个文件
Path src1 = createTaggedMp3(inputDir, "first.mp3",
"Dup Title", "Dup Artist", "Dup Album");
IngestService service = buildServiceWithValidation();
Set<Object> libraryIds = new HashSet<>();
Set<Object> batchIds = new HashSet<>();
AtomicInteger ingested1 = new AtomicInteger();
AtomicInteger duplicated1 = new AtomicInteger();
AtomicInteger missingMeta1 = new AtomicInteger();
AtomicInteger unreadable1 = new AtomicInteger();
AtomicInteger convFailed1 = new AtomicInteger();
AtomicInteger otherRejected1 = new AtomicInteger();
String result1 = invokeProcessSingleFile(service, src1, libDir, rejDir,
libraryIds, batchIds,
ingested1, duplicated1, missingMeta1, unreadable1,
convFailed1, otherRejected1);
assertEquals("ingested", result1);
// 第二个相同内容的文件应为 duplicate,不应触发解码验证
Path src2 = createTaggedMp3(inputDir, "second.mp3",
"Dup Title", "Dup Artist", "Dup Album");
// 截断第二个文件使其无法通过验证,但 duplicate 检测应提前阻止验证
long truncatedSize2 = Math.min(Files.size(src2), 512);
try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(src2.toFile(), "rw")) {
raf.setLength(truncatedSize2);
}
AtomicInteger ingested2 = new AtomicInteger();
AtomicInteger duplicated2 = new AtomicInteger();
AtomicInteger missingMeta2 = new AtomicInteger();
AtomicInteger unreadable2 = new AtomicInteger();
AtomicInteger convFailed2 = new AtomicInteger();
AtomicInteger otherRejected2 = new AtomicInteger();
String result2 = invokeProcessSingleFile(service, src2, libDir, rejDir,
libraryIds, batchIds,
ingested2, duplicated2, missingMeta2, unreadable2,
convFailed2, otherRejected2);
assertEquals("rejected:duplicate", result2,
"重复文件应在解码验证之前被拒绝");
assertEquals(1, duplicated2.get());
assertEquals(0, unreadable2.get(),
"重复文件不应触发解码验证,unreadable 应为 0");
assertEquals(1, ingested1.get());
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 3. 格式转换 + 完整性验证 ==========
@Test
void losslessFile_withValidationFails_cleansUpFlacAndRejects() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-e2e-");
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);
// 创建一个有标签的无损 WAV 文件,需要转换为 FLAC
Path src = createTaggedLossless(inputDir, "song.wav",
"Lossless Title", "Lossless Artist", "Lossless Album");
// 让 ffprobe 模拟失败,使验证在探测阶段即失败
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/bin/false");
IngestService service = buildServiceWithValidation();
AtomicInteger ingested = new AtomicInteger();
AtomicInteger duplicates = new AtomicInteger();
AtomicInteger missingMeta = new AtomicInteger();
AtomicInteger unreadable = new AtomicInteger();
AtomicInteger convFailed = new AtomicInteger();
AtomicInteger otherRejected = new AtomicInteger();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
ingested, duplicates, missingMeta, unreadable,
convFailed, otherRejected);
assertEquals("rejected:unreadable", result);
assertEquals(1, unreadable.get());
assertEquals(0, ingested.get());
assertEquals(0, convFailed.get(), "转换本身应成功");
// 转换产生的临时 FLAC 应被清理
Path expectedFlac = inputDir.resolve("song.flac");
assertFalse(Files.exists(expectedFlac), "临时 FLAC 应在验证失败后被清理");
// 源文件应被移入 Rejected/Unreadable
assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("song.wav")),
"源文件应放入 Rejected/Unreadable");
// Library 应为空
assertFalse(Files.list(libDir).findAny().isPresent(),
"Library 应为空");
} finally {
System.clearProperty(AudioValidationService.FFPROBE_BIN_PROPERTY);
deleteDirectory(tmpDir);
}
}
@Test
void losslessFile_withValidation_passesAndIngestedAsFlac() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-e2e-");
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);
Path src = createTaggedLossless(inputDir, "song.wav",
"Flac Title", "Flac Artist", "Flac Album");
IngestService service = buildServiceWithValidation();
AtomicInteger ingested = new AtomicInteger();
AtomicInteger duplicates = new AtomicInteger();
AtomicInteger missingMeta = new AtomicInteger();
AtomicInteger unreadable = new AtomicInteger();
AtomicInteger convFailed = new AtomicInteger();
AtomicInteger otherRejected = new AtomicInteger();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
ingested, duplicates, missingMeta, unreadable,
convFailed, otherRejected);
assertEquals("ingested", result);
assertEquals(1, ingested.get());
assertEquals(0, unreadable.get());
assertEquals(0, convFailed.get());
// 文件应作为 FLAC 存入 Library/Flac Artist/Flac Album/01 - Flac Title.flac
Path expectedDir = libDir.resolve("Flac Artist").resolve("Flac Album");
assertTrue(Files.isDirectory(expectedDir), "Library 应包含 Artist/Album 目录");
boolean foundFlac = Files.list(expectedDir).anyMatch(p ->
p.getFileName().toString().endsWith(".flac"));
assertTrue(foundFlac, "Library 中应包含 .flac 文件");
// 源 WAV 应已被删除(转换后清理)
assertFalse(Files.exists(src), "源 WAV 文件应在转换后被删除");
// Rejected 应为空
assertFalse(Files.list(rejDir).findAny().isPresent(),
"Rejected 应为空");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void losslessFile_withMetadataRereadFails_cleansUpAndRejects() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-e2e-");
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);
// 创建 WAV 文件(带 ffmpeg 元数据 → 初始 jaudiotagger 读取正常)
Path src = inputDir.resolve("song.wav");
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc",
"-t", "0.1",
"-metadata", "title=RereadTitle",
"-metadata", "artist=RereadArtist",
"-metadata", "album=RereadAlbum",
src.toAbsolutePath().toString()
);
runProcessChecked(pb, "ffmpeg");
// 创建 ffmpeg 包装脚本:正常执行转换,但之后剥离输出 FLAC 的元数据
// 这样初始文件元数据可读,但转换后 FLAC 元数据为空,触发重读失败路径
Path wrapper = tmpDir.resolve("ffmpeg-wrapper.sh");
String realFfmpeg = resolveFfmpegPath();
Files.write(wrapper, Arrays.asList(
"#!/bin/sh",
"REAL_FF=\"" + realFfmpeg + "\"",
"\"$REAL_FF\" \"$@\"",
"rc=$?",
"if [ $rc -ne 0 ]; then exit $rc; fi",
"for arg in \"$@\"; do",
" case \"$arg\" in -*) ;; *) OUTPUT=\"$arg\" ;; esac",
"done",
// 如果输出是 FLAC,剥离所有元数据(需用 -f flac 指定格式)
"case \"$OUTPUT\" in",
" *.flac)",
" \"$REAL_FF\" -y -i \"$OUTPUT\" -map_metadata -1 -c copy -f flac \"${OUTPUT}.stripped\" 2>/dev/null &&",
" mv \"${OUTPUT}.stripped\" \"$OUTPUT\"",
" ;;",
"esac"
));
wrapper.toFile().setExecutable(true);
IngestService service = buildServiceWithValidation();
// 注入包装脚本路径作为 ffmpeg 可执行文件(通过系统属性)
String oldBin = System.setProperty("mangtool.ffmpeg.bin",
wrapper.toAbsolutePath().toString());
try {
AtomicInteger ingested = new AtomicInteger();
AtomicInteger duplicates = new AtomicInteger();
AtomicInteger missingMeta = new AtomicInteger();
AtomicInteger unreadable = new AtomicInteger();
AtomicInteger convFailed = new AtomicInteger();
AtomicInteger otherRejected = new AtomicInteger();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
ingested, duplicates, missingMeta, unreadable,
convFailed, otherRejected);
assertEquals("rejected:unreadable", result,
"转换后 FLAC 元数据为空应被拒绝为 Unreadable");
assertEquals(1, unreadable.get());
assertEquals(0, ingested.get());
// 确认转换产物 FLAC 已被清理
assertTrue(Files.list(tmpDir).noneMatch(p ->
p.toString().endsWith(".flac")),
"转换产物 FLAC 应被清理");
// 源文件应在 Rejected/Unreadable 下
assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("song.wav")),
"源文件应放入 Rejected/Unreadable");
} finally {
if (oldBin != null) {
System.setProperty("mangtool.ffmpeg.bin", oldBin);
} else {
System.clearProperty("mangtool.ffmpeg.bin");
}
}
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 工具方法 ==========
private IngestService buildService() {
@@ -866,6 +1221,16 @@ class IngestServiceE2ETest {
);
}
private IngestService buildServiceWithValidation() {
return new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
mock(ConfigService.class),
new AudioValidationService()
);
}
private String invokeProcessSingleFile(
IngestService service, Path srcFile, Path libraryPath, Path rejectedPath,
Set<Object> libraryIdentities, Set<Object> batchIdentities,
@@ -905,6 +1270,29 @@ class IngestServiceE2ETest {
return file;
}
/**
* 创建一个带标签的无损音频文件(WAV 格式)。
* 用于测试需要将无损格式转换为 FLAC 的场景。
*/
private static Path createTaggedLossless(Path dir, String name,
String title, String artist, String album) throws Exception {
Path file = dir.resolve(name);
// 使用 ffmpeg 创建带元数据的 WAV 文件
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc",
"-t", "0.1",
"-metadata", "title=" + title,
"-metadata", "artist=" + artist,
"-metadata", "album=" + album,
file.toAbsolutePath().toString()
);
runProcessChecked(pb, "ffmpeg");
// 注意:ffmpeg 写入的 WAV 元数据(LIST INFO chunk)可被 jaudiotagger 读取,
// 但 jaudiotagger 的 commit() 会写入 ID3 chunk 导致二度读取出空字段。
// 因此此处不调用 writeTags(),使用 ffmpeg 原生元数据即可满足初始读取需求。
return file;
}
private static void runProcess(ProcessBuilder pb) throws Exception {
pb.redirectErrorStream(true);
Process p = pb.start();
@@ -966,4 +1354,22 @@ class IngestServiceE2ETest {
}
} catch (Exception ignored) {}
}
/** 解析系统路径中 ffmpeg 的绝对路径 */
private static String resolveFfmpegPath() throws IOException {
String userDefined = System.getProperty("mangtool.ffmpeg.bin");
if (userDefined != null && !userDefined.isEmpty()) {
return new File(userDefined).getCanonicalPath();
}
ProcessBuilder which = new ProcessBuilder("which", "ffmpeg");
which.redirectErrorStream(true);
Process p = which.start();
try {
if (p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0) {
return readStream(p.getInputStream()).trim();
}
} catch (InterruptedException ignored) {}
// 回退
return "ffmpeg";
}
}
@@ -509,6 +509,22 @@ class IngestServiceInternalTest {
}
}
@Test
void fiveParamConstructor_hasAutowiredAnnotation() throws Exception {
Constructor<?>[] ctors = IngestService.class.getConstructors();
Constructor<?> fiveParamCtor = null;
for (Constructor<?> c : ctors) {
if (c.getParameterCount() == 5) {
fiveParamCtor = c;
break;
}
}
assertNotNull(fiveParamCtor, "应存在 5 参数构造器");
assertTrue(fiveParamCtor.isAnnotationPresent(
org.springframework.beans.factory.annotation.Autowired.class),
"5 参数构造器应带有 @Autowired 注解");
}
// ========== 工具方法 ==========
private void deleteDirectory(Path dir) {