Improve music processing robustness and workflow UX

Unify safe file-move behavior and richer progress semantics across backend tasks, while upgrading traditional-to-simplified conversion and refining the frontend multi-step panels for clearer execution feedback.
This commit is contained in:
2026-03-08 04:26:18 +08:00
parent 20a70270c7
commit 81977a157e
39 changed files with 2131 additions and 1511 deletions
@@ -0,0 +1,73 @@
package com.music.common;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assumptions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FileTransferUtilsTest {
@Test
void moveWithFallbackMovesFileAndRemovesSource() throws Exception {
Path tempDir = Files.createTempDirectory("file-transfer-test-");
Path source = tempDir.resolve("source.txt");
Path target = tempDir.resolve("nested/target.txt");
byte[] content = "hello-move".getBytes(StandardCharsets.UTF_8);
Files.write(source, content);
FileTransferUtils.moveWithFallback(source, target);
assertFalse(Files.exists(source));
assertTrue(Files.exists(target));
assertArrayEquals(content, Files.readAllBytes(target));
}
@Test
void moveWithFallbackOverwritesExistingTarget() throws Exception {
Path tempDir = Files.createTempDirectory("file-transfer-overwrite-");
Path source = tempDir.resolve("source.txt");
Path target = tempDir.resolve("target.txt");
Files.write(source, "new-data".getBytes(StandardCharsets.UTF_8));
Files.write(target, "old-data".getBytes(StandardCharsets.UTF_8));
FileTransferUtils.moveWithFallback(source, target);
assertFalse(Files.exists(source));
assertArrayEquals("new-data".getBytes(StandardCharsets.UTF_8), Files.readAllBytes(target));
}
@Test
void moveWithFallbackWorksAcrossFileStoresWhenAvailable() throws Exception {
Path shmRoot = Paths.get("/dev/shm");
Assumptions.assumeTrue(Files.exists(shmRoot) && Files.isDirectory(shmRoot),
"/dev/shm 不可用,跳过跨文件系统测试");
Path sourceDir = Files.createTempDirectory(shmRoot, "file-transfer-src-");
Path targetDir = Files.createTempDirectory("file-transfer-dst-");
Assumptions.assumeFalse(
Files.getFileStore(sourceDir).equals(Files.getFileStore(targetDir)),
"源和目标在同一文件系统,跳过跨文件系统测试"
);
Path source = sourceDir.resolve("cross.txt");
Path target = targetDir.resolve("cross/out.txt");
byte[] content = "cross-device-data".getBytes(StandardCharsets.UTF_8);
Files.write(source, content);
FileTransferUtils.moveWithFallback(source, target);
assertFalse(Files.exists(source));
assertTrue(Files.exists(target));
assertArrayEquals(content, Files.readAllBytes(target));
}
}
@@ -0,0 +1,96 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assumptions;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class ConvertServiceInternalTest {
@Test
void classifyConvertErrorHandlesInterrupted() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
m.setAccessible(true);
String reason = (String) m.invoke(service, new InterruptedException("interrupted"));
assertEquals("任务线程被中断", reason);
}
@Test
void classifyConvertErrorHandlesExitCode() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
m.setAccessible(true);
String reason = (String) m.invoke(service, new RuntimeException("ffmpeg 退出码: 1"));
assertTrue(reason.contains("ffmpeg 退出码"));
assertTrue(reason.contains("源文件损坏"));
}
@Test
void classifyConvertErrorKeepsTimeoutMessage() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
m.setAccessible(true);
String reason = (String) m.invoke(service, new RuntimeException("ffmpeg 转码超时(600s"));
assertEquals("ffmpeg 转码超时(600s", reason);
}
@Test
void checkFfmpegAvailableReturnsErrorWhenCommandMissing() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method m = ConvertService.class.getDeclaredMethod("checkFfmpegAvailable");
m.setAccessible(true);
String previous = System.getProperty("mangtool.ffmpeg.bin");
try {
System.setProperty("mangtool.ffmpeg.bin", "ffmpeg_not_exists_for_test");
String error = (String) m.invoke(service);
assertTrue(error != null && error.contains("ffmpeg 不可用"));
} finally {
if (previous == null) {
System.clearProperty("mangtool.ffmpeg.bin");
} else {
System.setProperty("mangtool.ffmpeg.bin", previous);
}
}
}
@Test
void runFfmpegReturnsExitCodeErrorForInvalidInput() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method check = ConvertService.class.getDeclaredMethod("checkFfmpegAvailable");
check.setAccessible(true);
String checkError = (String) check.invoke(service);
Assumptions.assumeTrue(checkError == null, "ffmpeg 不可用,跳过损坏文件测试");
Path tempDir = Files.createTempDirectory("convert-invalid-");
Path invalidInput = tempDir.resolve("bad.wav");
Path output = tempDir.resolve("out.flac");
Files.write(invalidInput, "not-audio".getBytes(StandardCharsets.UTF_8));
Method run = ConvertService.class.getDeclaredMethod("runFfmpeg", Path.class, Path.class);
run.setAccessible(true);
InvocationTargetException ex = assertThrows(InvocationTargetException.class,
() -> run.invoke(service, invalidInput, output));
Throwable target = ex.getTargetException();
assertTrue(target.getMessage().contains("ffmpeg 退出码"));
}
}
@@ -0,0 +1,98 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class DedupServiceInternalTest {
@Test
@SuppressWarnings("unchecked")
void filterProcessableCandidatesSkipsProcessedAndMissingFiles() throws Exception {
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), new ProgressStore());
Path tempDir = Files.createTempDirectory("dedup-filter-");
Path fileA = tempDir.resolve("a.mp3");
Path fileB = tempDir.resolve("b.mp3");
Path missing = tempDir.resolve("missing.mp3");
Files.write(fileA, "a".getBytes(StandardCharsets.UTF_8));
Files.write(fileB, "b".getBytes(StandardCharsets.UTF_8));
Set<Path> processed = new HashSet<>();
processed.add(fileB.toAbsolutePath().normalize());
Method m = DedupService.class.getDeclaredMethod("filterProcessableCandidates", List.class, Set.class);
m.setAccessible(true);
List<Path> group = new ArrayList<>();
group.add(fileA);
group.add(fileB);
group.add(missing);
List<Path> result = (List<Path>) m.invoke(service, group, processed);
assertEquals(1, result.size());
assertEquals(fileA, result.get(0));
}
@Test
@SuppressWarnings("unchecked")
void handleDuplicatesAccumulatesMovedCountAndMarksProcessed() throws Exception {
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), new ProgressStore());
Path tempDir = Files.createTempDirectory("dedup-handle-");
Path trashDir = tempDir.resolve("trash");
Path keep = tempDir.resolve("keep.mp3");
Path dup = tempDir.resolve("dup.mp3");
Files.createDirectories(trashDir);
Files.write(keep, "keep".getBytes(StandardCharsets.UTF_8));
Files.write(dup, "dup".getBytes(StandardCharsets.UTF_8));
Method m = DedupService.class.getDeclaredMethod(
"handleDuplicates",
List.class,
Path.class,
Path.class,
String.class,
String.class,
int.class,
AtomicInteger.class,
AtomicInteger.class,
AtomicInteger.class,
AtomicInteger.class,
Set.class
);
m.setAccessible(true);
List<Path> duplicates = new ArrayList<>();
duplicates.add(dup);
AtomicInteger scanned = new AtomicInteger(10);
AtomicInteger groups = new AtomicInteger(2);
AtomicInteger moved = new AtomicInteger(5);
AtomicInteger failed = new AtomicInteger(0);
Set<Path> processed = new HashSet<>();
m.invoke(service, duplicates, keep, trashDir, "copy", "task-1", 20,
scanned, groups, moved, failed, processed);
assertEquals(6, moved.get());
assertEquals(0, failed.get());
assertTrue(processed.contains(dup.toAbsolutePath().normalize()));
}
}
@@ -0,0 +1,37 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class LibraryMergeServiceInternalTest {
@Test
void isExcludedSystemDirectoryRecognizesManualFixAndReports() throws Exception {
LibraryMergeService service = new LibraryMergeService(
mock(SimpMessagingTemplate.class),
new ProgressStore()
);
Path root = Files.createTempDirectory("merge-root-");
Path manualDir = root.resolve("_Manual_Fix_Required_").resolve("Missing_Title");
Path reportsDir = root.resolve("_Reports");
Path normalDir = root.resolve("A").resolve("Artist");
Method method = LibraryMergeService.class.getDeclaredMethod(
"isExcludedSystemDirectory", Path.class, Path.class);
method.setAccessible(true);
assertTrue((Boolean) method.invoke(service, root, manualDir));
assertTrue((Boolean) method.invoke(service, root, reportsDir));
assertFalse((Boolean) method.invoke(service, root, normalDir));
assertFalse((Boolean) method.invoke(service, root, root));
}
}
@@ -0,0 +1,79 @@
package com.music.service;
import com.music.dto.ProgressMessage;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
class ProgressMessageMappingTest {
@Test
void dedupProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), store);
Method m = DedupService.class.getDeclaredMethod(
"sendProgress", String.class, int.class, int.class, int.class, int.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(service, "t-dedup", 100, 50, 3, 8, "msg", false);
ProgressMessage pm = store.get("t-dedup");
assertEquals(3, pm.getDuplicateGroups().intValue());
assertEquals(8, pm.getMovedFiles().intValue());
}
@Test
void organizeProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
OrganizeService service = new OrganizeService(mock(SimpMessagingTemplate.class), store);
Method m = OrganizeService.class.getDeclaredMethod(
"sendProgress", String.class, int.class, int.class, int.class, int.class,
String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(service, "t-organize", 100, 80, 70, 10, "f.mp3", "msg", false);
ProgressMessage pm = store.get("t-organize");
assertEquals(70, pm.getOrganizedFiles().intValue());
assertEquals(10, pm.getManualFixFiles().intValue());
}
@Test
void zhconvertProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
ZhConvertService service = new ZhConvertService(
mock(SimpMessagingTemplate.class), store, new TraditionalFilterService());
Method m = ZhConvertService.class.getDeclaredMethod(
"sendProgress", String.class, int.class, int.class, int.class, int.class,
String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(service, "t-zh", 10, 5, 12, 1, "f.mp3", "msg", false);
ProgressMessage pm = store.get("t-zh");
assertEquals(12, pm.getTraditionalEntries().intValue());
assertEquals(1, pm.getFailedFiles().intValue());
}
@Test
void mergeProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
LibraryMergeService service = new LibraryMergeService(mock(SimpMessagingTemplate.class), store);
Method m = LibraryMergeService.class.getDeclaredMethod(
"sendProgress", String.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-merge", 100, 30, 4, 28, 3, 2, "f.mp3", "msg", false);
ProgressMessage pm = store.get("t-merge");
assertEquals(4, pm.getAlbumsMerged().intValue());
assertEquals(28, pm.getTracksMerged().intValue());
assertEquals(3, pm.getUpgradedFiles().intValue());
assertEquals(2, pm.getSkippedFiles().intValue());
}
}
@@ -0,0 +1,54 @@
package com.music.service;
import com.music.dto.ProgressMessage;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
class ProgressStoreTest {
@Test
void putAndGetReturnsLatestProgress() {
ProgressStore store = new ProgressStore();
ProgressMessage pm = new ProgressMessage();
pm.setTaskId("task-1");
pm.setMessage("running");
store.put(pm);
ProgressMessage fetched = store.get("task-1");
assertEquals("running", fetched.getMessage());
}
@Test
@SuppressWarnings("unchecked")
void getRemovesExpiredProgress() throws Exception {
ProgressStore store = new ProgressStore();
ProgressMessage pm = new ProgressMessage();
pm.setTaskId("expired-task");
pm.setMessage("old");
Field latestField = ProgressStore.class.getDeclaredField("latest");
latestField.setAccessible(true);
Map<String, Object> latest = (Map<String, Object>) latestField.get(store);
Class<?> storedClass = Class.forName("com.music.service.ProgressStore$StoredProgress");
Constructor<?> constructor = storedClass.getDeclaredConstructor(ProgressMessage.class, long.class);
constructor.setAccessible(true);
long oldTimestamp = System.currentTimeMillis() - (31L * 60L * 1000L);
Object expired = constructor.newInstance(pm, oldTimestamp);
latest.put("expired-task", expired);
ProgressMessage fetched = store.get("expired-task");
assertNull(fetched);
assertFalse(latest.containsKey("expired-task"));
}
}
@@ -0,0 +1,40 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TraditionalFilterServiceTest {
@Test
void toSimplifiedConvertsCommonMusicMetadata() {
TraditionalFilterService service = new TraditionalFilterService();
String converted = service.toSimplified("張學友 經典專輯 精選 愛與夢");
assertEquals("张学友 经典专辑 精选 爱与梦", converted);
}
@Test
void analyzeReturnsExpectedTraditionalRatio() {
TraditionalFilterService service = new TraditionalFilterService();
TraditionalFilterService.ZhStats stats = service.analyze("張學友abc");
assertEquals(3, stats.getChineseCount());
assertEquals(2, stats.getTraditionalCount());
assertTrue(stats.getRatio() > 0.6 && stats.getRatio() < 0.7);
}
@Test
void analyzeDetectsTraditionalCharsCoveredByOpencc() {
TraditionalFilterService service = new TraditionalFilterService();
TraditionalFilterService.ZhStats stats = service.analyze("愛與夢");
assertEquals(3, stats.getChineseCount());
assertEquals(3, stats.getTraditionalCount());
assertEquals(1.0, stats.getRatio());
}
}
@@ -0,0 +1,66 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class ZhConvertServiceInternalTest {
@Test
void resolveTargetFileForExecuteKeepsSourceWhenPreview() throws Exception {
ZhConvertService service = new ZhConvertService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService()
);
Path scanDir = Files.createTempDirectory("zh-scan-");
Path srcFile = scanDir.resolve("a/song.mp3");
Files.createDirectories(srcFile.getParent());
Files.write(srcFile, "abc".getBytes(StandardCharsets.UTF_8));
Method m = ZhConvertService.class.getDeclaredMethod(
"resolveTargetFileForExecute", Path.class, Path.class, Path.class, boolean.class);
m.setAccessible(true);
Path result = (Path) m.invoke(service, srcFile, scanDir, null, false);
assertEquals(srcFile, result);
assertTrue(Files.exists(srcFile));
}
@Test
void resolveTargetFileForExecuteMovesFileToOutputWhenExecute() throws Exception {
ZhConvertService service = new ZhConvertService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService()
);
Path scanDir = Files.createTempDirectory("zh-scan-exec-");
Path outDir = Files.createTempDirectory("zh-out-exec-");
Path srcFile = scanDir.resolve("artist/song.mp3");
Files.createDirectories(srcFile.getParent());
byte[] content = "xyz".getBytes(StandardCharsets.UTF_8);
Files.write(srcFile, content);
Method m = ZhConvertService.class.getDeclaredMethod(
"resolveTargetFileForExecute", Path.class, Path.class, Path.class, boolean.class);
m.setAccessible(true);
Path result = (Path) m.invoke(service, srcFile, scanDir, outDir, true);
assertFalse(Files.exists(srcFile));
assertTrue(Files.exists(result));
assertEquals(outDir.resolve("artist/song.mp3"), result);
assertEquals("xyz", new String(Files.readAllBytes(result), StandardCharsets.UTF_8));
}
}