Template
feat: productize ingest lifecycle and library health
This commit is contained in:
@@ -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 内容但命名为 .aac:jaudiotagger 无法解析,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) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user