Harden one-click ingestion workflow

This commit is contained in:
2026-07-19 03:54:48 +08:00
parent ff59f341cf
commit 2648cec97b
5 changed files with 1014 additions and 13 deletions
@@ -0,0 +1,969 @@
package com.music.service;
import com.music.dto.ProgressMessage;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 一键导入服务端到端测试 —— 使用临时文件系统验证完整的导入管线行为。
*
* <p>大部分测试直接调用 {@link IngestService#processSingleFile} 私有方法,
* 通过反射执行,以验证文件扫描、元数据校验、去重、格式转换、LRC 传播、
* 报告生成等关键路径。</p>
*
* <p>所有依赖 FFmpeg 的测试通过 {@link #assumeFfmpeg()} 守卫,
* FFmpeg 不可用时自动跳过,不影响其余测试。</p>
*/
class IngestServiceE2ETest {
private static boolean FFMPEG_AVAILABLE;
@BeforeAll
static void checkFfmpeg() 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;
}
}
/** 跳过依赖 FFmpeg 的测试 */
private static void assumeFfmpeg() {
assumeTrue(FFMPEG_AVAILABLE, "此测试需要 ffmpeg");
}
// ========== 1. 基础端到端:有效文件入库 / 拒绝 ==========
@Test
void validTaggedMp3IngestedToLibrary() 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, "song.mp3",
"Test Title", "Test Artist", "Test Album");
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", result);
Path expected = libDir.resolve("Test Artist").resolve("Test Album").resolve("01 - Test Title.mp3");
assertTrue(Files.exists(expected), "文件应存在于 Library: " + expected);
assertFalse(Files.exists(src), "源文件应已被移动");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void missingTitleFileRejectedAsMissingMetadata() 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, "notitle.mp3",
"", "Some Artist", "Some Album");
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertTrue(result.startsWith("rejected:missing-metadata"),
"缺少 Title 的文件应被拒绝,实际: " + result);
assertTrue(Files.exists(rejDir.resolve("MissingMetadata").resolve("notitle.mp3")),
"缺少元数据的文件应进入 Rejected/MissingMetadata");
assertFalse(Files.exists(src));
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void missingArtistFileRejectedAsMissingMetadata() 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, "noartist.mp3",
"A Title", "", "An Album");
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertTrue(result.startsWith("rejected:missing-metadata"),
"缺少 Artist 的文件应被拒绝");
assertTrue(Files.exists(rejDir.resolve("MissingMetadata").resolve("noartist.mp3")));
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void missingAlbumFileRejectedAsMissingMetadata() 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, "noalbum.mp3",
"A Title", "An Artist", "");
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertTrue(result.startsWith("rejected:missing-metadata"),
"缺少 Album 的文件应被拒绝");
assertTrue(Files.exists(rejDir.resolve("MissingMetadata").resolve("noalbum.mp3")));
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void unreadableFileRejectedAsUnreadable() throws Exception {
// 不依赖 FFmpeg
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 = inputDir.resolve("corrupt.mp3");
Files.write(src, "this is not an audio file".getBytes(StandardCharsets.UTF_8));
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertTrue(result.startsWith("rejected:unreadable"),
"不可读文件应被拒绝,实际结果: " + result);
assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("corrupt.mp3")),
"不可读文件应进入 Rejected/Unreadable");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 2. 去重 ==========
@Test
void existingLibraryDuplicateViaPublicIngest() 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);
// 1. 在 Library 中预先放置一个曲目
Path libTrack = createTaggedMp3(
Files.createDirectories(libDir.resolve("Some Artist").resolve("Some Album")),
"01 - Some Title.mp3",
"Some Title", "Some Artist", "Some Album"
);
assertTrue(Files.exists(libTrack), "Library 曲目应就绪");
// 2. 在 Input 中放置相同元数据的文件
Path inputTrack = createTaggedMp3(inputDir, "song.mp3",
"Some Title", "Some Artist", "Some Album");
// 3. 通过 public ingest 执行
ConfigService configService = mock(ConfigService.class);
when(configService.getInputDir()).thenReturn(inputDir.toString());
when(configService.getLibraryDir()).thenReturn(libDir.toString());
when(configService.getRejectedDir()).thenReturn(rejDir.toString());
when(configService.getBasePath()).thenReturn(tmpDir.toString());
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
configService
);
service.ingest("dup-lib-test", new AtomicBoolean(true));
// 4. 断言:Library 文件未被修改
assertTrue(Files.exists(libTrack), "Library 原有曲目应保留");
// 5. 断言:Input 文件移入 Rejected/Duplicate
Path dupDir = rejDir.resolve("Duplicate");
assertTrue(Files.exists(dupDir), "Rejected/Duplicate 应存在");
try (Stream<Path> dups = Files.list(dupDir)) {
assertTrue(dups.findAny().isPresent(), "应有文件进入 Rejected/Duplicate");
}
assertFalse(Files.exists(inputTrack), "Input 源文件应已被移动");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void withinBatchDuplicateSkipped() 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, "same1.mp3",
"Batch Title", "Batch Artist", "Batch Album");
Path src2 = createTaggedMp3(inputDir, "same2.mp3",
"Batch Title", "Batch Artist", "Batch Album");
IngestService service = buildService();
Set<Object> libraryIdentities = new HashSet<>();
Set<Object> batchIdentities = new HashSet<>();
String result1 = invokeProcessSingleFile(service, src1, libDir, rejDir,
libraryIdentities, batchIdentities,
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", result1);
String result2 = invokeProcessSingleFile(service, src2, libDir, rejDir,
libraryIdentities, batchIdentities,
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertTrue(result2.startsWith("rejected:duplicate"),
"批次内重复应被拒绝,实际: " + result2);
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void checkDuplicateUsesMd5Fallback() throws Exception {
// 不依赖 FFmpeg — 纯反射
IngestService service = buildService();
Method checkMethod = IngestService.class.getDeclaredMethod("checkDuplicate",
getIdentityKeyClass(), Set.class, Set.class);
checkMethod.setAccessible(true);
Constructor<?> ctor = getIdentityKeyClass().getDeclaredConstructor(
String.class, String.class, int.class, int.class, String.class, String.class);
ctor.setAccessible(true);
Object id1 = ctor.newInstance("artist_a", "album_a", 1, 1, "title_a", "abc123");
Object id2 = ctor.newInstance("artist_b", "album_b", 1, 1, "title_b", "abc123");
Set<Object> lib1 = new HashSet<>();
lib1.add(id1);
String result = (String) checkMethod.invoke(service, id2, lib1, new HashSet<>());
assertEquals("md5", result,
"不同身份但相同 MD5 应被 MD5 兜底拒绝");
}
// ========== 3. 格式转换(直接测试 convertToFlac 方法)==========
@Test
void wavConvertedToReadableFlac() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-e2e-");
try {
Path inputDir = tmpDir.resolve("Input");
Files.createDirectories(inputDir);
Path src = inputDir.resolve("song.wav");
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc",
"-metadata", "title=Wav Title",
"-metadata", "artist=Wav Artist",
"-metadata", "album=Wav Album",
"-t", "0.1",
src.toAbsolutePath().toString()
);
runProcess(pb);
assertTrue(Files.exists(src), "测试 WAV 文件应已创建");
IngestService service = buildService();
Method convertMethod = IngestService.class.getDeclaredMethod(
"convertToFlac", Path.class, Path.class);
convertMethod.setAccessible(true);
Path flacFile = (Path) convertMethod.invoke(service, src, inputDir);
assertTrue(Files.exists(flacFile), "FLAC 文件应已生成: " + flacFile);
assertTrue(flacFile.toString().endsWith(".flac"), "输出应为 FLAC 扩展名");
AudioFile af = AudioFileIO.read(flacFile.toFile());
Tag tag = af.getTagOrCreateAndSetDefault();
assertEquals("Wav Title", tag.getFirst(FieldKey.TITLE));
assertEquals("Wav Artist", tag.getFirst(FieldKey.ARTIST));
assertEquals("Wav Album", tag.getFirst(FieldKey.ALBUM));
assertTrue(Files.size(flacFile) > 200, "FLAC 应有合理的音频内容");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void convertToFlacFailureCleansOutput() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-e2e-");
try {
Path inputDir = tmpDir.resolve("Input");
Files.createDirectories(inputDir);
Path src = inputDir.resolve("bad.wav");
Files.write(src, "this is not a wav file".getBytes(StandardCharsets.UTF_8));
IngestService service = buildService();
Method convertMethod = IngestService.class.getDeclaredMethod(
"convertToFlac", Path.class, Path.class);
convertMethod.setAccessible(true);
try {
convertMethod.invoke(service, src, inputDir);
fail("convertToFlac 应抛出异常");
} catch (java.lang.reflect.InvocationTargetException e) {
assertTrue(e.getCause() instanceof Exception,
"底层异常应被包装: " + e.getCause().getClass().getSimpleName());
}
try (Stream<Path> files = Files.list(inputDir)) {
long flacCount = files.filter(f -> f.toString().endsWith(".flac")).count();
assertEquals(0, flacCount, "失败路径不应留有残留 FLAC 文件");
}
assertTrue(Files.exists(src), "源文件应保留");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 4. 碰撞安全放置 ==========
@Test
void differentIdentitiesSameTargetFilenameUsesUniqueName() 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);
// 两个文件的 artist 字符串不同,但 sanitizePathComponent 后产生相同目录名
// File A: artist with '/' → sanitized to '_'
// File B: artist with ':' → sanitized to '_'
// 目录 Artist_One/Album/,且 track 和 title 相同 → 文件名相同
// 但 IdentityKey 不同(保留原始标点),不触发去重
Path srcA = createTaggedMp3(inputDir, "a_song.mp3",
"Song Title", "Art/One", "Album");
Path srcB = createTaggedMp3(inputDir, "b_song.mp3",
"Song Title", "Art:One", "Album");
IngestService service = buildService();
Set<Object> libraryIdentities = new HashSet<>();
Set<Object> batchIdentities = new HashSet<>();
String resultA = invokeProcessSingleFile(service, srcA, libDir, rejDir,
libraryIdentities, batchIdentities,
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", resultA);
String resultB = invokeProcessSingleFile(service, srcB, libDir, rejDir,
libraryIdentities, batchIdentities,
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", resultB, "不同 identity 的文件应各自入库");
Path targetDir = libDir.resolve("Art_One").resolve("Album");
assertTrue(Files.exists(targetDir), "目标目录应存在");
Path firstFile = targetDir.resolve("01 - Song Title.mp3");
assertTrue(Files.exists(firstFile), "第一个文件应为原始文件名");
Path secondFile = targetDir.resolve("01 - Song Title (1).mp3");
assertTrue(Files.exists(secondFile), "冲突文件应获得唯一名称 (1)");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 5. 繁简转换持久化 ==========
@Test
void traditionalToSimplifiedTagsPersistedInOutput() 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);
// 繁体中文:體(体), 樂(乐), 門(门), 聲(声), 風(风), 馬(马)
// 直接用 FFmpeg 写入元数据(避免 jaudiotagger 对极短 MP3 的编码问题)
String tradTitle = "\u9AD4\u6A02"; // 體樂
String tradArtist = "\u9580\u8072"; // 門聲
String tradAlbum = "\u98A8\u99AC"; // 風馬
String simpTitle = "\u4F53\u4E50"; // 体乐
String simpArtist = "\u95E8\u58F0"; // 门声
String simpAlbum = "\u98CE\u9A6C"; // 风马
Path src = inputDir.resolve("trad.mp3");
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-metadata", "title=" + tradTitle,
"-metadata", "artist=" + tradArtist,
"-metadata", "album=" + tradAlbum,
"-t", "1",
src.toAbsolutePath().toString()
);
runProcess(pb);
assertTrue(Files.exists(src), "FFmpeg 应创建带标签的 MP3");
assertTrue(Files.size(src) > 2000, "MP3 文件应有足够的音频帧");
// 验证 jaudiotagger 能正确读取标签
AudioFile verify = AudioFileIO.read(src.toFile());
Tag verifyTag = verify.getTagOrCreateAndSetDefault();
assertEquals(tradTitle, verifyTag.getFirst(FieldKey.TITLE),
"jaudiotagger 应能读取繁体 Title");
assertEquals(tradArtist, verifyTag.getFirst(FieldKey.ARTIST),
"jaudiotagger 应能读取繁体 Artist");
assertEquals(tradAlbum, verifyTag.getFirst(FieldKey.ALBUM),
"jaudiotagger 应能读取繁体 Album");
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
mock(ConfigService.class)
);
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", result, "繁体文件应成功入库");
// 读取入库后的标签
Path expected = libDir.resolve(simpArtist).resolve(simpAlbum).resolve("01 - " + simpTitle + ".mp3");
assertTrue(Files.exists(expected), "简化后的路径应存在: " + expected);
AudioFile af = AudioFileIO.read(expected.toFile());
Tag tag = af.getTagOrCreateAndSetDefault();
assertEquals(simpTitle, tag.getFirst(FieldKey.TITLE),
"Title 应已简化: " + tradTitle + "" + simpTitle);
assertEquals(simpArtist, tag.getFirst(FieldKey.ARTIST),
"Artist 应已简化: " + tradArtist + "" + simpArtist);
assertEquals(simpAlbum, tag.getFirst(FieldKey.ALBUM),
"Album 应已简化: " + tradAlbum + "" + simpAlbum);
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 6. LRC 侧车文件 ==========
@Test
void lrcSidecarMovesWithSuccessfulIngest() 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, "song.mp3",
"LRC Title", "LRC Artist", "LRC Album");
Path lrc = inputDir.resolve("song.lrc");
Files.write(lrc, "[00:00.00]Test lyric".getBytes(StandardCharsets.UTF_8));
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", result);
Path expectedLrc = libDir.resolve("LRC Artist").resolve("LRC Album").resolve("01 - LRC Title.lrc");
assertTrue(Files.exists(expectedLrc), "LRC 应随音频进入 Library: " + expectedLrc);
assertFalse(Files.exists(lrc), "源 LRC 文件应已被移动");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void lrcSidecarMovesWithRejectedFile() 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, "song.mp3",
"", "LRC Artist", "LRC Album");
Path lrc = inputDir.resolve("song.lrc");
Files.write(lrc, "[00:00.00]Test lyric".getBytes(StandardCharsets.UTF_8));
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertTrue(result.startsWith("rejected:missing-metadata"));
assertTrue(Files.exists(rejDir.resolve("MissingMetadata").resolve("song.lrc")),
"LRC 应随被拒音频进入 Rejected");
assertFalse(Files.exists(lrc), "源 LRC 文件应已被移动");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 7. 报告生成 ==========
@Test
void reportWrittenOnIngestCompletion() 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 sub1 = inputDir.resolve("album_a");
Path sub2 = inputDir.resolve("album_b");
Files.createDirectories(sub1);
Files.createDirectories(sub2);
Path src1 = createTaggedMp3(sub1, "01.mp3",
"Song One", "Artist A", "Album A");
Path src2 = createTaggedMp3(sub2, "01.mp3",
"Song Two", "Artist B", "Album B");
ConfigService configService = mock(ConfigService.class);
when(configService.getInputDir()).thenReturn(inputDir.toString());
when(configService.getLibraryDir()).thenReturn(libDir.toString());
when(configService.getRejectedDir()).thenReturn(rejDir.toString());
when(configService.getBasePath()).thenReturn(tmpDir.toString());
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
configService
);
AtomicBoolean runningLock = new AtomicBoolean(true);
service.ingest("report-test-task", runningLock);
Path reportsDir = rejDir.resolve("Reports");
assertTrue(Files.exists(reportsDir), "Reports 目录应存在");
Path[] reports;
try (Stream<Path> files = Files.list(reportsDir)) {
reports = files.filter(f -> f.toString().endsWith(".json")).toArray(Path[]::new);
}
assertTrue(reports.length > 0, "应存在至少一个 JSON 报告文件");
String reportContent = new String(Files.readAllBytes(reports[0]), StandardCharsets.UTF_8);
assertTrue(reportContent.contains("album_a/01.mp3"),
"报告应包含 album_a 下文件的相对路径");
assertTrue(reportContent.contains("album_b/01.mp3"),
"报告应包含 album_b 下文件的相对路径");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 8. 并发锁 ==========
@Test
void concurrencyLockReleasedOnNormalCompletion() 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);
createTaggedMp3(inputDir, "song.mp3",
"Lock Test", "Lock Artist", "Lock Album");
ConfigService configService = mock(ConfigService.class);
when(configService.getInputDir()).thenReturn(inputDir.toString());
when(configService.getLibraryDir()).thenReturn(libDir.toString());
when(configService.getRejectedDir()).thenReturn(rejDir.toString());
when(configService.getBasePath()).thenReturn(tmpDir.toString());
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
configService
);
AtomicBoolean runningLock = new AtomicBoolean(true);
service.ingest("lock-test-task", runningLock);
assertFalse(runningLock.get(), "并发锁应在任务完成后释放");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void concurrencyLockReleasedOnConfigError() throws Exception {
// 不依赖 FFmpeg
ConfigService configService = mock(ConfigService.class);
when(configService.getInputDir()).thenReturn(null);
when(configService.getLibraryDir()).thenReturn(null);
when(configService.getRejectedDir()).thenReturn(null);
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
configService
);
AtomicBoolean runningLock = new AtomicBoolean(true);
service.ingest("lock-test-null-dir", runningLock);
assertFalse(runningLock.get(), "并发锁应在配置缺失时也能释放");
}
// ========== 9. 进度完成消息 ==========
@Test
void completedProgressPublishedOnTerminalPath() 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);
createTaggedMp3(inputDir, "song.mp3",
"Progress Title", "Progress Artist", "Progress Album");
ConfigService configService = mock(ConfigService.class);
when(configService.getInputDir()).thenReturn(inputDir.toString());
when(configService.getLibraryDir()).thenReturn(libDir.toString());
when(configService.getRejectedDir()).thenReturn(rejDir.toString());
when(configService.getBasePath()).thenReturn(tmpDir.toString());
ProgressStore store = new ProgressStore();
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
store,
new TraditionalFilterService(),
configService
);
AtomicBoolean runningLock = new AtomicBoolean(true);
service.ingest("progress-test-task", runningLock);
ProgressMessage finalMsg = store.get("progress-test-task");
assertNotNull(finalMsg, "最终进度消息应存在");
assertTrue(finalMsg.isCompleted(), "进度消息应标记为 completed");
assertEquals("progress-test-task", finalMsg.getTaskId());
assertTrue(finalMsg.getTotal() >= 1, "total 应 >= 1");
assertTrue(finalMsg.getProcessed() >= 1, "processed 应 >= 1");
assertTrue(finalMsg.getSuccess() >= 1, "success 应 >= 1");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 10. 可选字段未阻挡入库 ==========
@Test
void optionalFieldsNotRequired() 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, "minimal.mp3",
"Minimal Title", "Minimal Artist", "Minimal Album");
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", result);
Path expected = libDir.resolve("Minimal Artist")
.resolve("Minimal Album")
.resolve("01 - Minimal Title.mp3");
assertTrue(Files.exists(expected), "仅有必填字段的文件也应入库");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void orphanLrcNotProcessed() 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 lrc = inputDir.resolve("orphan.lrc");
Files.write(lrc, "[00:00.00]orphan".getBytes(StandardCharsets.UTF_8));
Path src = createTaggedMp3(inputDir, "song.mp3",
"Normal Title", "Normal Artist", "Normal Album");
IngestService service = buildService();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", result);
assertTrue(Files.exists(lrc), "无对应音频的 LRC 应原地保留");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 工具方法 ==========
private IngestService buildService() {
return new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
mock(ConfigService.class)
);
}
private String invokeProcessSingleFile(
IngestService service, Path srcFile, Path libraryPath, Path rejectedPath,
Set<Object> libraryIdentities, Set<Object> batchIdentities,
AtomicInteger ingested, AtomicInteger duplicates,
AtomicInteger missingMeta, AtomicInteger unreadable,
AtomicInteger convFailed, AtomicInteger otherRejected) throws Exception {
Method m = IngestService.class.getDeclaredMethod("processSingleFile",
Path.class, Path.class, Path.class,
Set.class, Set.class,
AtomicInteger.class, AtomicInteger.class,
AtomicInteger.class, AtomicInteger.class,
AtomicInteger.class, AtomicInteger.class);
m.setAccessible(true);
return (String) m.invoke(service, srcFile, libraryPath, rejectedPath,
libraryIdentities, batchIdentities,
ingested, duplicates, missingMeta, unreadable,
convFailed, otherRejected);
}
private Class<?> getIdentityKeyClass() throws Exception {
for (Class<?> c : IngestService.class.getDeclaredClasses()) {
if (c.getSimpleName().equals("IdentityKey")) return c;
}
throw new ClassNotFoundException("IdentityKey");
}
private static Path createTaggedMp3(Path dir, String name,
String title, String artist, String album) throws Exception {
Path file = dir.resolve(name);
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc",
"-t", "0.1", file.toAbsolutePath().toString()
);
runProcessChecked(pb, "ffmpeg");
writeTags(file, title, artist, album);
return file;
}
private static void runProcess(ProcessBuilder pb) throws Exception {
pb.redirectErrorStream(true);
Process p = pb.start();
boolean exited = p.waitFor(10, TimeUnit.SECONDS);
if (!exited) {
p.destroyForcibly();
throw new IOException("进程执行超时: " + pb.command().get(0));
}
if (p.exitValue() != 0) {
String err = readStream(p.getInputStream());
throw new IOException("进程失败 (exit=" + p.exitValue() + "): " + err);
}
}
private static void runProcessChecked(ProcessBuilder pb, String name) throws Exception {
pb.redirectErrorStream(true);
Process p = pb.start();
boolean exited = p.waitFor(10, TimeUnit.SECONDS);
if (!exited) {
p.destroyForcibly();
throw new IOException(name + " 执行超时");
}
if (p.exitValue() != 0) {
String err = readStream(p.getInputStream());
throw new IOException(name + " 失败 (exit=" + p.exitValue() + "): " + err);
}
}
/**
* Java 8 兼容的流读取 — 替代 {@code InputStream.readAllBytes()}Java 9+)。
*/
private static String readStream(InputStream is) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = is.read(buf)) != -1) {
buffer.write(buf, 0, n);
}
return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
}
private static void writeTags(Path file, String title, String artist, String album) throws Exception {
AudioFile af = AudioFileIO.read(file.toFile());
Tag tag = af.getTagOrCreateAndSetDefault();
if (title != null) tag.setField(FieldKey.TITLE, title);
if (artist != null) tag.setField(FieldKey.ARTIST, artist);
if (album != null) tag.setField(FieldKey.ALBUM, album);
af.commit();
}
private 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) {}
}
}