feat: productize ingest lifecycle and library health

This commit is contained in:
2026-07-21 08:56:38 +08:00
parent f5033c7020
commit f4f4ab092f
23 changed files with 2556 additions and 38 deletions
@@ -696,8 +696,136 @@ class IngestServiceE2ETest {
}
}
// ========== 7b. 歌词报告统计(歌词缺失/失败绝不拒绝音频) ==========
@Test
void lyricReportingCountsAndPerFileOutcomeWithoutRejectingAudio() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-lyric-report-");
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);
// 文件 A:附带同名 .lrc 侧车 → lyricsFound(sidecar)
Path a = createTaggedMp3(inputDir, "withlrc.mp3", "Song A", "Artist A", "Album A");
Files.write(inputDir.resolve("withlrc.lrc"),
"[00:01.00]hello".getBytes(StandardCharsets.UTF_8));
// 文件 B:无歌词(lyricsService 未注入,远程被跳过)→ lyricsMissing
createTaggedMp3(inputDir, "nolrc.mp3", "Song B", "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,
new AudioValidationService());
service.ingest("lyric-report-task", new AtomicBoolean(true));
// 两个音频都应入库(歌词有无绝不影响音频入库结果 —— AC1)
assertTrue(Files.exists(libDir.resolve("Artist A").resolve("Album A")
.resolve("01 - Song A.mp3")), "有歌词文件应入库");
assertTrue(Files.exists(libDir.resolve("Artist B").resolve("Album B")
.resolve("01 - Song B.mp3")), "无歌词文件仍应入库");
Path reportsDir = rejDir.resolve("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, "应存在报告文件");
String report = new String(Files.readAllBytes(reports[0]), StandardCharsets.UTF_8);
assertTrue(report.contains("\"lyricsFound\": 1"), "汇总应含 lyricsFound=1,实际:\n" + report);
assertTrue(report.contains("\"lyricsMissing\": 1"), "汇总应含 lyricsMissing=1");
assertTrue(report.contains("\"lyricsFailed\": 0"), "汇总应含 lyricsFailed=0");
assertTrue(report.contains("\"lyricStatus\": \"found\""), "应有 per-file found 状态");
assertTrue(report.contains("\"lyricSource\": \"sidecar\""), "侧车来源应记录为 sidecar");
assertTrue(report.contains("\"lyricStatus\": \"missing\""), "应有 per-file missing 状态");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 8. 并发锁 ==========
@Test
void cancelStopsFurtherProcessingAndKeepsCompletedFiles() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-cancel-");
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, "one.mp3", "One", "Art One", "Alb One");
createTaggedMp3(inputDir, "two.mp3", "Two", "Art Two", "Alb Two");
createTaggedMp3(inputDir, "three.mp3", "Three", "Art Three", "Alb Three");
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());
// 在第一个文件 recordProcessed 后自动请求取消,制造“中途取消”
IngestTaskStore store = new IngestTaskStore(configService) {
@Override
public synchronized void recordProcessed(String taskId, String fileKey, String outcome,
String lyricSource, String lyricStatus,
Counters counters) {
super.recordProcessed(taskId, fileKey, outcome, lyricSource, lyricStatus, counters);
requestCancel(taskId);
}
};
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
configService,
new AudioValidationService());
setPrivateField(service, "taskStore", store);
AtomicBoolean runningLock = new AtomicBoolean(true);
service.ingest("cancel-task", runningLock);
// 恰好一个文件被处理并入库;其余两个仍留在 Input(未被处理)
long libAudio;
try (Stream<Path> w = Files.walk(libDir)) {
libAudio = w.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".mp3")).count();
}
long inputRemaining;
try (Stream<Path> w = Files.walk(inputDir)) {
inputRemaining = w.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".mp3")).count();
}
assertEquals(1, libAudio, "取消前已完成的文件应入库并保留");
assertEquals(2, inputRemaining, "取消后未处理文件应保留在 Input,供下次导入");
assertEquals(IngestTaskStore.STATUS_CANCELLED, store.get("cancel-task").status,
"任务状态应为 cancelled");
assertFalse(runningLock.get(), "取消后并发锁仍应释放");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void concurrencyLockReleasedOnNormalCompletion() throws Exception {
assumeFfmpeg();
@@ -2408,6 +2536,13 @@ class IngestServiceE2ETest {
} catch (Exception ignored) {}
}
/** 通过反射设置 IngestService 的可选注入字段(测试构造器不设置这些字段)。 */
private static void setPrivateField(Object target, String field, Object value) throws Exception {
java.lang.reflect.Field f = target.getClass().getDeclaredField(field);
f.setAccessible(true);
f.set(target, value);
}
/** 解析系统路径中 ffmpeg 的绝对路径 */
private static String resolveFfmpegPath() throws IOException {
String userDefined = System.getProperty("mangtool.ffmpeg.bin");
@@ -0,0 +1,242 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
/**
* {@link IngestTaskStore} 生命周期持久化与重启恢复测试。
*/
class IngestTaskStoreTest {
@Test
void recoverInterrupted_marksRunningAsInterruptedAcrossReload() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
// 第一个实例:登记一个 running 任务并落盘
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("t-1", 10);
s1.recordProcessed("t-1", "a.mp3");
assertEquals(IngestTaskStore.STATUS_RUNNING, s1.get("t-1").status);
assertTrue(Files.isRegularFile(stateFile), "状态应已落盘");
// 第二个实例:模拟进程重启,加载 + 恢复
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
s2.recoverInterrupted();
IngestTaskStore.TaskState recovered = s2.get("t-1");
assertNotNull(recovered, "重载后应能读到任务");
assertEquals(IngestTaskStore.STATUS_INTERRUPTED, recovered.status,
"重启后仍处于 running 的任务应被标记为 interrupted");
// 已处理文件记录必须随重载保留(每条立即落盘,不丢最近一批)
assertEquals(1, recovered.processed, "processed 计数应随重载保留");
assertEquals(1, recovered.files.size());
assertEquals("a.mp3", recovered.files.get(0).file, "已处理文件键应随重载保留");
// 幂等:再次恢复不改变已终态任务
s2.recoverInterrupted();
assertEquals(IngestTaskStore.STATUS_INTERRUPTED, s2.get("t-1").status);
} finally {
deleteDir(tmpDir);
}
}
@Test
void everyProcessedRecordPersistsImmediately() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("t-batch", 5);
// 仅处理少量(< 旧的 20 批阈值),崩溃重载后不得丢失
for (int i = 0; i < 3; i++) {
s1.recordProcessed("t-batch", "f" + i + ".mp3");
}
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
IngestTaskStore.TaskState st = s2.get("t-batch");
assertNotNull(st);
assertEquals(3, st.processed, "3 条已处理记录应全部落盘(无 batch 丢失)");
assertEquals(3, st.files.size());
} finally {
deleteDir(tmpDir);
}
}
@Test
void interruptedReload_restoresCountersAndPerFileOutcomes() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("mix", 4);
// 文件1:成功入库 + 侧车歌词
s1.recordProcessed("mix", "a.mp3", "ingested", "sidecar", "found",
new IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0));
// 文件2:重复
s1.recordProcessed("mix", "b.mp3", "rejected:duplicate", null, null,
new IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0));
// 文件3:缺元数据
s1.recordProcessed("mix", "c.mp3", "rejected:missing-metadata", null, null,
new IngestTaskStore.Counters(1, 1, 1, 0, 0, 0, 0, 1, 0, 0));
// 文件4:入库但歌词失败
s1.recordProcessed("mix", "d.mp3", "ingested", "none", "failed",
new IngestTaskStore.Counters(2, 1, 1, 0, 0, 0, 0, 1, 0, 1));
// 未 complete → 模拟中断
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
s2.recoverInterrupted();
IngestTaskStore.TaskState st = s2.get("mix");
assertNotNull(st);
assertEquals(IngestTaskStore.STATUS_INTERRUPTED, st.status);
// 汇总计数恢复
assertEquals(2, st.ingested, "ingested 计数应恢复");
assertEquals(1, st.duplicates, "duplicates 计数应恢复");
assertEquals(1, st.missingMetadata, "missingMetadata 计数应恢复");
assertEquals(1, st.lyricsFound, "lyricsFound 计数应恢复");
assertEquals(1, st.lyricsFailed, "lyricsFailed 计数应恢复");
assertEquals(4, st.processed);
// 逐文件结果恢复
assertEquals(4, st.files.size());
assertEquals("rejected:duplicate", st.files.get(1).outcome, "重复曲目逐文件结果应恢复");
assertEquals("sidecar", st.files.get(0).lyricSource);
assertEquals("found", st.files.get(0).lyricStatus);
assertEquals("failed", st.files.get(3).lyricStatus);
} finally {
deleteDir(tmpDir);
}
}
@Test
void retention_keepsLatestTasksAndDropsOldest() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s = new IngestTaskStore(mock(ConfigService.class));
s.setStateFileOverride(stateFile);
int over = IngestTaskStore.MAX_TASKS + 5;
for (int i = 0; i < over; i++) {
s.begin("t-" + i, 1);
s.complete("t-" + i, "done");
}
// 有界:任务数不超过上限
int present = 0;
for (int i = 0; i < over; i++) {
if (s.get("t-" + i) != null) present++;
}
assertTrue(present <= IngestTaskStore.MAX_TASKS, "保留任务数应受 MAX_TASKS 限制,实际=" + present);
// 最新任务保留,最旧任务被压实丢弃
assertNotNull(s.get("t-" + (over - 1)), "最新任务应保留");
assertNull(s.get("t-0"), "最旧任务应被压实丢弃");
} finally {
deleteDir(tmpDir);
}
}
@Test
void perTaskFileWindow_isBounded() {
IngestTaskStore s = new IngestTaskStore(mock(ConfigService.class));
s.begin("big", IngestTaskStore.MAX_FILES_PER_TASK + 50);
for (int i = 0; i < IngestTaskStore.MAX_FILES_PER_TASK + 50; i++) {
s.recordProcessed("big", "f" + i + ".mp3");
}
IngestTaskStore.TaskState st = s.get("big");
assertEquals(IngestTaskStore.MAX_FILES_PER_TASK, st.files.size(),
"逐文件记录应受滚动窗口上限限制");
assertEquals(IngestTaskStore.MAX_FILES_PER_TASK + 50, st.processed,
"processed 计数仍为全量(不受窗口影响)");
// 保留的是最近的记录
assertEquals("f" + (IngestTaskStore.MAX_FILES_PER_TASK + 49) + ".mp3",
st.files.get(st.files.size() - 1).file);
}
@Test
void markFailed_persistsFailedTerminalStatus() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("fail-1", 2);
s1.markFailed("fail-1", "boom");
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
s2.recoverInterrupted();
IngestTaskStore.TaskState st = s2.get("fail-1");
assertEquals(IngestTaskStore.STATUS_FAILED, st.status, "failed 是终态,不应被恢复改成 interrupted");
assertEquals("boom", st.message);
} finally {
deleteDir(tmpDir);
}
}
@Test
void latest_returnsMostRecentlyUpdatedTask() {
IngestTaskStore s = new IngestTaskStore(mock(ConfigService.class));
assertNull(s.latest());
s.begin("old", 1);
s.begin("new", 1);
s.recordProcessed("new", "x.mp3");
IngestTaskStore.TaskState latest = s.latest();
assertNotNull(latest);
assertEquals("new", latest.taskId, "latest 应返回最近更新的任务");
}
@Test
void completedTask_notMarkedInterruptedOnReload() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("done-1", 1);
s1.complete("done-1", "完成");
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
s2.recoverInterrupted();
assertEquals(IngestTaskStore.STATUS_COMPLETED, s2.get("done-1").status,
"已完成的任务不应被恢复逻辑改成 interrupted");
} finally {
deleteDir(tmpDir);
}
}
@Test
void cancelRequest_flagObservableAndClearedByBegin() {
IngestTaskStore s = new IngestTaskStore(mock(ConfigService.class));
s.requestCancel("x");
assertTrue(s.isCancelRequested("x"));
// begin 应清除历史取消标记,避免新任务误取消
s.begin("x", 3);
assertFalse(s.isCancelRequested("x"));
}
private static void deleteDir(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) {}
}
}
@@ -0,0 +1,348 @@
package com.music.service;
import com.music.dto.LibraryHealthReport;
import com.music.dto.LibraryHealthRepairRequest;
import com.music.dto.LibraryHealthResult;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link LibraryHealthService} 只读扫描与确认后修复的侧车安全测试。
* <p>用占位文件(音频扩展名 + .lrc/.jpg)验证基于文件名的孤立侧车判定与确认门,
* 不依赖 ffmpeg。</p>
*/
class LibraryHealthServiceTest {
private LibraryHealthService newService(Path libraryDir) {
ConfigService cfg = mock(ConfigService.class);
when(cfg.getLibraryDir()).thenReturn(libraryDir.toString());
return new LibraryHealthService(cfg, new TraditionalFilterService());
}
/** 构建:Album 内含 1 音频 + 匹配侧车 + 孤立侧车 + 封面。 */
private Path buildLibrary(Path root) throws Exception {
Path album = root.resolve("Artist").resolve("Album");
Files.createDirectories(album);
Files.write(album.resolve("01 - Song.mp3"), new byte[]{1, 2, 3});
Files.write(album.resolve("01 - Song.lrc"), "[00:01]hi".getBytes(StandardCharsets.UTF_8));
Files.write(album.resolve("orphan.lrc"), "[00:01]orphan".getBytes(StandardCharsets.UTF_8));
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
return album;
}
@Test
void scan_isReadOnly_detectsOrphanSidecarWithoutModifying() throws Exception {
Path root = Files.createTempDirectory("lib-health-");
try {
Path album = buildLibrary(root);
LibraryHealthService svc = newService(root);
LibraryHealthReport report = svc.scan(false);
assertTrue(report.isReadOnly());
assertEquals(1, report.getAlbums());
assertEquals(1, report.getTracks());
assertEquals(1, report.getOrphanSidecars(), "应检出 1 个孤立侧车");
assertEquals(0, report.getAlbumsMissingCover(), "cover.jpg 存在,不应计缺封面");
assertEquals(0, report.getTracksMissingLyrics(), "01 - Song.lrc 匹配,不应计缺歌词");
assertFalse(report.isDecodeChecked(), "未启用解码校验");
// 只读:所有文件仍在
assertTrue(Files.exists(album.resolve("01 - Song.mp3")));
assertTrue(Files.exists(album.resolve("01 - Song.lrc")));
assertTrue(Files.exists(album.resolve("orphan.lrc")), "扫描绝不删除任何文件");
assertTrue(Files.exists(album.resolve("cover.jpg")));
} finally {
deleteDir(root);
}
}
@Test
void repair_withoutConfirm_isDryRunAndDeletesNothing() throws Exception {
Path root = Files.createTempDirectory("lib-health-");
try {
Path album = buildLibrary(root);
LibraryHealthService svc = newService(root);
LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
req.setConfirm(false);
req.setRemoveOrphanSidecars(true);
LibraryHealthResult res = svc.repair(req);
assertFalse(res.isApplied(), "未确认应为演练");
assertEquals(0, res.getOrphanSidecarsRemoved());
assertTrue(Files.exists(album.resolve("orphan.lrc")), "演练不得删除任何文件");
assertTrue(Files.exists(album.resolve("01 - Song.lrc")));
} finally {
deleteDir(root);
}
}
@Test
void repair_confirmed_removesOnlyOrphanSidecar_preservesMatchedAndAudio() throws Exception {
Path root = Files.createTempDirectory("lib-health-");
try {
Path album = buildLibrary(root);
LibraryHealthService svc = newService(root);
LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
req.setConfirm(true);
req.setRemoveOrphanSidecars(true);
LibraryHealthResult res = svc.repair(req);
assertTrue(res.isApplied());
assertEquals(1, res.getOrphanSidecarsRemoved(), "仅应删除 1 个孤立侧车");
assertFalse(Files.exists(album.resolve("orphan.lrc")), "孤立侧车应被删除");
// 侧车安全:匹配侧车与音频、封面均保留
assertTrue(Files.exists(album.resolve("01 - Song.lrc")), "匹配侧车不得删除");
assertTrue(Files.exists(album.resolve("01 - Song.mp3")), "音频不得删除");
assertTrue(Files.exists(album.resolve("cover.jpg")), "封面不得删除");
} finally {
deleteDir(root);
}
}
@Test
void scan_reportsMissingCoverForUncoveredAlbum() throws Exception {
Path root = Files.createTempDirectory("lib-health-");
try {
Path album = root.resolve("A").resolve("NoCover");
Files.createDirectories(album);
Files.write(album.resolve("01 - T.mp3"), new byte[]{9});
LibraryHealthService svc = newService(root);
LibraryHealthReport report = svc.scan(false);
assertEquals(1, report.getAlbumsMissingCover());
assertEquals(1, report.getTracksMissingLyrics());
} finally {
deleteDir(root);
}
}
// ========== codex-1-3: FFprobe 后备 + 无效音频删除同 basename 侧车(需 ffmpeg ==========
@org.junit.jupiter.api.Test
void scan_usesFfprobeFallback_forJaudiotaggerUnreadableM4a() throws Exception {
assumeFfmpeg();
Path root = Files.createTempDirectory("lib-health-fb-");
try {
Path album = root.resolve("FB Artist").resolve("FB Album");
Files.createDirectories(album);
// MP4/AAC 内容但命名为 .aacjaudiotagger 无法解析,FFprobe 可读完整元数据
createMp4AsAac(album.resolve("01 - Song.aac"), "T", "A", "Al");
// 提供封面避免与缺元数据无关的其他计数干扰(缺封面不影响本断言)
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
assertJaudiotaggerCannotRead(album.resolve("01 - Song.aac"));
LibraryHealthService svc = withValidation(newService(root));
LibraryHealthReport report = svc.scan(false);
assertEquals(1, report.getTracks());
assertEquals(0, report.getTracksMissingMetadata(),
"FFprobe 后备可读元数据的合法 M4A 不应被误报为缺元数据");
} finally {
deleteDir(root);
}
}
@org.junit.jupiter.api.Test
void repair_removeDecodeInvalid_alsoDeletesSameBasenameSidecar() throws Exception {
assumeFfmpeg();
Path root = Files.createTempDirectory("lib-health-inv-");
try {
Path album = root.resolve("Art").resolve("Alb");
Files.createDirectories(album);
// 损坏音频(ffprobe/ffmpeg 无法解码)+ 同 basename 侧车
Files.write(album.resolve("bad.mp3"), "not audio".getBytes(StandardCharsets.UTF_8));
Files.write(album.resolve("bad.lrc"), "[00:01]x".getBytes(StandardCharsets.UTF_8));
// 另一首“有效”曲目(真实音频,解码通过)的侧车与公共封面:不得被误删
createValidMp3(album.resolve("good.mp3"));
Files.write(album.resolve("good.lrc"), "[00:01]y".getBytes(StandardCharsets.UTF_8));
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
LibraryHealthService svc = withValidation(newService(root));
LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
req.setConfirm(true);
req.setRemoveDecodeInvalid(true);
LibraryHealthResult res = svc.repair(req);
assertTrue(res.isApplied());
assertTrue(res.getDecodeInvalidRemoved() >= 1, "损坏音频应被删除");
assertFalse(Files.exists(album.resolve("bad.mp3")), "损坏音频应删除");
assertFalse(Files.exists(album.resolve("bad.lrc")), "损坏音频的同名侧车应一并删除,避免遗留孤立 .lrc");
// 侧车安全:其他曲目侧车、公共封面保留
assertTrue(Files.exists(album.resolve("good.lrc")), "其他曲目侧车不得删除");
assertTrue(Files.exists(album.resolve("cover.jpg")), "公共封面不得删除");
} finally {
deleteDir(root);
}
}
// ========== codex-2-2: 去重身份含 Disc/Track + 大小写不敏感 .lrc ==========
@org.junit.jupiter.api.Test
void scan_sameTitleDifferentTrack_notFlaggedDuplicate() throws Exception {
assumeFfmpeg();
Path root = Files.createTempDirectory("lib-health-dup-");
try {
Path album = root.resolve("Art").resolve("Alb");
Files.createDirectories(album);
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
// 同 Title/Artist/Album 但 Track 不同 —— 合法的不同曲目(如 live/remix 同名),不应判重复
createMp4AsAac(album.resolve("01 - Song.aac"), "Song", "Art", "Alb", 1);
createMp4AsAac(album.resolve("02 - Song.aac"), "Song", "Art", "Alb", 2);
LibraryHealthService svc = withValidation(newService(root));
LibraryHealthReport report = svc.scan(false);
assertEquals(2, report.getTracks());
assertEquals(0, report.getTracksMissingMetadata(), "FFprobe 后备应读到元数据");
assertEquals(0, report.getDuplicateGroups(),
"同名不同 Track 的合法曲目不应被误判为重复(身份含 Disc/Track)");
assertEquals(0, report.getDuplicateTracks());
} finally {
deleteDir(root);
}
}
@org.junit.jupiter.api.Test
void scan_sameTitleSameTrack_isDuplicate() throws Exception {
assumeFfmpeg();
Path root = Files.createTempDirectory("lib-health-dup2-");
try {
Path album = root.resolve("Art").resolve("Alb");
Files.createDirectories(album);
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
// 完全相同的身份(含相同 Track)→ 真重复
createMp4AsAac(album.resolve("a.aac"), "Song", "Art", "Alb", 1);
createMp4AsAac(album.resolve("b.aac"), "Song", "Art", "Alb", 1);
LibraryHealthService svc = withValidation(newService(root));
LibraryHealthReport report = svc.scan(false);
assertEquals(1, report.getDuplicateGroups(), "相同身份应判为一组重复");
assertEquals(2, report.getDuplicateTracks());
} finally {
deleteDir(root);
}
}
@org.junit.jupiter.api.Test
void scan_uppercaseLrc_notReportedMissingLyricsNorOrphan() throws Exception {
Path root = Files.createTempDirectory("lib-health-lrc-");
try {
Path album = root.resolve("A").resolve("B");
Files.createDirectories(album);
// 大写 .LRC 侧车:既不应算缺歌词,也不应算孤立(与 orphan 判定保持一致)
Files.write(album.resolve("01 - Song.mp3"), new byte[]{1, 2, 3});
Files.write(album.resolve("01 - Song.LRC"), "[00:01]hi".getBytes(StandardCharsets.UTF_8));
LibraryHealthService svc = newService(root);
LibraryHealthReport report = svc.scan(false);
assertEquals(0, report.getTracksMissingLyrics(),
"大写 .LRC 应被识别为已有歌词(大小写不敏感)");
assertEquals(0, report.getOrphanSidecars(),
"匹配到音频的 .LRC 不应被判为孤立");
} finally {
deleteDir(root);
}
}
// ---- ffmpeg 辅助 ----
private static void assumeFfmpeg() { boolean ok;
try {
Process p = new ProcessBuilder("ffmpeg", "-version").redirectErrorStream(true).start();
ok = p.waitFor(5, java.util.concurrent.TimeUnit.SECONDS) && p.exitValue() == 0;
if (p.isAlive()) p.destroyForcibly();
} catch (Exception e) {
ok = false;
}
org.junit.jupiter.api.Assumptions.assumeTrue(ok, "此测试需要 ffmpeg");
}
/** 注入真实 AudioValidationService,启用 FFprobe 后备 / 解码校验。 */
private static LibraryHealthService withValidation(LibraryHealthService svc) throws Exception {
java.lang.reflect.Field f = LibraryHealthService.class.getDeclaredField("audioValidationService");
f.setAccessible(true);
f.set(svc, new AudioValidationService());
return svc;
}
/** 生成一个真实可解码的短 MP3(有效音频,不会被解码校验判无效)。 */
private static void createValidMp3(Path out) throws Exception {
run(new ProcessBuilder("ffmpeg", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.2",
"-c:a", "libmp3lame", out.toAbsolutePath().toString()));
}
private static void createMp4AsAac(Path out, String title, String artist, String album) throws Exception {
createMp4AsAac(out, title, artist, album, null);
}
private static void createMp4AsAac(Path out, String title, String artist, String album, Integer track) throws Exception {
Path cover = out.resolveSibling("." + out.getFileName() + ".cover.png");
run(new ProcessBuilder("ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=blue:s=48x48:d=0.1",
"-frames:v", "1", cover.toAbsolutePath().toString()));
java.util.List<String> cmd = new java.util.ArrayList<>(java.util.Arrays.asList("ffmpeg", "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-i", cover.toAbsolutePath().toString(),
"-map", "0:a", "-map", "1:v", "-disposition:v:0", "attached_pic", "-c:v", "png",
"-t", "0.3", "-c:a", "aac", "-f", "mp4",
"-metadata", "title=" + title, "-metadata", "artist=" + artist, "-metadata", "album=" + album));
if (track != null) {
cmd.add("-metadata");
cmd.add("track=" + track);
}
cmd.add(out.toAbsolutePath().toString());
run(new ProcessBuilder(cmd));
Files.deleteIfExists(cover);
}
private static void run(ProcessBuilder pb) throws Exception {
pb.redirectErrorStream(true);
Process p = pb.start();
// 排空输出
try (java.io.InputStream in = p.getInputStream()) {
byte[] buf = new byte[4096];
while (in.read(buf) != -1) { /* drain */ }
}
if (!p.waitFor(20, java.util.concurrent.TimeUnit.SECONDS)) {
p.destroyForcibly();
throw new RuntimeException("ffmpeg 超时");
}
if (p.exitValue() != 0) throw new RuntimeException("ffmpeg 失败 exit=" + p.exitValue());
}
private static void assertJaudiotaggerCannotRead(Path file) {
boolean readable;
try {
org.jaudiotagger.audio.AudioFile af =
org.jaudiotagger.audio.AudioFileIO.read(file.toFile());
readable = af.getTag() != null;
} catch (Exception e) {
readable = false;
}
assertFalse(readable, "前置条件:jaudiotagger 应无法读取该文件的标签");
}
private static void deleteDir(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) {}
}
}
@@ -113,7 +113,8 @@ class ProgressMessageMappingTest {
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class),
new ProgressStore(),
cfgService);
cfgService,
mock(com.music.service.IngestTaskStore.class));
// 模拟配置已设置
com.music.dto.ConfigResponse cfgResp = new com.music.dto.ConfigResponse();
@@ -146,7 +147,8 @@ class ProgressMessageMappingTest {
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class),
store,
cfgService);
cfgService,
mock(com.music.service.IngestTaskStore.class));
java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running");
runningField.setAccessible(true);
@@ -166,7 +168,8 @@ class ProgressMessageMappingTest {
ConfigService cfgService = mock(ConfigService.class);
ProgressStore store = new ProgressStore();
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class), store, cfgService);
mock(IngestService.class), store, cfgService,
mock(com.music.service.IngestTaskStore.class));
com.music.common.Result<com.music.dto.IngestStatusResponse> result =
controller.statusById("expired-task");
@@ -175,4 +178,55 @@ class ProgressMessageMappingTest {
assertTrue(result.getData().isCompleted());
assertEquals("未找到任务或已过期", result.getData().getMessage());
}
@Test
void ingestControllerExposesPersistedInterruptedTaskAfterRestart() throws Exception {
java.nio.file.Path tmpDir = java.nio.file.Files.createTempDirectory("ctrl-restart-");
java.nio.file.Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
ConfigService cfgService = mock(ConfigService.class);
// 模拟“上次进程”:登记一个 running 任务并记录已处理文件(含汇总计数)后落盘
com.music.service.IngestTaskStore prev = new com.music.service.IngestTaskStore(cfgService);
prev.setStateFileOverride(stateFile);
prev.begin("restart-task", 5);
prev.recordProcessed("restart-task", "done1.mp3", "ingested", "sidecar", "found",
new com.music.service.IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0));
prev.recordProcessed("restart-task", "done2.mp3", "rejected:duplicate", null, null,
new com.music.service.IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0));
// 模拟“重启”:新 store 加载 + 恢复(running -> interrupted
com.music.service.IngestTaskStore fresh = new com.music.service.IngestTaskStore(cfgService);
fresh.setStateFileOverride(stateFile);
fresh.load();
fresh.recoverInterrupted();
// 新进程的控制器:ProgressStore 为空(内存进度已丢失),currentTaskId 为 null
ProgressStore store = new ProgressStore();
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class), store, cfgService, fresh);
com.music.common.Result<com.music.dto.IngestStatusResponse> result = controller.statusCurrent();
com.music.dto.IngestStatusResponse resp = result.getData();
assertEquals("restart-task", resp.getTaskId(), "重启后应从持久化恢复最近任务");
assertFalse(resp.isRunning(), "interrupted 任务不再运行");
assertTrue(resp.isCompleted(), "终态任务对前端视为已结束");
assertEquals(2, resp.getProcessed(), "已处理文件数应随重载暴露");
assertEquals(5, resp.getTotal());
// 汇总计数亦应随重启恢复(此前 IngestController 恒置零)
assertEquals(1, resp.getIngestedFiles(), "入库计数应随重启恢复");
assertEquals(1, resp.getDuplicateFiles(), "重复计数应随重启恢复");
assertEquals(1, resp.getLyricsFound(), "歌词计数应随重启恢复");
} finally {
deleteDirRecursive(tmpDir);
}
}
private static void deleteDirRecursive(java.nio.file.Path dir) {
try {
java.nio.file.Files.walk(dir).sorted((a, b) -> -a.compareTo(b))
.forEach(p -> { try { java.nio.file.Files.deleteIfExists(p); } catch (Exception ignored) {} });
} catch (Exception ignored) {}
}
}
@@ -0,0 +1,112 @@
package com.music.service;
import com.music.dto.DependencyStatus;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link RuntimeDependencyService} 依赖探测测试(用伪脚本,不依赖真实 ffmpeg)。
*/
class RuntimeDependencyServiceTest {
private final RuntimeDependencyService service = new RuntimeDependencyService();
@AfterEach
void clear() {
System.clearProperty("mangtool.ffmpeg.bin");
System.clearProperty("mangtool.ffprobe.bin");
}
@Test
void reportsAvailableWithVersionLine() throws Exception {
Path tmp = Files.createTempDirectory("dep-");
try {
Path fake = writeScript(tmp, "fake.sh",
"#!/bin/sh",
"echo 'ffmpeg version 6.1.1 fake'",
"exit 0");
System.setProperty("mangtool.ffmpeg.bin", fake.toString());
System.setProperty("mangtool.ffprobe.bin", fake.toString());
DependencyStatus s = service.check();
assertTrue(s.isFfmpegAvailable());
assertTrue(s.isFfprobeAvailable());
assertTrue(s.isAllAvailable());
assertTrue(s.getFfmpegVersion().contains("6.1.1"), s.getFfmpegVersion());
} finally {
deleteDir(tmp);
}
}
@Test
void reportsUnavailableForMissingBinary() {
System.setProperty("mangtool.ffmpeg.bin", "/nonexistent/ffmpeg-xyz");
System.setProperty("mangtool.ffprobe.bin", "/nonexistent/ffprobe-xyz");
DependencyStatus s = service.check();
assertFalse(s.isFfmpegAvailable());
assertFalse(s.isFfprobeAvailable());
assertFalse(s.isAllAvailable());
}
@Test
void nonZeroExitIsUnavailable() throws Exception {
Path tmp = Files.createTempDirectory("dep-");
try {
Path fake = writeScript(tmp, "bad.sh", "#!/bin/sh", "echo boom >&2", "exit 3");
System.setProperty("mangtool.ffmpeg.bin", fake.toString());
System.setProperty("mangtool.ffprobe.bin", fake.toString());
DependencyStatus s = service.check();
assertFalse(s.isFfmpegAvailable());
} finally {
deleteDir(tmp);
}
}
@Test
void hangingBinaryHonorsTimeoutAndDoesNotBlockForever() throws Exception {
Path tmp = Files.createTempDirectory("dep-");
try {
// 打印一行后长睡且持有 stdout 不退出:若同步 readLine 到 EOF 会永久阻塞
Path hang = writeScript(tmp, "hang.sh",
"#!/bin/sh",
"echo 'ffmpeg version hang'",
"sleep 60");
System.setProperty("mangtool.ffmpeg.bin", hang.toString());
System.setProperty("mangtool.ffprobe.bin", hang.toString());
RuntimeDependencyService fast = new RuntimeDependencyService();
fast.setTimeoutSeconds(1);
long start = System.currentTimeMillis();
DependencyStatus s = fast.check();
long elapsed = System.currentTimeMillis() - start;
assertFalse(s.isFfmpegAvailable(), "挂起进程应因超时被判不可用");
assertFalse(s.isFfprobeAvailable());
// 两次探测各约 1s 超时 + 有界回收,总耗时应远小于 sleep 60s
assertTrue(elapsed < 20_000, "超时应生效而非永久阻塞,耗时(ms)=" + elapsed);
} finally {
deleteDir(tmp);
}
}
private static Path writeScript(Path dir, String name, String... lines) throws Exception {
Path p = dir.resolve(name);
Files.write(p, Arrays.asList(lines));
p.toFile().setExecutable(true);
return p;
}
private static void deleteDir(Path dir) {
try {
Files.walk(dir).sorted((a, b) -> -a.compareTo(b))
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} });
} catch (Exception ignored) {}
}
}