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);
|
Path targetDir = libraryPath.resolve(artistDir).resolve(albumDir);
|
||||||
Files.createDirectories(targetDir);
|
Files.createDirectories(targetDir);
|
||||||
|
|
||||||
String safeTitle = sanitizePathComponent(title);
|
String safeTitle = sanitizePathComponent(traditionalFilterService.toSimplified(title));
|
||||||
String destFileName = trackStr + " - " + safeTitle + "." + ext;
|
String destFileName = trackStr + " - " + safeTitle + "." + ext;
|
||||||
Path targetFile = resolveUniqueFile(targetDir, destFileName);
|
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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-5
@@ -18,14 +18,26 @@ cd MyTool
|
|||||||
|
|
||||||
### 2. 配置工作目录
|
### 2. 配置工作目录
|
||||||
|
|
||||||
编辑 `docker/docker-compose.yml`,将 `/path/to/MusicWork` 替换为宿主机上的音乐工作目录:
|
工作目录默认为 `docker/data/`(相对于 docker-compose.yml),可通过环境变量 `MANGTOOL_DATA_DIR` 覆盖:
|
||||||
|
|
||||||
```yaml
|
```bash
|
||||||
volumes:
|
# 使用默认目录(docker/data/)
|
||||||
- /your/actual/path/MusicWork:/home/mangtool/MusicWork
|
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. 启动服务
|
### 3. 启动服务
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mangtool:
|
mangtool:
|
||||||
build:
|
build:
|
||||||
@@ -10,10 +8,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# 挂载音乐工作目录(替换 /path/to/MusicWork 为实际路径)
|
# 挂载音乐工作目录(默认 ./data,可通过 MANGTOOL_DATA_DIR 环境变量覆盖)
|
||||||
# 工作根目录下应包含 Input/ (待处理) Library/ (曲库)和 Rejected/ (被拒绝)三个子目录
|
# 工作根目录下应包含 Input/ (待处理) Library/ (曲库)和 Rejected/ (被拒绝)三个子目录
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/MusicWork:/home/mangtool/MusicWork
|
- ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork
|
||||||
environment:
|
environment:
|
||||||
- MANGTOOL_HOME=/home/mangtool
|
- MANGTOOL_HOME=/home/mangtool
|
||||||
# 如果需要覆盖 ffmpeg 路径:
|
# 如果需要覆盖 ffmpeg 路径:
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
<!-- 消息提示 -->
|
<!-- 消息提示 -->
|
||||||
<div v-if="progress.message" class="message-section">
|
<div v-if="progress.message" class="message-section">
|
||||||
<el-alert
|
<el-alert
|
||||||
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
|
:type="alertType"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
>
|
>
|
||||||
@@ -136,7 +136,7 @@ import { ElMessage } from 'element-plus';
|
|||||||
import { Upload, VideoPlay, Refresh, DataLine, FolderOpened, Document } from '@element-plus/icons-vue';
|
import { Upload, VideoPlay, Refresh, DataLine, FolderOpened, Document } from '@element-plus/icons-vue';
|
||||||
import { getConfig } from '../api/config';
|
import { getConfig } from '../api/config';
|
||||||
import type { ConfigResponse } 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 type { IngestStatusResponse } from '../api/ingest';
|
||||||
import { useWebSocket } from '../composables/useWebSocket';
|
import { useWebSocket } from '../composables/useWebSocket';
|
||||||
import type { ProgressMessage } 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() {
|
function formatProgress() {
|
||||||
if (progress.completed) return '完成';
|
if (progress.completed) return '完成';
|
||||||
return `${progress.processed ?? 0} / ${progress.total ?? 0}`;
|
return `${progress.processed ?? 0} / ${progress.total ?? 0}`;
|
||||||
@@ -286,7 +309,6 @@ async function startIngest() {
|
|||||||
// 页面挂载恢复:查询是否存在正在运行或最近完成的任务
|
// 页面挂载恢复:查询是否存在正在运行或最近完成的任务
|
||||||
async function resumeFromCurrentTask() {
|
async function resumeFromCurrentTask() {
|
||||||
try {
|
try {
|
||||||
const { getIngestCurrentStatus } = await import('../api/ingest');
|
|
||||||
let resp: IngestStatusResponse | null = null;
|
let resp: IngestStatusResponse | null = null;
|
||||||
try {
|
try {
|
||||||
resp = await getIngestCurrentStatus();
|
resp = await getIngestCurrentStatus();
|
||||||
|
|||||||
Reference in New Issue
Block a user