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

View File

@@ -72,6 +72,13 @@
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version>
</dependency>
<!-- OpenCC Java 实现:繁简转换(词典驱动,减少手工维护成本) -->
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>opencc4j</artifactId>
<version>1.13.1</version>
</dependency>
</dependencies>
<build>
@@ -93,4 +100,3 @@
</plugins>
</build>
</project>

View File

@@ -0,0 +1,43 @@
package com.music.common;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
/**
* 文件传输工具:提供跨文件系统安全移动能力。
*/
public final class FileTransferUtils {
private FileTransferUtils() {
}
/**
* 安全移动文件:优先原子移动,失败后回退普通移动,再回退 copy + delete。
*/
public static void moveWithFallback(Path source, Path target) throws IOException {
Path parent = target.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
try {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
return;
} catch (AtomicMoveNotSupportedException ignored) {
// 跨文件系统常见,继续回退
}
try {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
return;
} catch (IOException ignored) {
// 继续回退到 copy + delete
}
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
Files.delete(source);
}
}

View File

@@ -14,5 +14,19 @@ public class ProgressMessage {
private String currentFile; // 当前处理文件
private String message; // 进度消息
private boolean completed; // 是否完成
}
// 以下为按任务类型细化后的标准化字段,前端可优先读取
private Integer duplicateGroups; // dedup: 重复组数量
private Integer movedFiles; // dedup: 移动/复制的重复文件数量
private Integer organizedFiles; // organize: 整理成功数量
private Integer manualFixFiles; // organize: 需人工修复数量
private Integer traditionalEntries; // zhconvert: 繁体标签条目数量
private Integer failedFiles; // zhconvert: 失败文件数量
private Integer albumsMerged; // merge: 已合并专辑数
private Integer tracksMerged; // merge: 已合并曲目数
private Integer upgradedFiles; // merge: 升级替换文件数
private Integer skippedFiles; // merge: 跳过文件数
}

View File

@@ -24,7 +24,7 @@ public class ZhConvertRequest {
* - 预览模式:忽略该字段
* - 执行模式:
* - 为空:在原文件上就地修改标签
* - 非空:在该目录下生成一份带简体标签的副本
* - 非空:将包含繁体标签的文件移动到该目录(保留相对路径)后再修改标签
*/
private String outputDir;
@@ -41,4 +41,3 @@ public class ZhConvertRequest {
@NotBlank(message = "处理模式不能为空")
private String mode;
}

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Async;
@@ -74,7 +75,7 @@ public class AggregatorService {
if ("move".equalsIgnoreCase(mode)) {
// 移动模式
Files.move(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(sourceFile, targetFile);
} else {
// 复制模式
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);

View File

@@ -16,6 +16,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.TimeUnit;
@Service
public class ConvertService {
@@ -33,6 +34,9 @@ public class ConvertService {
));
private static final int FFMPEG_COMPRESSION_LEVEL = 5;
private static final int FFMPEG_CHECK_TIMEOUT_SECONDS = 10;
private static final int FFMPEG_CONVERT_TIMEOUT_SECONDS = 600;
private static final String FFMPEG_BIN_PROPERTY = "mangtool.ffmpeg.bin";
private final SimpMessagingTemplate messagingTemplate;
private final ProgressStore progressStore;
@@ -77,6 +81,13 @@ public class ConvertService {
Files.createDirectories(dstPath);
}
// 预检查 ffmpeg 可用性
String ffmpegCheckError = checkFfmpegAvailable();
if (ffmpegCheckError != null) {
sendProgress(taskId, 0, 0, 0, 0, null, ffmpegCheckError, true);
return;
}
// 扫描输入目录,查找需要转换的文件
List<Path> toConvert = new ArrayList<>();
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
@@ -128,10 +139,11 @@ public class ConvertService {
"已处理: " + fileName, false);
} catch (Exception e) {
failed.incrementAndGet();
log.warn("转码失败: {} - {}", fileName, e.getMessage());
String reason = classifyConvertError(e);
log.warn("转码失败: {} - {}", fileName, reason);
sendProgress(taskId, total, processed.incrementAndGet(),
success.get(), failed.get(), fileName,
"转码失败: " + fileName + " - " + e.getMessage(), false);
"转码失败: " + fileName + " - " + reason, false);
}
}
@@ -165,7 +177,7 @@ public class ConvertService {
private void runFfmpeg(Path input, Path output) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg",
getFfmpegCommand(),
"-y",
"-i", input.toAbsolutePath().toString(),
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
@@ -173,12 +185,66 @@ public class ConvertService {
);
pb.redirectErrorStream(true);
Process p = pb.start();
int exit = p.waitFor();
boolean finished = p.waitFor(FFMPEG_CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
throw new RuntimeException("ffmpeg 转码超时(" + FFMPEG_CONVERT_TIMEOUT_SECONDS + "s");
}
int exit = p.exitValue();
if (exit != 0) {
throw new RuntimeException("ffmpeg 退出码: " + exit);
}
}
private String checkFfmpegAvailable() {
try {
ProcessBuilder pb = new ProcessBuilder(getFfmpegCommand(), "-version");
pb.redirectErrorStream(true);
Process p = pb.start();
boolean finished = p.waitFor(FFMPEG_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
return "ffmpeg 预检查超时,请检查环境配置";
}
if (p.exitValue() != 0) {
return "ffmpeg 不可用,请确认已正确安装并加入 PATH";
}
return null;
} catch (IOException e) {
return "ffmpeg 不可用,请确认已正确安装并加入 PATH";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "ffmpeg 预检查被中断";
}
}
private String classifyConvertError(Exception e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
return "任务线程被中断";
}
String message = e.getMessage();
if (message == null || message.trim().isEmpty()) {
return "未知错误";
}
if (message.contains("超时")) {
return message;
}
if (message.contains("ffmpeg 退出码")) {
return message + "(可能是源文件损坏或编码不支持)";
}
return message;
}
private String getFfmpegCommand() {
String configured = System.getProperty(FFMPEG_BIN_PROPERTY);
if (configured == null || configured.trim().isEmpty()) {
return "ffmpeg";
}
return configured.trim();
}
private Path resolveTargetFile(Path targetDir, String fileName) throws IOException {
Path target = targetDir.resolve(fileName);
if (!Files.exists(target)) return target;

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
@@ -109,6 +110,7 @@ public class DedupService {
AtomicInteger duplicateGroups = new AtomicInteger(0);
AtomicInteger moved = new AtomicInteger(0);
AtomicInteger failed = new AtomicInteger(0);
Set<Path> processedDuplicates = new HashSet<>();
sendProgress(taskId, total, 0, 0, 0,
"开始扫描音乐库...", false);
@@ -147,7 +149,7 @@ public class DedupService {
// 第二阶段:处理 MD5 去重结果(完全二进制重复)
if (useMd5) {
for (Map.Entry<String, List<Path>> entry : md5Groups.entrySet()) {
List<Path> group = entry.getValue();
List<Path> group = filterProcessableCandidates(entry.getValue(), processedDuplicates);
if (group.size() <= 1) {
continue;
}
@@ -158,15 +160,15 @@ public class DedupService {
List<Path> duplicates = new ArrayList<>(group);
duplicates.remove(keep);
moved.addAndGet(handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, failed));
handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, moved, failed, processedDuplicates);
}
}
if (useMetadata) {
// 第三阶段:处理元数据匹配去重结果
for (Map.Entry<MetadataKey, List<Path>> entry : metadataGroups.entrySet()) {
List<Path> group = entry.getValue();
List<Path> group = filterProcessableCandidates(entry.getValue(), processedDuplicates);
if (group.size() <= 1) {
continue;
}
@@ -177,8 +179,8 @@ public class DedupService {
List<Path> duplicates = new ArrayList<>(group);
duplicates.remove(keep);
moved.addAndGet(handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, failed));
handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, moved, failed, processedDuplicates);
}
}
@@ -362,30 +364,30 @@ public class DedupService {
/**
* 将重复文件移动/复制到回收站,并更新统计与进度
*
* @return 实际成功移动/复制的文件数量
*/
private int handleDuplicates(List<Path> duplicates,
Path keep,
Path trashPath,
String mode,
String taskId,
int total,
AtomicInteger scanned,
AtomicInteger duplicateGroups,
AtomicInteger failed) {
int movedCount = 0;
private void handleDuplicates(List<Path> duplicates,
Path keep,
Path trashPath,
String mode,
String taskId,
int total,
AtomicInteger scanned,
AtomicInteger duplicateGroups,
AtomicInteger moved,
AtomicInteger failed,
Set<Path> processedDuplicates) {
for (Path dup : duplicates) {
try {
Path target = resolveTargetFile(trashPath, dup.getFileName().toString());
if ("move".equalsIgnoreCase(mode)) {
Files.move(dup, target, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(dup, target);
} else {
Files.copy(dup, target, StandardCopyOption.REPLACE_EXISTING);
}
movedCount++;
moved.incrementAndGet();
processedDuplicates.add(toNormalizedAbsolutePath(dup));
sendProgress(taskId, total, scanned.get(),
duplicateGroups.get(), movedCount,
duplicateGroups.get(), moved.get(),
String.format("重复文件: %s (保留: %s)",
dup.getFileName(), keep.getFileName()),
false);
@@ -394,7 +396,24 @@ public class DedupService {
log.warn("处理重复文件失败: {}", dup, e);
}
}
return movedCount;
}
private List<Path> filterProcessableCandidates(List<Path> group, Set<Path> processedDuplicates) {
List<Path> candidates = new ArrayList<>(group.size());
for (Path file : group) {
Path normalized = toNormalizedAbsolutePath(file);
if (processedDuplicates.contains(normalized)) {
continue;
}
if (Files.exists(file)) {
candidates.add(file);
}
}
return candidates;
}
private Path toNormalizedAbsolutePath(Path path) {
return path.toAbsolutePath().normalize();
}
/**
@@ -424,8 +443,8 @@ public class DedupService {
* 字段语义(供前端展示用):
* - total扫描到的音频文件总数
* - processed已扫描文件数
* - success重复组数量
* - failed移动/复制的重复文件数量
* - duplicateGroups / success重复组数量
* - movedFiles / failed移动/复制的重复文件数量
*
* 由于进度字段在不同任务中的含义略有差异,前端可根据 type === "dedup" 做专门映射。
*/
@@ -443,6 +462,8 @@ public class DedupService {
pm.setProcessed(processed);
pm.setSuccess(success);
pm.setFailed(failed);
pm.setDuplicateGroups(success);
pm.setMovedFiles(failed);
pm.setCurrentFile(null);
pm.setMessage(message);
pm.setCompleted(completed);
@@ -451,4 +472,3 @@ public class DedupService {
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
}
}

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,6 +30,9 @@ public class LibraryMergeService {
private static final Set<String> LYRICS_EXTENSIONS = new HashSet<>(Arrays.asList("lrc"));
private static final Set<String> COVER_NAMES = new HashSet<>(Arrays.asList("cover.jpg", "cover.png", "folder.jpg", "folder.png"));
private static final Set<String> EXCLUDED_ROOT_DIRS = new HashSet<>(Arrays.asList(
"_Manual_Fix_Required_", "_Reports"
));
private final SimpMessagingTemplate messagingTemplate;
private final ProgressStore progressStore;
@@ -46,19 +50,19 @@ public class LibraryMergeService {
try {
// 基本校验
if (srcDir == null || srcDir.trim().isEmpty()) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录不能为空", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "源目录不能为空", true);
return;
}
if (!Files.exists(srcPath) || !Files.isDirectory(srcPath)) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
return;
}
if (dstDir == null || dstDir.trim().isEmpty()) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "目标目录不能为空", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "目标目录不能为空", true);
return;
}
if (srcPath.normalize().equals(dstPath.normalize())) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录与目标目录不能相同", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "源目录与目标目录不能相同", true);
return;
}
@@ -72,6 +76,14 @@ public class LibraryMergeService {
Map<Path, Path> coverMap = new HashMap<>(); // 专辑目录 -> 封面文件
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
if (isExcludedSystemDirectory(srcPath, dir)) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
String fileName = file.getFileName().toString().toLowerCase();
@@ -97,7 +109,7 @@ public class LibraryMergeService {
int total = audioFiles.size();
if (total == 0) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "未在源目录中找到音频文件", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "未在源目录中找到音频文件", true);
return;
}
@@ -109,7 +121,7 @@ public class LibraryMergeService {
Set<String> processedAlbums = new HashSet<>();
sendProgress(taskId, total, 0, 0, 0, 0, null, "开始合并...", false);
sendProgress(taskId, total, 0, 0, 0, 0, 0, null, "开始合并...", false);
for (Path audioFile : audioFiles) {
try {
@@ -141,7 +153,7 @@ public class LibraryMergeService {
targetPath.getFileName().toString() + ".backup");
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
}
Files.move(audioFile, targetPath, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(audioFile, targetPath);
wasUpgraded = true;
upgraded.incrementAndGet();
} else {
@@ -149,7 +161,7 @@ public class LibraryMergeService {
int p = processed.incrementAndGet();
if (p % 20 == 0 || p == total) {
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
upgraded.get(), audioFile.getFileName().toString(),
upgraded.get(), skipped.get(), audioFile.getFileName().toString(),
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
}
continue; // 跳过,不升级
@@ -160,7 +172,7 @@ public class LibraryMergeService {
int p = processed.incrementAndGet();
if (p % 20 == 0 || p == total) {
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
upgraded.get(), audioFile.getFileName().toString(),
upgraded.get(), skipped.get(), audioFile.getFileName().toString(),
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
}
continue;
@@ -168,7 +180,7 @@ public class LibraryMergeService {
} else {
// 新文件,直接移动
Files.createDirectories(targetPath.getParent());
Files.move(audioFile, targetPath, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(audioFile, targetPath);
}
tracksMerged.incrementAndGet();
@@ -178,7 +190,7 @@ public class LibraryMergeService {
if (lyricsFile != null && Files.exists(lyricsFile)) {
Path lyricsTarget = targetPath.resolveSibling(lyricsFile.getFileName().toString());
if (!Files.exists(lyricsTarget) || wasUpgraded) {
Files.move(lyricsFile, lyricsTarget, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(lyricsFile, lyricsTarget);
}
}
@@ -190,7 +202,7 @@ public class LibraryMergeService {
Path coverTarget = albumDir.resolve("cover.jpg");
if (!Files.exists(coverTarget)) {
// 目标目录没有封面,直接移动
Files.move(coverFile, coverTarget, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(coverFile, coverTarget);
} else {
// 比较封面,保留更好的版本
if (isBetterCover(coverFile, coverTarget)) {
@@ -198,7 +210,7 @@ public class LibraryMergeService {
Path backupPath = coverTarget.resolveSibling("cover.jpg.backup");
Files.copy(coverTarget, backupPath, StandardCopyOption.REPLACE_EXISTING);
}
Files.move(coverFile, coverTarget, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(coverFile, coverTarget);
}
}
}
@@ -212,20 +224,20 @@ public class LibraryMergeService {
int p = processed.incrementAndGet();
if (p % 20 == 0 || p == total) {
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
upgraded.get(), audioFile.getFileName().toString(),
upgraded.get(), skipped.get(), audioFile.getFileName().toString(),
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
}
}
sendProgress(taskId, total, processed.get(), albumsMerged.get(), tracksMerged.get(),
upgraded.get(), null,
upgraded.get(), skipped.get(), null,
String.format("合并完成!专辑: %d, 曲目: %d, 升级: %d, 跳过: %d",
albumsMerged.get(), tracksMerged.get(), upgraded.get(), skipped.get()),
true);
} catch (Exception e) {
log.error("合并任务执行失败", e);
sendProgress(taskId, 0, 0, 0, 0, 0, null, "任务执行失败: " + e.getMessage(), true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "任务执行失败: " + e.getMessage(), true);
}
}
@@ -325,11 +337,29 @@ public class LibraryMergeService {
return i > 0 ? fileName.substring(0, i) : fileName;
}
private boolean isExcludedSystemDirectory(Path sourceRoot, Path dir) {
if (dir == null || sourceRoot == null) {
return false;
}
if (dir.equals(sourceRoot)) {
return false;
}
Path relative = sourceRoot.relativize(dir);
if (relative.getNameCount() <= 0) {
return false;
}
String firstSegment = relative.getName(0).toString();
return EXCLUDED_ROOT_DIRS.contains(firstSegment);
}
/**
* 发送进度消息
*/
private void sendProgress(String taskId, int total, int processed, int albumsMerged,
int tracksMerged, int upgraded, String currentFile, String message, boolean completed) {
int tracksMerged, int upgraded, int skipped,
String currentFile, String message, boolean completed) {
try {
ProgressMessage pm = new ProgressMessage();
pm.setTaskId(taskId);
@@ -338,6 +368,10 @@ public class LibraryMergeService {
pm.setProcessed(processed);
pm.setSuccess(albumsMerged); // 使用 success 字段存储专辑数
pm.setFailed(tracksMerged); // 使用 failed 字段存储曲目数
pm.setAlbumsMerged(albumsMerged);
pm.setTracksMerged(tracksMerged);
pm.setUpgradedFiles(upgraded);
pm.setSkippedFiles(skipped);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
@@ -194,7 +195,7 @@ public class OrganizeService {
Files.createDirectories(destDir);
Path destFile = resolveTargetFile(destDir, destFileName);
Files.move(file, destFile, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(file, destFile);
organized.incrementAndGet();
String albumKey = (isVarious ? VARIOUS_ARTISTS : index + "|" + safeArtist) + "|" + safeAlbum + "|" + year;
@@ -258,7 +259,7 @@ public class OrganizeService {
Path sub = manualRoot.resolve(reason);
Files.createDirectories(sub);
Path target = resolveTargetFile(sub, fileName);
Files.move(file, target, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(file, target);
manualFix.incrementAndGet();
} catch (IOException e) {
log.warn("移动至人工修复目录失败: {} - {}", file, e.getMessage());
@@ -461,6 +462,8 @@ public class OrganizeService {
pm.setProcessed(processed);
pm.setSuccess(success);
pm.setFailed(failed);
pm.setOrganizedFiles(success);
pm.setManualFixFiles(failed);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);

View File

@@ -3,7 +3,10 @@ package com.music.service;
import com.music.dto.ProgressMessage;
import org.springframework.stereotype.Service;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 进度缓存:用于 WebSocket 消息丢失时的兜底查询
@@ -11,20 +14,55 @@ import java.util.concurrent.ConcurrentHashMap;
@Service
public class ProgressStore {
private final ConcurrentHashMap<String, ProgressMessage> latest = new ConcurrentHashMap<>();
private static final long EXPIRE_MILLIS = 30L * 60L * 1000L;
private final ConcurrentHashMap<String, StoredProgress> latest = new ConcurrentHashMap<>();
private final AtomicInteger writeCounter = new AtomicInteger(0);
public void put(ProgressMessage message) {
if (message == null || message.getTaskId() == null) {
return;
}
latest.put(message.getTaskId(), message);
long now = System.currentTimeMillis();
latest.put(message.getTaskId(), new StoredProgress(message, now));
if (writeCounter.incrementAndGet() % 100 == 0) {
cleanupExpired(now);
}
}
public ProgressMessage get(String taskId) {
if (taskId == null) {
return null;
}
return latest.get(taskId);
StoredProgress stored = latest.get(taskId);
if (stored == null) {
return null;
}
long now = System.currentTimeMillis();
if (now - stored.updatedAt > EXPIRE_MILLIS) {
latest.remove(taskId, stored);
return null;
}
return stored.message;
}
private void cleanupExpired(long now) {
Iterator<Map.Entry<String, StoredProgress>> it = latest.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, StoredProgress> entry = it.next();
if (now - entry.getValue().updatedAt > EXPIRE_MILLIS) {
it.remove();
}
}
}
private static class StoredProgress {
private final ProgressMessage message;
private final long updatedAt;
private StoredProgress(ProgressMessage message, long updatedAt) {
this.message = message;
this.updatedAt = updatedAt;
}
}
}

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.github.houbb.opencc4j.util.ZhConverterUtil;
import org.springframework.stereotype.Service;
import java.util.HashMap;
@@ -54,7 +55,148 @@ public class TraditionalFilterService {
addMapping('錄', '录');
addMapping('戲', '戏');
addMapping('畫', '画');
addMapping('', '');
addMapping('', '');
addMapping('業', '业');
addMapping('劃', '划');
addMapping('劍', '剑');
addMapping('劑', '剂');
addMapping('勝', '胜');
addMapping('區', '区');
addMapping('華', '华');
addMapping('萬', '万');
addMapping('與', '与');
addMapping('東', '东');
addMapping('絲', '丝');
addMapping('兩', '两');
addMapping('嚴', '严');
addMapping('麗', '丽');
addMapping('亂', '乱');
addMapping('為', '为');
addMapping('義', '义');
addMapping('烏', '乌');
addMapping('喬', '乔');
addMapping('喚', '唤');
addMapping('團', '团');
addMapping('園', '园');
addMapping('圖', '图');
addMapping('學', '学');
addMapping('寶', '宝');
addMapping('實', '实');
addMapping('寫', '写');
addMapping('將', '将');
addMapping('對', '对');
addMapping('屆', '届');
addMapping('屬', '属');
addMapping('嶺', '岭');
addMapping('島', '岛');
addMapping('峯', '峰');
addMapping('廣', '广');
addMapping('張', '张');
addMapping('彈', '弹');
addMapping('強', '强');
addMapping('當', '当');
addMapping('復', '复');
addMapping('戀', '恋');
addMapping('戶', '户');
addMapping('撫', '抚');
addMapping('擇', '择');
addMapping('擁', '拥');
addMapping('擇', '择');
addMapping('數', '数');
addMapping('樂', '乐');
addMapping('機', '机');
addMapping('檔', '档');
addMapping('檢', '检');
addMapping('歸', '归');
addMapping('歲', '岁');
addMapping('歷', '历');
addMapping('氣', '气');
addMapping('漢', '汉');
addMapping('濤', '涛');
addMapping('灣', '湾');
addMapping('無', '无');
addMapping('獎', '奖');
addMapping('產', '产');
addMapping('發', '发');
addMapping('盧', '卢');
addMapping('盡', '尽');
addMapping('盤', '盘');
addMapping('眾', '众');
addMapping('碼', '码');
addMapping('礦', '矿');
addMapping('禮', '礼');
addMapping('穩', '稳');
addMapping('竇', '窦');
addMapping('籤', '签');
addMapping('級', '级');
addMapping('紅', '红');
addMapping('細', '细');
addMapping('經', '经');
addMapping('網', '网');
addMapping('線', '线');
addMapping('練', '练');
addMapping('總', '总');
addMapping('編', '编');
addMapping('續', '续');
addMapping('藝', '艺');
addMapping('蘇', '苏');
addMapping('號', '号');
addMapping('葉', '叶');
addMapping('萬', '万');
addMapping('裝', '装');
addMapping('複', '复');
addMapping('視', '视');
addMapping('覺', '觉');
addMapping('覽', '览');
addMapping('訊', '讯');
addMapping('輯', '辑');
addMapping('選', '选');
addMapping('詞', '词');
addMapping('試', '试');
addMapping('詩', '诗');
addMapping('語', '语');
addMapping('說', '说');
addMapping('讀', '读');
addMapping('譜', '谱');
addMapping('豐', '丰');
addMapping('貝', '贝');
addMapping('資', '资');
addMapping('質', '质');
addMapping('購', '购');
addMapping('轉', '转');
addMapping('輸', '输');
addMapping('這', '这');
addMapping('進', '进');
addMapping('遠', '远');
addMapping('還', '还');
addMapping('邊', '边');
addMapping('鄧', '邓');
addMapping('鄭', '郑');
addMapping('醜', '丑');
addMapping('醫', '医');
addMapping('釋', '释');
addMapping('鐘', '钟');
addMapping('鋼', '钢');
addMapping('錄', '录');
addMapping('長', '长');
addMapping('陳', '陈');
addMapping('陸', '陆');
addMapping('陰', '阴');
addMapping('陽', '阳');
addMapping('雙', '双');
addMapping('雜', '杂');
addMapping('雲', '云');
addMapping('靈', '灵');
addMapping('韓', '韩');
addMapping('頁', '页');
addMapping('頒', '颁');
addMapping('類', '类');
addMapping('顏', '颜');
addMapping('風', '风');
addMapping('飛', '飞');
addMapping('馬', '马');
addMapping('驗', '验');
addMapping('體', '体');
// 可以根据需要逐步补充更多常见映射
}
@@ -72,19 +214,36 @@ public class TraditionalFilterService {
return new ZhStats(0, 0);
}
String simplified = toSimplified(text);
int chineseCount = 0;
int traditionalCount = 0;
int compareLength = Math.min(text.length(), simplified.length());
for (int i = 0; i < text.length(); i++) {
for (int i = 0; i < compareLength; i++) {
char c = text.charAt(i);
if (isCjk(c)) {
chineseCount++;
if (TRADITIONAL_SET.contains(c)) {
if (c != simplified.charAt(i)) {
traditionalCount++;
}
}
}
// 正常繁简转换通常长度一致。若长度不一致,退回到本地字典统计,避免漏算。
if (text.length() != simplified.length()) {
chineseCount = 0;
traditionalCount = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (isCjk(c)) {
chineseCount++;
if (TRADITIONAL_SET.contains(c)) {
traditionalCount++;
}
}
}
}
return new ZhStats(chineseCount, traditionalCount);
}
@@ -95,13 +254,17 @@ public class TraditionalFilterService {
if (text == null || text.isEmpty()) {
return text;
}
StringBuilder sb = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
Character mapped = TRAD_TO_SIMP_MAP.get(c);
sb.append(mapped != null ? mapped : c);
try {
return ZhConverterUtil.toSimple(text);
} catch (Exception e) {
StringBuilder sb = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
Character mapped = TRAD_TO_SIMP_MAP.get(c);
sb.append(mapped != null ? mapped : c);
}
return sb.toString();
}
return sb.toString();
}
/**
@@ -144,4 +307,3 @@ public class TraditionalFilterService {
}
}
}

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import com.music.service.TraditionalFilterService.ZhStats;
import org.jaudiotagger.audio.AudioFile;
@@ -318,13 +319,7 @@ public class ZhConvertService {
// 移动源文件到目标位置(因为已经确认文件包含繁体,需要过滤出来处理)
// 如果目标文件已存在,则覆盖(可能是重复处理的情况)
try {
// 尝试原子移动(在同一文件系统上更快更安全)
Files.move(srcFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
// 如果原子移动失败(例如跨分区),回退到普通移动
Files.move(srcFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
}
FileTransferUtils.moveWithFallback(srcFile, targetFile);
return targetFile;
}
@@ -344,6 +339,8 @@ public class ZhConvertService {
pm.setProcessed(processed);
pm.setSuccess(entries);
pm.setFailed(failed);
pm.setTraditionalEntries(entries);
pm.setFailedFiles(failed);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);
@@ -354,4 +351,3 @@ public class ZhConvertService {
}
}
}

View File

@@ -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));
}
}

View File

@@ -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 退出码"));
}
}

View File

@@ -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()));
}
}

View File

@@ -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));
}
}

View File

@@ -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());
}
}

View File

@@ -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"));
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}