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