Add one-click Navidrome ingestion workflow

This commit is contained in:
2026-07-19 02:23:41 +08:00
parent 81977a157e
commit ff59f341cf
19 changed files with 2340 additions and 46 deletions
@@ -0,0 +1,525 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class IngestServiceInternalTest {
@Test
void identityKeyEquality() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class);
ctor.setAccessible(true);
Object a = ctor.newInstance("artist1", "album1", 1, 3, "title1");
Object b = ctor.newInstance("artist1", "album1", 1, 3, "title1");
Object c = ctor.newInstance("artist2", "album1", 1, 3, "title1");
Object d = ctor.newInstance("artist1", "album1", 1, 4, "title1");
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
assertNotEquals(a, c);
assertNotEquals(a, d);
}
@Test
void identityKeyConstructorAndGetters() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class);
ctor.setAccessible(true);
Object key = ctor.newInstance("test artist", "test album", 2, 5, "test title");
Method toString = ikClass.getDeclaredMethod("toString");
String str = (String) toString.invoke(key);
assertTrue(str.contains("test artist"));
assertTrue(str.contains("test album"));
assertTrue(str.contains("2"));
assertTrue(str.contains("5"));
assertTrue(str.contains("test title"));
}
@Test
void sanitizePathComponentRemovesInvalidChars() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("sanitizePathComponent", String.class);
m.setAccessible(true);
assertEquals("Hello World", m.invoke(service, "Hello World"));
assertEquals("A_B", m.invoke(service, "A:B"));
assertEquals("A_B", m.invoke(service, "A/B"));
assertEquals("_", m.invoke(service, ""));
assertEquals("_", m.invoke(service, (String) null));
assertEquals("Trimmed", m.invoke(service, " Trimmed "));
}
@Test
void parseIntHandlesVariousInputs() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("parseInt", String.class, int.class);
m.setAccessible(true);
assertEquals(3, (int) m.invoke(service, "3", 0));
assertEquals(5, (int) m.invoke(service, "05", 0));
assertEquals(0, (int) m.invoke(service, (String) null, 0));
assertEquals(0, (int) m.invoke(service, "", 0));
assertEquals(42, (int) m.invoke(service, " 42 ", 0));
assertEquals(1, (int) m.invoke(service, "1/10", 0));
assertEquals(0, (int) m.invoke(service, "abc", 0));
}
@Test
void extractYearParsesDates() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("extractYear", String.class);
m.setAccessible(true);
assertEquals("2024", m.invoke(service, "2024"));
assertEquals("1999", m.invoke(service, "1999-01-01"));
assertEquals("2000", m.invoke(service, "2000-12-31"));
assertEquals("", m.invoke(service, (String) null));
assertEquals("", m.invoke(service, ""));
assertEquals("", m.invoke(service, "abc"));
}
@Test
void getExtensionWorksCorrectly() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("getExtension", String.class);
m.setAccessible(true);
assertEquals("flac", m.invoke(service, "song.flac"));
assertEquals("mp3", m.invoke(service, "song.MP3"));
assertEquals("wav", m.invoke(service, "song.Wav"));
assertNull(m.invoke(service, "noext"));
assertNull(m.invoke(service, ".hidden"));
}
@Test
void getBaseNameWorksCorrectly() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("getBaseName", String.class);
m.setAccessible(true);
assertEquals("song", m.invoke(service, "song.flac"));
assertEquals("song.name", m.invoke(service, "song.name.flac"));
assertEquals("noext", m.invoke(service, "noext"));
assertEquals("", m.invoke(service, ""));
}
@Test
void resolveUniqueFileCreatesNonConflictingPaths() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("resolveUniqueFile", Path.class, String.class);
m.setAccessible(true);
Path tmpDir = Files.createTempDirectory("ingest-unique-");
Path result = (Path) m.invoke(service, tmpDir, "test.flac");
assertEquals(tmpDir.resolve("test.flac"), result);
// 已存在时应该添加后缀
Files.write(result, "existing".getBytes());
Path result2 = (Path) m.invoke(service, tmpDir, "test.flac");
assertEquals(tmpDir.resolve("test (1).flac"), result2);
}
@Test
void isLosslessFormatDetectsCorrectFormats() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("isLosslessFormat", Path.class);
m.setAccessible(true);
assertTrue((boolean) m.invoke(service, Paths.get("test.wav")));
assertTrue((boolean) m.invoke(service, Paths.get("test.ape")));
assertTrue((boolean) m.invoke(service, Paths.get("test.aiff")));
assertTrue((boolean) m.invoke(service, Paths.get("test.wv")));
assertTrue((boolean) m.invoke(service, Paths.get("test.tta")));
assertFalse((boolean) m.invoke(service, Paths.get("test.flac")));
assertFalse((boolean) m.invoke(service, Paths.get("test.mp3")));
assertFalse((boolean) m.invoke(service, Paths.get("test.txt")));
}
@Test
void isAudioFileDetectsAllSupportedFormats() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("isAudioFile", Path.class);
m.setAccessible(true);
assertTrue((boolean) m.invoke(service, Paths.get("test.flac")));
assertTrue((boolean) m.invoke(service, Paths.get("test.mp3")));
assertTrue((boolean) m.invoke(service, Paths.get("test.wav")));
assertTrue((boolean) m.invoke(service, Paths.get("test.ape")));
assertTrue((boolean) m.invoke(service, Paths.get("test.m4a")));
assertFalse((boolean) m.invoke(service, Paths.get("test.txt")));
assertFalse((boolean) m.invoke(service, Paths.get("test.jpg")));
}
/**
* 通过反射获取嵌套类 IdentityKey
*/
private Class<?> getIdentityKeyClass() throws ClassNotFoundException {
return Class.forName("com.music.service.IngestService$IdentityKey");
}
@Test
void identityKeyWithMd5Constructor() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
// 6 参数构造器:artist, album, disc, track, title, md5
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Object keyA = ctor6.newInstance("art", "alb", 1, 2, "ttl", "abc123");
Object keyB = ctor6.newInstance("art", "alb", 1, 2, "ttl", "def456");
// equals/hashCode 应忽略 MD5,仅基于元数据
assertEquals(keyA, keyB);
assertEquals(keyA.hashCode(), keyB.hashCode());
}
@Test
void identityKeyMd5StoredAndAccessible() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Object key = ctor6.newInstance("a", "b", 0, 0, "c", "md5hashvalue");
Method toString = ikClass.getDeclaredMethod("toString");
String str = (String) toString.invoke(key);
assertTrue(str.contains("a|b|0|0|c"));
}
@Test
void computeMd5ReturnsNonEmptyString() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("computeMd5", Path.class);
m.setAccessible(true);
Path tmpFile = Files.createTempFile("ingest-md5-", ".tmp");
Files.write(tmpFile, "hello test content".getBytes());
String md5 = (String) m.invoke(service, tmpFile);
assertNotNull(md5);
assertEquals(32, md5.length());
assertTrue(md5.matches("[0-9a-f]{32}"));
// 相同内容 → 相同 MD5
String md5b = (String) m.invoke(service, tmpFile);
assertEquals(md5, md5b);
// 不同内容 → 不同 MD5
Path tmpFile2 = Files.createTempFile("ingest-md5-", ".tmp");
Files.write(tmpFile2, "different content".getBytes());
String md5c = (String) m.invoke(service, tmpFile2);
assertNotEquals(md5, md5c);
}
@Test
void identityKeyDifferentMetadataNotEqual() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Object key1 = ctor6.newInstance("artist1", "album1", 1, 1, "title1", "sameMd5");
Object key2 = ctor6.newInstance("artist2", "album1", 1, 1, "title1", "sameMd5");
Object key3 = ctor6.newInstance("artist1", "album2", 1, 1, "title1", "sameMd5");
assertNotEquals(key1, key2);
assertNotEquals(key1, key3);
}
@Test
void validatePathsRejectsSamePaths() throws Exception {
Path p = Paths.get("/tmp/ingest-test");
String inputVsLib = IngestService.validatePaths(p, p, Paths.get("/tmp/ingest-test-rej"));
assertNotNull(inputVsLib);
assertTrue(inputVsLib.contains("不能是同一目录"));
String libVsRej = IngestService.validatePaths(Paths.get("/tmp/ingest-test-in"), p, p);
assertNotNull(libVsRej);
assertTrue(libVsRej.contains("不能是同一目录"));
}
@Test
void validatePathsRejectsNestedPaths() throws Exception {
Path root = Paths.get("/tmp/ingest-nest-test");
Path nested = root.resolve("sub");
String libInInput = IngestService.validatePaths(root, nested, Paths.get("/tmp/ingest-nest-rej"));
assertNotNull(libInInput);
assertTrue(libInInput.contains("不能位于") || libInInput.contains("不能是同一"));
String rejInInput = IngestService.validatePaths(root, Paths.get("/tmp/ingest-nest-lib"), nested);
assertNotNull(rejInInput);
assertTrue(rejInInput.contains("不能位于") || rejInInput.contains("不能是同一"));
}
@Test
void validatePathsAcceptsValidPaths() throws Exception {
Path input = Paths.get("/tmp/ingest-valid-in");
Path lib = Paths.get("/tmp/ingest-valid-lib");
Path rej = Paths.get("/tmp/ingest-valid-rej");
assertNull(IngestService.validatePaths(input, lib, rej));
}
@Test
void checkDuplicateReturnsNullForNewIdentity() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Set<Object> library = new java.util.HashSet<>();
Set<Object> batch = new java.util.HashSet<>();
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
m.setAccessible(true);
Object newKey = ctor6.newInstance("new", "album", 1, 1, "title", "md5_1");
// 两个集合都为空 → 不重复
Object result = m.invoke(service, newKey, library, batch);
assertNull(result);
}
@Test
void checkDuplicateDetectsMetadataMatch() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Set<Object> library = new java.util.HashSet<>();
Set<Object> batch = new java.util.HashSet<>();
Object existing = ctor6.newInstance("artist", "album", 1, 3, "title", "md5_existing");
library.add(existing);
Object dupe = ctor6.newInstance("artist", "album", 1, 3, "title", "md5_other");
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
m.setAccessible(true);
Object result = m.invoke(service, dupe, library, batch);
assertNotNull(result);
assertEquals("metadata", result);
}
@Test
void checkDuplicateDetectsMd5Fallback() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Set<Object> library = new java.util.HashSet<>();
Set<Object> batch = new java.util.HashSet<>();
// 元数据不同但 MD5 相同
Object existing = ctor6.newInstance("artist1", "album1", 1, 1, "title1", "same_md5_hash");
library.add(existing);
Object dupByMd5 = ctor6.newInstance("artist2", "album2", 2, 2, "title2", "same_md5_hash");
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
m.setAccessible(true);
Object result = m.invoke(service, dupByMd5, library, batch);
assertNotNull(result);
assertEquals("md5", result);
}
// ========== codex-4-2: LRC 侧车文件传播 ==========
@Test
void moveToRejectedMovesLrcSidecar() throws Exception {
Path tmpDir = Files.createTempDirectory("ingest-lrc-propagate-");
try {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
// 创建模拟音频文件 + .lrc 侧车
Path audioFile = tmpDir.resolve("song.flac");
Files.write(audioFile, "audio-data".getBytes());
Path lrcFile = tmpDir.resolve("song.lrc");
Files.write(lrcFile, "[00:01]lyrics".getBytes());
Path rejectedRoot = tmpDir.resolve("Rejected");
Method m = IngestService.class.getDeclaredMethod(
"moveToRejected", Path.class, Path.class, String.class, String.class);
m.setAccessible(true);
m.invoke(service, audioFile, rejectedRoot, "Other", "song.flac");
// 音频被移到 Rejected/Other
assertTrue(Files.exists(rejectedRoot.resolve("Other/song.flac")),
"音频应被移到 Rejected/Other");
// LRC 侧车也被移到 Rejected/Other
assertTrue(Files.exists(rejectedRoot.resolve("Other/song.lrc")),
"LRC 侧车文件应被一同移到 Rejected/Other");
// 源文件不再存在
assertFalse(Files.exists(audioFile), "源音频应已被移动");
assertFalse(Files.exists(lrcFile), "源 LRC 应已被移动");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void moveToRejectedSkipsMissingLrc() throws Exception {
Path tmpDir = Files.createTempDirectory("ingest-lrc-skip-");
try {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Path audioFile = tmpDir.resolve("track.flac");
Files.write(audioFile, "data".getBytes());
// 故意不创建 .lrc 文件
Path rejectedRoot = tmpDir.resolve("Rej");
Method m = IngestService.class.getDeclaredMethod(
"moveToRejected", Path.class, Path.class, String.class, String.class);
m.setAccessible(true);
m.invoke(service, audioFile, rejectedRoot, "Duplicate", "track.flac");
assertTrue(Files.exists(rejectedRoot.resolve("Duplicate/track.flac")));
// 不应报错(无 LRC 时静默跳过)
} finally {
deleteDirectory(tmpDir);
}
}
// ========== codex-4-3: 唯一报告条目(相对路径键) ==========
@Test
void reportOutcomesKeyedByRelativePathAvoidsCollision() throws Exception {
Path tmpDir = Files.createTempDirectory("ingest-relpath-");
try {
// 模拟 Input 目录中有两个同名文件位于不同子目录
Path inputRoot = tmpDir.resolve("Input");
Path sub1 = inputRoot.resolve("album_a");
Path sub2 = inputRoot.resolve("album_b");
Files.createDirectories(sub1);
Files.createDirectories(sub2);
Path file1 = sub1.resolve("01.flac");
Path file2 = sub2.resolve("01.flac");
Files.write(file1, "a".getBytes());
Files.write(file2, "b".getBytes());
// relativize 应产生差异化路径
String rel1 = inputRoot.relativize(file1).toString();
String rel2 = inputRoot.relativize(file2).toString();
assertEquals("album_a/01.flac", rel1);
assertEquals("album_b/01.flac", rel2);
assertNotEquals(rel1, rel2, "不同子目录中的同名文件应有不同 relative 路径");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 工具方法 ==========
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) {}
}
}
@@ -5,9 +5,12 @@ import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ProgressMessageMappingTest {
@@ -76,4 +79,84 @@ class ProgressMessageMappingTest {
assertEquals(3, pm.getUpgradedFiles().intValue());
assertEquals(2, pm.getSkippedFiles().intValue());
}
@Test
void ingestProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class), store,
mock(TraditionalFilterService.class),
mock(ConfigService.class));
Method m = IngestService.class.getDeclaredMethod(
"sendProgress", String.class, int.class, int.class,
int.class, int.class, int.class, int.class,
int.class, int.class, String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(service, "t-ingest", 100, 50, 10, 5, 3, 2, 1, 4, "f.mp3", "msg", false);
ProgressMessage pm = store.get("t-ingest");
assertEquals(10, pm.getIngestedFiles().intValue());
assertEquals(5, pm.getDuplicateFiles().intValue());
assertEquals(3, pm.getMissingMetadataFiles().intValue());
assertEquals(2, pm.getUnreadableFiles().intValue());
assertEquals(1, pm.getConversionFailedFiles().intValue());
assertEquals(4, pm.getOtherRejectedFiles().intValue());
}
@Test
void ingestControllerRejectsConcurrentStarts() throws Exception {
ConfigService cfgService = mock(ConfigService.class);
when(cfgService.getBasePath()).thenReturn("/tmp");
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class),
new ProgressStore(),
cfgService);
// 模拟配置已设置
com.music.dto.ConfigResponse cfgResp = new com.music.dto.ConfigResponse();
cfgResp.setBasePath("/tmp");
java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running");
runningField.setAccessible(true);
AtomicBoolean running = (AtomicBoolean) runningField.get(controller);
// 设置为运行中
running.set(true);
// 验证并发被拒绝
com.music.exception.BusinessException ex = org.junit.jupiter.api.Assertions.assertThrows(
com.music.exception.BusinessException.class,
() -> controller.start(new com.music.dto.IngestRequest())
);
assertEquals(409, ex.getCode());
// 重置
running.set(false);
}
@Test
void ingestControllerAcceptsSecondStartAfterCompletion() throws Exception {
ConfigService cfgService = mock(ConfigService.class);
when(cfgService.getBasePath()).thenReturn("/tmp");
ProgressStore store = new ProgressStore();
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class),
store,
cfgService);
java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running");
runningField.setAccessible(true);
AtomicBoolean running = (AtomicBoolean) runningField.get(controller);
// 模拟第一个任务完成:IngestService.finally 释放了锁
running.set(true);
running.set(false); // 模拟 finally 释放
// 第二个 start 应成功(没有 409 异常)
com.music.common.Result<?> result = controller.start(new com.music.dto.IngestRequest());
assertTrue(result.getCode() == 0);
}
}