Template
Harden one-click ingestion workflow
This commit is contained in:
@@ -406,7 +406,7 @@ public class IngestService {
|
||||
Path targetDir = libraryPath.resolve(artistDir).resolve(albumDir);
|
||||
Files.createDirectories(targetDir);
|
||||
|
||||
String safeTitle = sanitizePathComponent(title);
|
||||
String safeTitle = sanitizePathComponent(traditionalFilterService.toSimplified(title));
|
||||
String destFileName = trackStr + " - " + safeTitle + "." + ext;
|
||||
Path targetFile = resolveUniqueFile(targetDir, destFileName);
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user