From 2648cec97b20b514be6caacf57062f5c2c187f5c Mon Sep 17 00:00:00 2001 From: mangmang <362165265@qq.com> Date: Sun, 19 Jul 2026 03:54:48 +0800 Subject: [PATCH] Harden one-click ingestion workflow --- .../java/com/music/service/IngestService.java | 2 +- .../music/service/IngestServiceE2ETest.java | 969 ++++++++++++++++++ docker/README.md | 22 +- docker/docker-compose.yml | 6 +- frontend/src/components/IngestTab.vue | 28 +- 5 files changed, 1014 insertions(+), 13 deletions(-) create mode 100644 backend/src/test/java/com/music/service/IngestServiceE2ETest.java diff --git a/backend/src/main/java/com/music/service/IngestService.java b/backend/src/main/java/com/music/service/IngestService.java index b604c2c..ecaa9fe 100644 --- a/backend/src/main/java/com/music/service/IngestService.java +++ b/backend/src/main/java/com/music/service/IngestService.java @@ -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); diff --git a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java new file mode 100644 index 0000000..da12930 --- /dev/null +++ b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java @@ -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; + +/** + * 一键导入服务端到端测试 —— 使用临时文件系统验证完整的导入管线行为。 + * + *

大部分测试直接调用 {@link IngestService#processSingleFile} 私有方法, + * 通过反射执行,以验证文件扫描、元数据校验、去重、格式转换、LRC 传播、 + * 报告生成等关键路径。

+ * + *

所有依赖 FFmpeg 的测试通过 {@link #assumeFfmpeg()} 守卫, + * FFmpeg 不可用时自动跳过,不影响其余测试。

+ */ +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 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 libraryIdentities = new HashSet<>(); + Set 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 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 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 libraryIdentities = new HashSet<>(); + Set 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 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 libraryIdentities, Set 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) {} + } +} diff --git a/docker/README.md b/docker/README.md index f8ad267..af28412 100644 --- a/docker/README.md +++ b/docker/README.md @@ -18,14 +18,26 @@ cd MyTool ### 2. 配置工作目录 -编辑 `docker/docker-compose.yml`,将 `/path/to/MusicWork` 替换为宿主机上的音乐工作目录: +工作目录默认为 `docker/data/`(相对于 docker-compose.yml),可通过环境变量 `MANGTOOL_DATA_DIR` 覆盖: -```yaml -volumes: - - /your/actual/path/MusicWork:/home/mangtool/MusicWork +```bash +# 使用默认目录(docker/data/) +cd docker +docker compose up -d --build + +# 指定自定义目录 +MANGTOOL_DATA_DIR=/your/actual/path/MusicWork docker compose up -d --build ``` -该目录下包含 `Input/`(放入待处理的音频文件)、`Library/`(整理后的曲库)和 `Rejected/`(被拒绝的文件)三个子目录。 +或在 `.env` 文件中设置: + +```bash +# 在 docker/ 目录下创建 .env 文件 +echo 'MANGTOOL_DATA_DIR=/your/actual/path/MusicWork' > .env +docker compose up -d --build +``` + +工作目录下应包含 `Input/`(放入待处理的音频文件)、`Library/`(整理后的曲库)和 `Rejected/`(被拒绝的文件)三个子目录,首次启动时系统会自动创建。 ### 3. 启动服务 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7799142..b2003ea 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: mangtool: build: @@ -10,10 +8,10 @@ services: ports: - "8080:8080" restart: unless-stopped - # 挂载音乐工作目录(替换 /path/to/MusicWork 为实际路径) + # 挂载音乐工作目录(默认 ./data,可通过 MANGTOOL_DATA_DIR 环境变量覆盖) # 工作根目录下应包含 Input/ (待处理) Library/ (曲库)和 Rejected/ (被拒绝)三个子目录 volumes: - - /path/to/MusicWork:/home/mangtool/MusicWork + - ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork environment: - MANGTOOL_HOME=/home/mangtool # 如果需要覆盖 ffmpeg 路径: diff --git a/frontend/src/components/IngestTab.vue b/frontend/src/components/IngestTab.vue index a1505a8..b194e0a 100644 --- a/frontend/src/components/IngestTab.vue +++ b/frontend/src/components/IngestTab.vue @@ -114,7 +114,7 @@
@@ -136,7 +136,7 @@ import { ElMessage } from 'element-plus'; import { Upload, VideoPlay, Refresh, DataLine, FolderOpened, Document } from '@element-plus/icons-vue'; import { getConfig } from '../api/config'; import type { ConfigResponse } from '../api/config'; -import { startIngest as apiStartIngest, getIngestStatus } from '../api/ingest'; +import { startIngest as apiStartIngest, getIngestStatus, getIngestCurrentStatus } from '../api/ingest'; import type { IngestStatusResponse } from '../api/ingest'; import { useWebSocket } from '../composables/useWebSocket'; import type { ProgressMessage } from '../composables/useWebSocket'; @@ -192,6 +192,29 @@ const progressStats = computed((): StatItem[] => { ]; }); +/** 所有被拒绝类别之和(不含已入库) */ +const totalRejected = computed(() => + (progress.duplicateFiles ?? 0) + + (progress.missingMetadataFiles ?? 0) + + (progress.unreadableFiles ?? 0) + + (progress.conversionFailedFiles ?? 0) + + (progress.otherRejectedFiles ?? 0) +); + +/** 是否为致命/内部错误 */ +const isFatalError = computed(() => { + const msg = progress.message || ''; + return msg.startsWith('导入任务失败') || msg.startsWith('任务内部错误') || msg.includes('异常终止'); +}); + +/** 任务完成后的提醒类型 */ +const alertType = computed(() => { + if (!progress.completed) return 'info'; + if (isFatalError.value) return 'error'; + if (totalRejected.value > 0) return 'warning'; + return 'success'; +}); + function formatProgress() { if (progress.completed) return '完成'; return `${progress.processed ?? 0} / ${progress.total ?? 0}`; @@ -286,7 +309,6 @@ async function startIngest() { // 页面挂载恢复:查询是否存在正在运行或最近完成的任务 async function resumeFromCurrentTask() { try { - const { getIngestCurrentStatus } = await import('../api/ingest'); let resp: IngestStatusResponse | null = null; try { resp = await getIngestCurrentStatus();