feat: productize ingest lifecycle and library health

This commit is contained in:
2026-07-21 08:56:38 +08:00
parent f5033c7020
commit f4f4ab092f
23 changed files with 2556 additions and 38 deletions
@@ -1,15 +1,30 @@
package com.music.controller;
import com.music.common.Result;
import com.music.dto.DependencyStatus;
import com.music.service.RuntimeDependencyService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HealthController {
private final RuntimeDependencyService dependencyService;
public HealthController(RuntimeDependencyService dependencyService) {
this.dependencyService = dependencyService;
}
@GetMapping("/api/health")
public Result<String> health() {
return Result.success("OK");
}
}
/**
* 运行时依赖检查:报告 ffmpeg / ffprobe 是否可用及版本,便于部署自检与排障。
*/
@GetMapping("/api/health/dependencies")
public Result<DependencyStatus> dependencies() {
return Result.success(dependencyService.check());
}
}
@@ -7,6 +7,7 @@ import com.music.dto.ProgressMessage;
import com.music.exception.BusinessException;
import com.music.service.ConfigService;
import com.music.service.IngestService;
import com.music.service.IngestTaskStore;
import com.music.service.ProgressStore;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -25,6 +26,7 @@ public class IngestController {
private final IngestService ingestService;
private final ProgressStore progressStore;
private final ConfigService configService;
private final IngestTaskStore taskStore;
/** 防止并发执行的生命周期锁,由 IngestService 在 finally 中释放 */
private final AtomicBoolean running = new AtomicBoolean(false);
@@ -34,10 +36,12 @@ public class IngestController {
public IngestController(IngestService ingestService,
ProgressStore progressStore,
ConfigService configService) {
ConfigService configService,
IngestTaskStore taskStore) {
this.ingestService = ingestService;
this.progressStore = progressStore;
this.configService = configService;
this.taskStore = taskStore;
}
/**
@@ -60,6 +64,20 @@ public class IngestController {
return Result.success(new StartResponse(taskId));
}
/**
* 请求取消当前正在运行的导入任务。取消只停止后续文件处理,
* 已完成(已移动入库)的文件不会被删除;未处理的文件保留在 Input 供下次导入。
*/
@PostMapping("/cancel")
public Result<String> cancel() {
String tid = currentTaskId;
if (tid == null || !running.get()) {
return Result.success("当前没有正在运行的导入任务", "no-op");
}
taskStore.requestCancel(tid);
return Result.success("已请求取消,任务将在当前文件处理完成后停止", tid);
}
/**
* 获取当前/最近一次导入任务的状态(页面刷新恢复用)。
* 优先返回正在运行的任务,否则返回最近完成的任务。
@@ -67,9 +85,17 @@ public class IngestController {
@GetMapping("/status")
public Result<IngestStatusResponse> statusCurrent() {
String tid = currentTaskId;
if (tid == null) {
// 服务重启后 currentTaskId 丢失:从持久化状态恢复最近一次任务
IngestTaskStore.TaskState latest = taskStore.latest();
if (latest != null) {
currentTaskId = latest.taskId;
tid = latest.taskId;
}
}
if (tid == null) {
return Result.success(new IngestStatusResponse(null, false, false,
0, 0, 0, 0, 0, 0, 0, 0, "暂无任务记录"));
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "暂无任务记录"));
}
return statusById(tid);
}
@@ -81,9 +107,22 @@ public class IngestController {
public Result<IngestStatusResponse> statusById(@PathVariable("taskId") String taskId) {
ProgressMessage pm = progressStore.get(taskId);
if (pm == null) {
// 内存进度已过期或服务重启:回退到持久化任务状态(暴露 completed/cancelled/interrupted/failed 及汇总计数)
IngestTaskStore.TaskState st = taskStore.get(taskId);
if (st != null) {
boolean stillRunning = IngestTaskStore.STATUS_RUNNING.equals(st.status);
String msg = st.message != null && !st.message.isEmpty() ? st.message : st.status;
return Result.success(new IngestStatusResponse(
taskId, stillRunning, !stillRunning,
st.total, st.processed,
st.ingested, st.duplicates, st.missingMetadata, st.unreadable,
st.conversionFailed, st.otherRejected,
st.lyricsFound, st.lyricsMissing, st.lyricsFailed,
msg));
}
boolean isRunning = running.get();
return Result.success(new IngestStatusResponse(taskId, isRunning, !isRunning,
0, 0, 0, 0, 0, 0, 0, 0, "未找到任务或已过期"));
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "未找到任务或已过期"));
}
IngestStatusResponse resp = new IngestStatusResponse(
@@ -95,6 +134,9 @@ public class IngestController {
optInt(pm.getUnreadableFiles()),
optInt(pm.getConversionFailedFiles()),
optInt(pm.getOtherRejectedFiles()),
optInt(pm.getLyricsFound()),
optInt(pm.getLyricsMissing()),
optInt(pm.getLyricsFailed()),
pm.getMessage()
);
@@ -109,6 +151,49 @@ public class IngestController {
return val == null ? 0 : val;
}
/**
* 读取指定任务的结构化导入报告 JSON(供 UI 查看/下载)。只读,不修改任何文件。
* 若 {@code taskId} 为 "latest" 则返回最近的一份报告。
*/
@GetMapping("/report/{taskId}")
public Result<String> report(@PathVariable("taskId") String taskId) {
String rejectedDir = configService.getRejectedDir();
if (rejectedDir == null) {
throw new BusinessException(400, "请先配置工作根目录");
}
java.nio.file.Path reportsDir = java.nio.file.Paths.get(rejectedDir).resolve("Reports");
if (!java.nio.file.Files.isDirectory(reportsDir)) {
throw new BusinessException(404, "暂无报告");
}
boolean latest = taskId == null || "latest".equalsIgnoreCase(taskId);
try (java.util.stream.Stream<java.nio.file.Path> files = java.nio.file.Files.list(reportsDir)) {
java.util.List<java.nio.file.Path> jsons = files
.filter(p -> p.getFileName().toString().endsWith(".json"))
.sorted(java.util.Comparator.comparingLong(this::lastModified).reversed())
.collect(java.util.stream.Collectors.toList());
for (java.nio.file.Path p : jsons) {
// 单份报告体积有界(Reports 由本服务写入)
if (java.nio.file.Files.size(p) > 8L * 1024 * 1024) continue;
String content = new String(java.nio.file.Files.readAllBytes(p),
java.nio.charset.StandardCharsets.UTF_8);
if (latest || content.contains("\"taskId\": \"" + taskId + "\"")) {
return Result.success(content);
}
}
} catch (java.io.IOException e) {
throw new BusinessException(500, "读取报告失败: " + e.getMessage());
}
throw new BusinessException(404, "未找到该任务的报告");
}
private long lastModified(java.nio.file.Path p) {
try {
return java.nio.file.Files.getLastModifiedTime(p).toMillis();
} catch (java.io.IOException e) {
return 0L;
}
}
static class StartResponse {
private String taskId;
@@ -0,0 +1,45 @@
package com.music.controller;
import com.music.common.Result;
import com.music.dto.LibraryHealthReport;
import com.music.dto.LibraryHealthRepairRequest;
import com.music.dto.LibraryHealthResult;
import com.music.service.LibraryHealthService;
import org.springframework.web.bind.annotation.*;
/**
* Library 健康检查控制器。
* <ul>
* <li>{@code POST /api/library/health/scan} —— 只读扫描,绝不修改文件;</li>
* <li>{@code POST /api/library/health/repair} —— 修复动作,需请求体 {@code confirm=true} 显式确认。</li>
* </ul>
*/
@RestController
@RequestMapping("/api/library/health")
public class LibraryHealthController {
private final LibraryHealthService service;
public LibraryHealthController(LibraryHealthService service) {
this.service = service;
}
/**
* 只读健康扫描。
* @param checkDecode 可选查询参数,是否执行完整解码校验(代价较高,默认 false)
*/
@PostMapping("/scan")
public Result<LibraryHealthReport> scan(
@RequestParam(name = "checkDecode", required = false, defaultValue = "false") boolean checkDecode) {
return Result.success(service.scan(checkDecode));
}
/**
* 确认后修复。未确认(confirm=false)时仅演练,不修改任何文件。
*/
@PostMapping("/repair")
public Result<LibraryHealthResult> repair(@RequestBody(required = false) LibraryHealthRepairRequest request) {
if (request == null) request = new LibraryHealthRepairRequest();
return Result.success(service.repair(request));
}
}
@@ -0,0 +1,19 @@
package com.music.dto;
import lombok.Data;
/**
* 运行时外部依赖(ffmpeg/ffprobe)可用性状态。
*/
@Data
public class DependencyStatus {
private boolean ffmpegAvailable;
private boolean ffprobeAvailable;
private String ffmpegVersion;
private String ffprobeVersion;
/** 全部关键依赖可用。 */
public boolean isAllAvailable() {
return ffmpegAvailable && ffprobeAvailable;
}
}
@@ -22,5 +22,8 @@ public class IngestStatusResponse {
private int unreadableFiles;
private int conversionFailedFiles;
private int otherRejectedFiles;
private int lyricsFound;
private int lyricsMissing;
private int lyricsFailed;
private String message;
}
@@ -0,0 +1,23 @@
package com.music.dto;
import lombok.Data;
/**
* Library 健康检查修复请求。所有修复动作均需 {@code confirm=true} 显式确认,
* 否则视为演练(dry-run),不修改任何文件。
*/
@Data
public class LibraryHealthRepairRequest {
/** 必须为 true 才会真正执行修复;false 时仅返回演练说明,不修改任何文件。 */
private boolean confirm;
/** 为缺歌词的曲目远程回填歌词(非致命,失败仅统计)。 */
private boolean backfillLyrics;
/** 删除孤立侧车(无对应音频的 .lrc)。仅删除确为孤立的侧车。 */
private boolean removeOrphanSidecars;
/** 删除完整解码校验失败的音频(危险,需同时开启解码校验)。默认 false。 */
private boolean removeDecodeInvalid;
}
@@ -0,0 +1,46 @@
package com.music.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* Library 健康检查报告(只读)。统计各类问题数量并给出有界示例列表,绝不修改任何文件。
*/
@Data
public class LibraryHealthReport {
/** 恒为 true:扫描是只读操作。 */
private boolean readOnly = true;
private int albums;
private int tracks;
/** 缺少必需元数据(Title/Artist/Album 任一为空或无法解析)的曲目数。 */
private int tracksMissingMetadata;
/** 缺少 cover.jpg/png 的专辑目录数。 */
private int albumsMissingCover;
/** 缺少同名 .lrc 的曲目数。 */
private int tracksMissingLyrics;
/** 孤立侧车(无对应音频的 .lrc)数量。 */
private int orphanSidecars;
/** 重复分组数(相同 Artist|Album|Disc|Track|Title)。 */
private int duplicateGroups;
/** 处于重复分组中的曲目总数。 */
private int duplicateTracks;
/** 是否执行了完整解码校验(代价较高,默认关闭)。 */
private boolean decodeChecked;
/** 完整解码校验失败的曲目数(decodeChecked=false 时恒为 0)。 */
private int decodeInvalid;
private List<String> examplesMissingMetadata = new ArrayList<>();
private List<String> examplesMissingCover = new ArrayList<>();
private List<String> examplesMissingLyrics = new ArrayList<>();
private List<String> examplesOrphanSidecars = new ArrayList<>();
private List<String> examplesDuplicates = new ArrayList<>();
private List<String> examplesDecodeInvalid = new ArrayList<>();
private String message;
}
@@ -0,0 +1,25 @@
package com.music.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* Library 健康检查修复结果。
*/
@Data
public class LibraryHealthResult {
/** 是否真正应用了修复(confirm=false 时为 false,表示仅演练)。 */
private boolean applied;
private int lyricsBackfilled;
private int orphanSidecarsRemoved;
private int decodeInvalidRemoved;
/** 已删除/已处理条目的有界示例列表。 */
private List<String> changed = new ArrayList<>();
private String message;
}
@@ -37,4 +37,9 @@ public class ProgressMessage {
private Integer unreadableFiles; // ingest: 无法读取文件数
private Integer conversionFailedFiles; // ingest: 转码失败文件数
private Integer otherRejectedFiles; // ingest: 其他原因拒绝文件数
// ingest 歌词统计(歌词失败绝不导致音频被拒绝,仅用于观测)
private Integer lyricsFound; // ingest: 已获得歌词的入库文件数(侧车/内嵌/远程)
private Integer lyricsMissing; // ingest: 无歌词可用的入库文件数
private Integer lyricsFailed; // ingest: 歌词查询/写入出错的入库文件数
}
@@ -93,6 +93,49 @@ public class IngestService {
@Autowired(required = false)
private LyricsService lyricsService;
/** 任务生命周期持久化与取消(可选注入;测试构造器不设置时为 null,行为不变)。 */
@Autowired(required = false)
private IngestTaskStore taskStore;
/**
* 当前 ingest 运行的歌词统计收集器;仅由 {@link #ingest} 在循环前设置、结束后清空。
* <p>直接调用 {@link #processSingleFile}(测试)时为 null,此时不记录(不影响返回值/行为)。
* ingest 由 running 锁保证同一时刻仅一个任务,故单实例字段安全。</p>
*/
private volatile LyricRun lyricRun;
/** 歌词来源分类(用于报告观测)。 */
static final String LYRIC_SOURCE_SIDECAR = "sidecar";
static final String LYRIC_SOURCE_EMBEDDED = "embedded";
static final String LYRIC_SOURCE_REMOTE = "remote";
static final String LYRIC_SOURCE_NONE = "none";
/** 歌词状态分类:found/missing/failed。 */
static final String LYRIC_STATUS_FOUND = "found";
static final String LYRIC_STATUS_MISSING = "missing";
static final String LYRIC_STATUS_FAILED = "failed";
/**
* 单次 ingest 运行的歌词聚合。歌词失败绝不影响音频入库,此处仅作观测统计。
* 循环单线程执行,{@code lastSource/lastStatus} 在每个文件处理前后读取一次,无并发问题。
*/
static final class LyricRun {
final AtomicInteger found = new AtomicInteger();
final AtomicInteger missing = new AtomicInteger();
final AtomicInteger failed = new AtomicInteger();
volatile String lastSource;
volatile String lastStatus;
void reset() { lastSource = null; lastStatus = null; }
void record(String source, String status) {
lastSource = source;
lastStatus = status;
if (LYRIC_STATUS_FOUND.equals(status)) found.incrementAndGet();
else if (LYRIC_STATUS_FAILED.equals(status)) failed.incrementAndGet();
else missing.incrementAndGet();
}
}
/**
* Spring DI 构造器。
* <p>包级 4 参数构造器保留给测试,此时 {@code audioValidationService} 为 null
@@ -215,17 +258,33 @@ public class IngestService {
AtomicInteger missingCover = new AtomicInteger(0);
AtomicInteger processed = new AtomicInteger(0);
// 歌词统计收集器(歌词失败不影响入库,仅用于报告/进度观测)
LyricRun run = new LyricRun();
this.lyricRun = run;
// 登记任务生命周期(持久化,供重启恢复与取消)
if (taskStore != null) {
taskStore.begin(taskId, total);
}
// 批内重复检测集合
Set<IdentityKey> batchIdentities = new HashSet<>();
// 文件结果映射(用于报告)
LinkedHashMap<String, String> fileOutcomes = new LinkedHashMap<>();
// 文件结果映射(用于报告)outcome + 歌词来源/状态
LinkedHashMap<String, FileReport> fileOutcomes = new LinkedHashMap<>();
boolean cancelled = false;
for (Path srcFile : audioFiles) {
// 取消检查:仅停止后续文件处理,已完成(已移动)的文件保持不动
if (taskStore != null && taskStore.isCancelRequested(taskId)) {
cancelled = true;
break;
}
String fileName = srcFile.getFileName().toString();
// 使用相对于 Input 的路径作为唯一跟踪键(防止不同子目录中的同名文件互相覆盖)
String relativeKey = inputPath.relativize(srcFile).toString();
String outcome = "unknown";
run.reset();
try {
outcome = processSingleFile(srcFile, libraryPath, rejectedPath,
libraryIdentities, batchIdentities,
@@ -237,36 +296,71 @@ public class IngestService {
outcome = "rejected:exception";
log.warn("处理文件异常: {} - {}", relativeKey, e.getMessage());
}
fileOutcomes.put(relativeKey, outcome);
fileOutcomes.put(relativeKey, new FileReport(outcome, run.lastSource, run.lastStatus));
if (taskStore != null) {
taskStore.recordProcessed(taskId, relativeKey, outcome, run.lastSource, run.lastStatus,
new IngestTaskStore.Counters(
ingested.get(), duplicates.get(), missingMeta.get(),
unreadable.get(), convFailed.get(), otherRejected.get(),
missingCover.get(),
run.found.get(), run.missing.get(), run.failed.get()));
}
int p = processed.incrementAndGet();
sendProgress(taskId, total, p, ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(), relativeKey,
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(),
relativeKey,
String.format("已处理 (%d/%d): %s", p, total, relativeKey),
false);
}
// 写入结构化报告
// 写入结构化报告(无论是否取消,都记录已处理部分)
writeReport(taskId, fileOutcomes, rejectedPath, ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(), otherRejected.get(),
missingCover.get(), cleanupRemoved);
missingCover.get(), cleanupRemoved,
run.found.get(), run.missing.get(), run.failed.get());
if (cancelled) {
if (taskStore != null) {
taskStore.markCancelled(taskId, "任务已取消");
}
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(), null,
String.format("任务已取消:已处理 %d/%d,已入库 %d(已完成文件保持不动)",
processed.get(), total, ingested.get()),
true);
return;
}
// 完成
if (taskStore != null) {
taskStore.complete(taskId, "导入完成");
}
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(), null,
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 缺封面: %d, 不可读: %d, 转码失败: %d, 其他: %d, 清理: %d",
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(), null,
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 缺封面: %d, 不可读: %d, 转码失败: %d, 其他: %d, 清理: %d, 歌词(有/无/失败): %d/%d/%d",
ingested.get(), duplicates.get(), missingMeta.get(), missingCover.get(),
unreadable.get(), convFailed.get(), otherRejected.get(), cleanupRemoved),
unreadable.get(), convFailed.get(), otherRejected.get(), cleanupRemoved,
run.found.get(), run.missing.get(), run.failed.get()),
true);
} catch (Exception e) {
log.error("导入任务执行失败", e);
// 持久化任务标记为 failed(若已 begin),避免重启后残留 running 状态
if (taskStore != null) {
taskStore.markFailed(taskId, "任务内部错误: " + e.getMessage());
}
// 发送 terminal 进度消息,确保前端停止轮询并显示错误
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
null, "任务内部错误: " + e.getMessage(), true);
} finally {
this.lyricRun = null;
if (runningLock != null) {
runningLock.set(false);
}
@@ -644,14 +738,50 @@ public class IngestService {
ingested.incrementAndGet();
// 处理关联 LRC 文件(可选)
handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
boolean sidecarLrc = handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
// 提取嵌入式歌词(可选,仅 jaudiotagger 路径有 tag 对象)
boolean embeddedLrc = false;
if (tag != null) {
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
embeddedLrc = extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
}
if (lyricsService != null) {
lyricsService.fetchIfMissing(targetDir.resolve(destFileName), title, effectiveArtist);
// 远程歌词兜底(可选)。任何失败均非致命,仅记录为 lyricsFailed,绝不改变入库结果。
String lyricSource;
String lyricStatus;
if (sidecarLrc) {
lyricSource = LYRIC_SOURCE_SIDECAR;
lyricStatus = LYRIC_STATUS_FOUND;
} else if (embeddedLrc) {
lyricSource = LYRIC_SOURCE_EMBEDDED;
lyricStatus = LYRIC_STATUS_FOUND;
} else if (lyricsService != null) {
LyricsService.RemoteOutcome ro =
lyricsService.resolveRemote(targetDir.resolve(destFileName), title, effectiveArtist);
switch (ro) {
case FETCHED:
lyricSource = LYRIC_SOURCE_REMOTE;
lyricStatus = LYRIC_STATUS_FOUND;
break;
case EXISTS:
lyricSource = LYRIC_SOURCE_SIDECAR;
lyricStatus = LYRIC_STATUS_FOUND;
break;
case FAILED:
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_FAILED;
break;
default: // MISSING / DISABLED
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_MISSING;
break;
}
} else {
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_MISSING;
}
if (lyricRun != null) {
lyricRun.record(lyricSource, lyricStatus);
}
return "ingested";
@@ -752,14 +882,15 @@ public class IngestService {
// ========== 关联文件处理 ==========
/**
* 将同名 .lrc 文件随音频一起移入目标目录
* 将同名 .lrc 文件随音频一起移入目标目录
* @return true 表示确实移动了一个侧车 .lrc
*/
private void handleAssociatedLrc(Path srcFile, Path targetDir, String baseName, String destFileName) {
private boolean handleAssociatedLrc(Path srcFile, Path targetDir, String baseName, String destFileName) {
Path lrcSource = srcFile.resolveSibling(baseName + ".lrc");
if (!Files.exists(lrcSource)) {
// 也检查小写扩展名
lrcSource = srcFile.resolveSibling(baseName + ".LRC");
if (!Files.exists(lrcSource)) return;
if (!Files.exists(lrcSource)) return false;
}
try {
@@ -767,19 +898,22 @@ public class IngestService {
Path lrcTarget = resolveUniqueFile(targetDir, lrcDestName);
FileTransferUtils.moveWithFallback(lrcSource, lrcTarget);
log.info("已移动关联 LRC 文件: {} -> {}", lrcSource.getFileName(), lrcTarget);
return true;
} catch (IOException e) {
log.warn("移动 LRC 文件失败: {} - {}", lrcSource, e.getMessage());
return false;
}
}
/**
* 从标签中提取嵌入式歌词(可选),写入 LRC 文件
* 从标签中提取嵌入式歌词(可选),写入 LRC 文件
* @return true 表示确实写出了一个内嵌歌词 .lrc
*/
private void extractEmbeddedLyrics(Tag tag, Path targetDir, String trackStr,
private boolean extractEmbeddedLyrics(Tag tag, Path targetDir, String trackStr,
String safeTitle, String originalTitle, String artist) {
try {
String lyrics = tag.getFirst(FieldKey.LYRICS);
if (lyrics == null || lyrics.trim().isEmpty()) return;
if (lyrics == null || lyrics.trim().isEmpty()) return false;
// 构建简易 LRC 内容(无时间戳,仅作为歌词文本)
StringBuilder lrcContent = new StringBuilder();
@@ -792,8 +926,10 @@ public class IngestService {
Path lrcFile = resolveUniqueFile(targetDir, lrcFileName);
Files.write(lrcFile, lrcContent.toString().getBytes(StandardCharsets.UTF_8));
log.info("已提取嵌入式歌词到: {}", lrcFile);
return true;
} catch (Exception e) {
log.debug("提取嵌入式歌词失败(可选,忽略): {}", e.getMessage());
return false;
}
}
@@ -802,11 +938,25 @@ public class IngestService {
/**
* 写入 JSON 格式的结构化报告
*/
private void writeReport(String taskId, LinkedHashMap<String, String> fileOutcomes,
/** 单文件报告条目:处理结果 + 歌词来源/状态(歌词字段可为 null,仅入库文件有值)。 */
static final class FileReport {
final String outcome;
final String lyricSource;
final String lyricStatus;
FileReport(String outcome, String lyricSource, String lyricStatus) {
this.outcome = outcome;
this.lyricSource = lyricSource;
this.lyricStatus = lyricStatus;
}
}
private void writeReport(String taskId, LinkedHashMap<String, FileReport> fileOutcomes,
Path rejectedPath, int ingestedCount, int duplicateCount,
int missingMetaCount, int unreadableCount,
int convFailedCount, int otherRejectedCount,
int missingCoverCount, int cleanupRemovedCount) {
int missingCoverCount, int cleanupRemovedCount,
int lyricsFoundCount, int lyricsMissingCount, int lyricsFailedCount) {
try {
Path reportsDir = rejectedPath.resolve(REPORT_SUBDIR);
Files.createDirectories(reportsDir);
@@ -829,15 +979,25 @@ public class IngestService {
json.append(" \"otherRejected\": ").append(otherRejectedCount).append(",\n");
// 新增字段:追加在已有字段之后,保持既有消费者向后兼容
json.append(" \"missingCover\": ").append(missingCoverCount).append(",\n");
json.append(" \"libraryCleanupRemoved\": ").append(cleanupRemovedCount).append("\n");
json.append(" \"libraryCleanupRemoved\": ").append(cleanupRemovedCount).append(",\n");
json.append(" \"lyricsFound\": ").append(lyricsFoundCount).append(",\n");
json.append(" \"lyricsMissing\": ").append(lyricsMissingCount).append(",\n");
json.append(" \"lyricsFailed\": ").append(lyricsFailedCount).append("\n");
json.append(" },\n");
json.append(" \"files\": [\n");
int idx = 0;
int size = fileOutcomes.size();
for (Map.Entry<String, String> entry : fileOutcomes.entrySet()) {
for (Map.Entry<String, FileReport> entry : fileOutcomes.entrySet()) {
FileReport fr = entry.getValue();
json.append(" {\"file\": \"").append(escapeJson(entry.getKey()));
json.append("\", \"outcome\": \"").append(escapeJson(entry.getValue())).append("\"}");
json.append("\", \"outcome\": \"").append(escapeJson(fr.outcome)).append("\"");
// 歌词字段仅对入库文件有值;追加在已有字段之后,保持向后兼容
if (fr.lyricStatus != null) {
json.append(", \"lyricStatus\": \"").append(escapeJson(fr.lyricStatus)).append("\"");
json.append(", \"lyricSource\": \"").append(escapeJson(fr.lyricSource)).append("\"");
}
json.append("}");
if (++idx < size) json.append(",");
json.append("\n");
}
@@ -1449,6 +1609,17 @@ public class IngestService {
int missingMetadataFiles, int unreadableFiles,
int conversionFailedFiles, int otherRejectedFiles,
String currentFile, String message, boolean completed) {
sendProgress(taskId, total, processed, ingestedFiles, duplicateFiles,
missingMetadataFiles, unreadableFiles, conversionFailedFiles, otherRejectedFiles,
null, null, null, currentFile, message, completed);
}
private void sendProgress(String taskId, int total, int processed,
int ingestedFiles, int duplicateFiles,
int missingMetadataFiles, int unreadableFiles,
int conversionFailedFiles, int otherRejectedFiles,
Integer lyricsFound, Integer lyricsMissing, Integer lyricsFailed,
String currentFile, String message, boolean completed) {
try {
ProgressMessage pm = new ProgressMessage();
pm.setTaskId(taskId);
@@ -1463,6 +1634,9 @@ public class IngestService {
pm.setUnreadableFiles(unreadableFiles);
pm.setConversionFailedFiles(conversionFailedFiles);
pm.setOtherRejectedFiles(otherRejectedFiles);
pm.setLyricsFound(lyricsFound);
pm.setLyricsMissing(lyricsMissing);
pm.setLyricsFailed(lyricsFailed);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);
@@ -0,0 +1,340 @@
package com.music.service;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 导入任务生命周期持久化:将每个任务的状态、汇总计数与逐文件结果落盘,用于
* <ul>
* <li><b>重启恢复</b>:进程重启后把仍处于 running 的任务标记为 interrupted(中断),
* 并通过 {@code /api/ingest/status} 恢复其汇总计数与逐文件结果;下一次导入只处理未完成文件
* (已完成文件已被移出 Input)。</li>
* <li><b>取消</b>:记录取消请求,供导入循环在处理下一个文件前检查,取消只停止后续处理,
* 绝不删除已完成(已移动)的文件。</li>
* </ul>
*
* <p><b>有界保留</b>:最多保留最近 {@value #MAX_TASKS} 个任务(按 updatedAt 压实,丢弃最旧),
* 每个任务最多保留最近 {@value #MAX_FILES_PER_TASK} 条逐文件记录(滚动窗口),
* 避免历史无界增长导致每次写盘二次方膨胀。</p>
*
* <p>持久化文件默认位于工作根目录下 {@code .mangtool/ingest-tasks.json};无根目录时退化为
* 纯内存(仍支持进程内取消与状态查询)。落盘尽力而为,失败不影响导入主流程。</p>
*/
@Service
public class IngestTaskStore {
private static final Logger log = LoggerFactory.getLogger(IngestTaskStore.class);
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
public static final String STATUS_RUNNING = "running";
public static final String STATUS_COMPLETED = "completed";
public static final String STATUS_CANCELLED = "cancelled";
public static final String STATUS_INTERRUPTED = "interrupted";
public static final String STATUS_FAILED = "failed";
/** 保留的最大任务数(压实策略:按最近更新保留,丢弃最旧)。 */
static final int MAX_TASKS = 20;
/** 每个任务保留的最大逐文件记录数(滚动窗口,丢弃最旧)。 */
static final int MAX_FILES_PER_TASK = 2000;
/** 单文件结构化结果(公有字段供 Jackson 序列化)。 */
public static class FileOutcome {
public String file;
public String outcome;
public String lyricSource;
public String lyricStatus;
public FileOutcome() {
}
public FileOutcome(String file, String outcome, String lyricSource, String lyricStatus) {
this.file = file;
this.outcome = outcome;
this.lyricSource = lyricSource;
this.lyricStatus = lyricStatus;
}
}
/** 可观测汇总计数快照(用于 /api/ingest/status 恢复)。 */
public static final class Counters {
public final int ingested;
public final int duplicates;
public final int missingMetadata;
public final int unreadable;
public final int conversionFailed;
public final int otherRejected;
public final int missingCover;
public final int lyricsFound;
public final int lyricsMissing;
public final int lyricsFailed;
public Counters(int ingested, int duplicates, int missingMetadata, int unreadable,
int conversionFailed, int otherRejected, int missingCover,
int lyricsFound, int lyricsMissing, int lyricsFailed) {
this.ingested = ingested;
this.duplicates = duplicates;
this.missingMetadata = missingMetadata;
this.unreadable = unreadable;
this.conversionFailed = conversionFailed;
this.otherRejected = otherRejected;
this.missingCover = missingCover;
this.lyricsFound = lyricsFound;
this.lyricsMissing = lyricsMissing;
this.lyricsFailed = lyricsFailed;
}
}
/** 单任务持久化状态(公有字段供 Jackson 直接序列化)。 */
public static class TaskState {
public String taskId;
public String status;
public long startedAt;
public long updatedAt;
public int total;
public int processed;
public String message;
// 可观测汇总计数(重启后经 /api/ingest/status 恢复)
public int ingested;
public int duplicates;
public int missingMetadata;
public int unreadable;
public int conversionFailed;
public int otherRejected;
public int missingCover;
public int lyricsFound;
public int lyricsMissing;
public int lyricsFailed;
/** 逐文件结构化结果(有界滚动窗口)。 */
public List<FileOutcome> files = new ArrayList<>();
}
private final ConfigService configService;
private final Map<String, TaskState> tasks = new ConcurrentHashMap<>();
private final java.util.Set<String> cancelRequests = ConcurrentHashMap.newKeySet();
/** 测试可覆盖的状态文件路径;为 null 时从 ConfigService 派生。 */
private volatile Path stateFileOverride;
public IngestTaskStore(ConfigService configService) {
this.configService = configService;
}
/** 测试注入:指定状态文件路径。 */
void setStateFileOverride(Path stateFile) {
this.stateFileOverride = stateFile;
}
@PostConstruct
public void init() {
load();
recoverInterrupted();
}
// ========== 生命周期写入 ==========
/** 开始一个任务:登记为 running,重置计数,并按保留策略压实历史任务。 */
public synchronized void begin(String taskId, int total) {
if (taskId == null) return;
TaskState s = new TaskState();
s.taskId = taskId;
s.status = STATUS_RUNNING;
s.startedAt = System.currentTimeMillis();
s.updatedAt = s.startedAt;
s.total = total;
s.processed = 0;
s.message = "running";
tasks.put(taskId, s);
cancelRequests.remove(taskId);
pruneTasks();
persist();
}
/**
* 记录一个源文件已处理完成,附带结构化结果与最新汇总计数。
* <p>每条都立即落盘,保证进程崩溃时不丢失最近一批已处理记录;逐文件记录以滚动窗口有界保留,
* 汇总计数始终为最新值,二者共同保证重启后可经 /api/ingest/status 恢复观测数据。</p>
*/
public synchronized void recordProcessed(String taskId, String fileKey, String outcome,
String lyricSource, String lyricStatus, Counters counters) {
TaskState s = tasks.get(taskId);
if (s == null) return;
s.processed++;
if (fileKey != null) {
s.files.add(new FileOutcome(fileKey, outcome, lyricSource, lyricStatus));
// 滚动窗口:仅保留最近 MAX_FILES_PER_TASK 条,防止单次导入写盘二次方膨胀
int overflow = s.files.size() - MAX_FILES_PER_TASK;
if (overflow > 0) {
s.files.subList(0, overflow).clear();
}
}
applyCounters(s, counters);
s.updatedAt = System.currentTimeMillis();
persist();
}
/** 便捷重载(仅记录文件键,无结构化结果/计数)。 */
public synchronized void recordProcessed(String taskId, String fileKey) {
recordProcessed(taskId, fileKey, null, null, null, null);
}
private static void applyCounters(TaskState s, Counters c) {
if (c == null) return;
s.ingested = c.ingested;
s.duplicates = c.duplicates;
s.missingMetadata = c.missingMetadata;
s.unreadable = c.unreadable;
s.conversionFailed = c.conversionFailed;
s.otherRejected = c.otherRejected;
s.missingCover = c.missingCover;
s.lyricsFound = c.lyricsFound;
s.lyricsMissing = c.lyricsMissing;
s.lyricsFailed = c.lyricsFailed;
}
/** 压实历史:超过 MAX_TASKS 时按 updatedAt 丢弃最旧任务。 */
private void pruneTasks() {
if (tasks.size() <= MAX_TASKS) return;
List<TaskState> all = new ArrayList<>(tasks.values());
all.sort(Comparator.comparingLong((TaskState t) -> t.updatedAt).reversed());
for (int i = MAX_TASKS; i < all.size(); i++) {
String tid = all.get(i).taskId;
tasks.remove(tid);
cancelRequests.remove(tid);
}
}
/** 标记任务正常完成。 */
public synchronized void complete(String taskId, String message) {
setTerminal(taskId, STATUS_COMPLETED, message);
}
/** 标记任务被取消。 */
public synchronized void markCancelled(String taskId, String message) {
setTerminal(taskId, STATUS_CANCELLED, message);
}
/** 标记任务失败(外层异常)。 */
public synchronized void markFailed(String taskId, String message) {
setTerminal(taskId, STATUS_FAILED, message);
}
private void setTerminal(String taskId, String status, String message) {
TaskState s = tasks.get(taskId);
if (s == null) return;
s.status = status;
s.message = message;
s.updatedAt = System.currentTimeMillis();
cancelRequests.remove(taskId);
persist();
}
// ========== 取消 ==========
/** 请求取消指定任务;导入循环会在处理下一个文件前观察到并停止。 */
public void requestCancel(String taskId) {
if (taskId != null) cancelRequests.add(taskId);
}
public boolean isCancelRequested(String taskId) {
return taskId != null && cancelRequests.contains(taskId);
}
// ========== 查询 ==========
public synchronized TaskState get(String taskId) {
return taskId == null ? null : tasks.get(taskId);
}
/** 返回最近一次更新的任务(用于服务重启后 /api/ingest/status 恢复展示)。 */
public synchronized TaskState latest() {
TaskState best = null;
for (TaskState s : tasks.values()) {
if (best == null || s.updatedAt > best.updatedAt) {
best = s;
}
}
return best;
}
// ========== 重启恢复 ==========
/**
* 把仍处于 running 的任务标记为 interrupted(进程上次异常退出/重启)。
* 幂等:重复调用不改变已终态的任务。
*/
synchronized void recoverInterrupted() {
boolean changed = false;
for (TaskState s : tasks.values()) {
if (STATUS_RUNNING.equals(s.status)) {
s.status = STATUS_INTERRUPTED;
s.message = "服务重启,任务被标记为中断";
s.updatedAt = System.currentTimeMillis();
changed = true;
log.warn("检测到中断的导入任务: {}(已处理 {}/{})", s.taskId, s.processed, s.total);
}
}
if (changed) persist();
}
// ========== 落盘 ==========
private Path stateFile() {
if (stateFileOverride != null) return stateFileOverride;
String base = configService != null ? configService.getBasePath() : null;
if (base == null || base.trim().isEmpty()) return null;
return Paths.get(base).resolve(".mangtool").resolve("ingest-tasks.json");
}
private synchronized void persist() {
Path file = stateFile();
if (file == null) return;
try {
Files.createDirectories(file.getParent());
Path tmp = file.resolveSibling(file.getFileName().toString() + ".tmp");
MAPPER.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), tasks);
try {
Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception atomicFail) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
} catch (Exception e) {
log.warn("持久化导入任务状态失败(不影响导入): {}", e.getMessage());
}
}
synchronized void load() {
Path file = stateFile();
if (file == null || !Files.isRegularFile(file)) return;
try {
@SuppressWarnings("unchecked")
Map<String, TaskState> loaded = MAPPER.readValue(file.toFile(),
MAPPER.getTypeFactory().constructMapType(
LinkedHashMap.class, String.class, TaskState.class));
if (loaded != null) {
tasks.clear();
tasks.putAll(loaded);
pruneTasks();
}
} catch (Exception e) {
log.warn("读取导入任务状态失败(忽略): {}", e.getMessage());
}
}
}
@@ -0,0 +1,478 @@
package com.music.service;
import com.music.dto.LibraryHealthReport;
import com.music.dto.LibraryHealthRepairRequest;
import com.music.dto.LibraryHealthResult;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* Library 健康检查服务。
* <p><b>只读扫描</b>:统计解码有效性(可选)、必需元数据、封面/歌词齐备、孤立侧车、重复曲目,
* 绝不修改任何文件。<b>修复</b>:所有破坏性/写入动作都必须由 {@link LibraryHealthRepairRequest#isConfirm()}
* 显式确认,未确认时仅返回演练结果,绝不改动磁盘。</p>
*/
@Service
public class LibraryHealthService {
private static final Logger log = LoggerFactory.getLogger(LibraryHealthService.class);
private static final Set<String> AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList(
"flac", "mp3", "m4a", "aac", "ogg", "opus", "wma",
"wav", "ape", "aiff", "aif", "wv", "tta"
));
/** 每类问题的示例列表上限,避免报告无界膨胀。 */
private static final int EXAMPLE_CAP = 50;
private final ConfigService configService;
private final TraditionalFilterService traditionalFilterService;
@Autowired(required = false)
private AudioValidationService audioValidationService;
@Autowired(required = false)
private LyricsService lyricsService;
public LibraryHealthService(ConfigService configService,
TraditionalFilterService traditionalFilterService) {
this.configService = configService;
this.traditionalFilterService = traditionalFilterService;
}
// ========== 只读扫描 ==========
/**
* 只读扫描 Library。
*
* @param checkDecode 是否执行完整解码校验(代价高;需 AudioValidationService 可用)
*/
public LibraryHealthReport scan(boolean checkDecode) {
LibraryHealthReport report = new LibraryHealthReport();
String libraryDir = configService.getLibraryDir();
if (libraryDir == null) {
report.setMessage("尚未配置工作根目录");
return report;
}
Path libraryPath = Paths.get(libraryDir);
if (!Files.isDirectory(libraryPath)) {
report.setMessage("Library 目录不存在: " + libraryDir);
return report;
}
boolean effectiveDecodeCheck = checkDecode && audioValidationService != null;
report.setDecodeChecked(effectiveDecodeCheck);
// 按 album 目录(含音频文件的目录)分组统计
Map<Path, List<Path>> albumToAudio = new TreeMap<>();
List<Path> allAudio = new ArrayList<>();
try (java.util.stream.Stream<Path> walk = Files.walk(libraryPath)) {
walk.filter(Files::isRegularFile)
.filter(LibraryHealthService::isAudioFile)
.forEach(p -> {
allAudio.add(p);
albumToAudio.computeIfAbsent(p.getParent(), k -> new ArrayList<>()).add(p);
});
} catch (IOException e) {
report.setMessage("扫描 Library 失败: " + e.getMessage());
return report;
}
report.setAlbums(albumToAudio.size());
report.setTracks(allAudio.size());
// 重复检测:identity -> count
Map<String, Integer> identityCount = new HashMap<>();
Map<String, String> identitySample = new HashMap<>();
for (Path album : albumToAudio.keySet()) {
boolean albumCovered = hasExistingCover(album);
if (!albumCovered) {
report.setAlbumsMissingCover(report.getAlbumsMissingCover() + 1);
addExample(report.getExamplesMissingCover(), rel(libraryPath, album));
}
// 孤立侧车与歌词齐备均按 .lrc(大小写不敏感)判定:
// 统计目录内音频 basename 与 .lrc basename 集合(均小写化)
Set<String> audioBaseNames = new HashSet<>();
for (Path a : albumToAudio.get(album)) {
audioBaseNames.add(baseName(a.getFileName().toString()).toLowerCase(Locale.ROOT));
}
Set<String> lrcBaseNames = new HashSet<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(album)) {
for (Path f : ds) {
if (!Files.isRegularFile(f)) continue;
String name = f.getFileName().toString();
if (name.toLowerCase(Locale.ROOT).endsWith(".lrc")) {
String base = baseName(name).toLowerCase(Locale.ROOT);
lrcBaseNames.add(base);
if (!audioBaseNames.contains(base)) {
report.setOrphanSidecars(report.getOrphanSidecars() + 1);
addExample(report.getExamplesOrphanSidecars(), rel(libraryPath, f));
}
}
}
} catch (IOException ignored) {
}
for (Path audio : albumToAudio.get(album)) {
String relKey = rel(libraryPath, audio);
String[] meta = readMeta(audio);
boolean metaOk = meta != null && !meta[0].isEmpty() && !meta[1].isEmpty() && !meta[2].isEmpty();
if (!metaOk) {
report.setTracksMissingMetadata(report.getTracksMissingMetadata() + 1);
addExample(report.getExamplesMissingMetadata(), relKey);
} else {
String id = identity(meta);
identityCount.merge(id, 1, Integer::sum);
identitySample.putIfAbsent(id, relKey);
}
// 歌词齐备:同名 .lrc(大小写不敏感,与孤立侧车判定一致)
String audioBase = baseName(audio.getFileName().toString()).toLowerCase(Locale.ROOT);
if (!lrcBaseNames.contains(audioBase)) {
report.setTracksMissingLyrics(report.getTracksMissingLyrics() + 1);
addExample(report.getExamplesMissingLyrics(), relKey);
}
// 解码有效性(可选)
if (effectiveDecodeCheck) {
AudioValidationService.ValidationResult vr = audioValidationService.validate(audio);
if (!vr.isValid()) {
report.setDecodeInvalid(report.getDecodeInvalid() + 1);
addExample(report.getExamplesDecodeInvalid(), relKey);
}
}
}
}
int dupGroups = 0, dupTracks = 0;
for (Map.Entry<String, Integer> e : identityCount.entrySet()) {
if (e.getValue() > 1) {
dupGroups++;
dupTracks += e.getValue();
addExample(report.getExamplesDuplicates(),
identitySample.get(e.getKey()) + " (x" + e.getValue() + ")");
}
}
report.setDuplicateGroups(dupGroups);
report.setDuplicateTracks(dupTracks);
report.setMessage("扫描完成(只读)");
return report;
}
// ========== 确认后修复 ==========
/**
* 执行修复动作。仅当 {@code request.confirm == true} 才真正修改磁盘,否则为演练。
*/
public LibraryHealthResult repair(LibraryHealthRepairRequest request) {
LibraryHealthResult result = new LibraryHealthResult();
if (request == null) {
result.setMessage("请求为空");
return result;
}
String libraryDir = configService.getLibraryDir();
if (libraryDir == null || !Files.isDirectory(Paths.get(libraryDir))) {
result.setMessage("Library 目录不存在");
return result;
}
Path libraryPath = Paths.get(libraryDir);
if (!request.isConfirm()) {
result.setApplied(false);
result.setMessage("未确认(confirm=false):仅演练,未修改任何文件。请设置 confirm=true 以执行。");
return result;
}
result.setApplied(true);
List<Path> allAudio = new ArrayList<>();
try (java.util.stream.Stream<Path> walk = Files.walk(libraryPath)) {
walk.filter(Files::isRegularFile)
.filter(LibraryHealthService::isAudioFile).forEach(allAudio::add);
} catch (IOException e) {
result.setMessage("扫描 Library 失败: " + e.getMessage());
return result;
}
// 1. 删除孤立侧车(仅确为孤立、无对应音频的 .lrc)
if (request.isRemoveOrphanSidecars()) {
removeOrphanSidecars(libraryPath, result);
}
// 2. 歌词回填(非致命)
if (request.isBackfillLyrics() && lyricsService != null) {
for (Path audio : allAudio) {
// 大小写不敏感判定已有 .lrc/.LRC,避免重复生成
if (hasLrcSidecar(audio)) continue;
Path lrc = audio.resolveSibling(baseName(audio.getFileName().toString()) + ".lrc");
String[] meta = readMeta(audio);
if (meta == null || meta[0].isEmpty() || meta[1].isEmpty()) continue;
LyricsService.RemoteOutcome ro =
lyricsService.resolveRemote(audio, meta[0], meta[1]);
if (ro == LyricsService.RemoteOutcome.FETCHED) {
result.setLyricsBackfilled(result.getLyricsBackfilled() + 1);
addExample(result.getChanged(), "lyric+ " + rel(libraryPath, lrc));
}
}
}
// 3. 删除解码校验失败的音频(危险,需开启解码校验);同时删除同 basename 侧车,避免遗留孤立 .lrc
if (request.isRemoveDecodeInvalid() && audioValidationService != null) {
for (Path audio : allAudio) {
AudioValidationService.ValidationResult vr = audioValidationService.validate(audio);
if (!vr.isValid()) {
try {
Files.deleteIfExists(audio);
result.setDecodeInvalidRemoved(result.getDecodeInvalidRemoved() + 1);
addExample(result.getChanged(), "decode- " + rel(libraryPath, audio));
// 删除该音频同 basename 的关联侧车(仅其自身的侧车,不影响其他曲目)
for (Path sidecar : sameBasenameSidecars(audio)) {
try {
if (Files.deleteIfExists(sidecar)) {
result.setOrphanSidecarsRemoved(result.getOrphanSidecarsRemoved() + 1);
addExample(result.getChanged(), "sidecar- " + rel(libraryPath, sidecar));
}
} catch (IOException e) {
log.warn("删除关联侧车失败: {} - {}", sidecar, e.getMessage());
}
}
} catch (IOException e) {
log.warn("删除无效音频失败: {} - {}", audio, e.getMessage());
}
}
}
}
result.setMessage("修复完成");
return result;
}
/** 删除孤立 .lrc(无同名音频的侧车)。仅在 confirm 后调用。 */
private void removeOrphanSidecars(Path libraryPath, LibraryHealthResult result) {
List<Path> dirs = new ArrayList<>();
try (java.util.stream.Stream<Path> walk = Files.walk(libraryPath)) {
walk.filter(Files::isDirectory).forEach(dirs::add);
} catch (IOException e) {
log.warn("孤立侧车清理扫描失败: {}", e.getMessage());
return;
}
for (Path dir : dirs) {
Set<String> audioBase = new HashSet<>();
List<Path> lrcs = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir)) {
for (Path f : ds) {
if (!Files.isRegularFile(f)) continue;
String name = f.getFileName().toString();
if (isAudioFile(f)) {
audioBase.add(baseName(name).toLowerCase(Locale.ROOT));
} else if (name.toLowerCase(Locale.ROOT).endsWith(".lrc")) {
lrcs.add(f);
}
}
} catch (IOException e) {
log.warn("读取目录失败: {} - {}", dir, e.getMessage());
continue;
}
for (Path lrc : lrcs) {
String base = baseName(lrc.getFileName().toString()).toLowerCase(Locale.ROOT);
if (!audioBase.contains(base)) {
try {
Files.deleteIfExists(lrc);
result.setOrphanSidecarsRemoved(result.getOrphanSidecarsRemoved() + 1);
addExample(result.getChanged(), "orphan- " + rel(libraryPath, lrc));
} catch (IOException e) {
log.warn("删除孤立侧车失败: {} - {}", lrc, e.getMessage());
}
}
}
}
}
/**
* 返回与音频同 basename 的关联侧车(同目录、去掉扩展名相同)。用于删除无效音频时一并清理。
*/
private List<Path> sameBasenameSidecars(Path audio) {
List<Path> result = new ArrayList<>();
Path dir = audio.getParent();
if (dir == null) return result;
String base = baseName(audio.getFileName().toString()).toLowerCase(Locale.ROOT);
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir)) {
for (Path f : ds) {
if (!Files.isRegularFile(f) || f.equals(audio)) continue;
if (isAudioFile(f)) continue; // 不动其他音频
String name = f.getFileName().toString();
// 不删除专辑级公共封面
String lower = name.toLowerCase(Locale.ROOT);
if (lower.equals("cover.jpg") || lower.equals("cover.jpeg") || lower.equals("cover.png")) {
continue;
}
if (baseName(name).toLowerCase(Locale.ROOT).equals(base)) {
result.add(f);
}
}
} catch (IOException e) {
log.debug("读取关联侧车失败: {} - {}", dir, e.getMessage());
}
return result;
}
// ========== 工具 ==========
/** 大小写不敏感判定音频是否有同名 .lrc 侧车(.lrc/.LRC 等)。 */
private boolean hasLrcSidecar(Path audio) {
Path dir = audio.getParent();
if (dir == null) return false;
String target = (baseName(audio.getFileName().toString()) + ".lrc").toLowerCase(Locale.ROOT);
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir)) {
for (Path f : ds) {
if (Files.isRegularFile(f)
&& f.getFileName().toString().toLowerCase(Locale.ROOT).equals(target)) {
return true;
}
}
} catch (IOException ignored) {
}
return false;
}
/**
* @return [title, artist, album, disc, track],读取失败返回 null。
* <p>jaudiotagger 无法解析(抛异常或无 tag)时,回退到 FFprobe 结构化元数据,
* 使合法但 jaudiotagger 不支持的 M4A/AAC 不被误报为缺元数据。
* 去重身份与 ingest 一致,需包含 Disc/Track。</p>
*/
private String[] readMeta(Path audio) {
try {
AudioFile af = AudioFileIO.read(audio.toFile());
Tag tag = af.getTag();
if (tag != null) {
// jaudiotagger 可读(即使字段为空也视为可读,缺字段属真实缺元数据)
return new String[]{
trim(tag.getFirst(FieldKey.TITLE)),
trim(tag.getFirst(FieldKey.ARTIST)),
trim(tag.getFirst(FieldKey.ALBUM)),
safeGetFirst(tag, FieldKey.DISC_NO),
safeGetFirst(tag, FieldKey.TRACK)
};
}
} catch (Exception e) {
// 落到 FFprobe 后备
}
// FFprobe 后备(对 jaudiotagger 无法解析的受支持容器)
if (audioValidationService != null) {
AudioValidationService.ProbeMetadata meta = audioValidationService.readMetadata(audio);
if (meta.isAvailable()) {
return new String[]{
meta.getFirst("title"),
meta.getFirst("artist"),
meta.getFirst("album"),
meta.getFirst("disc", "discnumber"),
meta.getFirst("track")
};
}
}
return null;
}
/** 与 ingest 去重身份一致:Artist|Album|Disc|Track|TitleDisc/Track 取前导数字)。 */
private String identity(String[] meta) {
int disc = meta.length > 3 ? parseLeadingInt(meta[3]) : 0;
int track = meta.length > 4 ? parseLeadingInt(meta[4]) : 0;
return norm(meta[1]) + "|" + norm(meta[2]) + "|" + disc + "|" + track + "|" + norm(meta[0]);
}
/** 安全读取 tag 字段,遇不支持的实现返回空串。 */
private static String safeGetFirst(Tag tag, FieldKey key) {
try {
return trim(tag.getFirst(key));
} catch (UnsupportedOperationException e) {
return "";
}
}
/** 解析形如 "3" 或 "3/12" 的前导数字,无数字返回 0。 */
private static int parseLeadingInt(String s) {
if (s == null) return 0;
StringBuilder digits = new StringBuilder();
for (char c : s.trim().toCharArray()) {
if (Character.isDigit(c)) digits.append(c);
else if (digits.length() > 0) break;
}
if (digits.length() == 0) return 0;
try {
return Integer.parseInt(digits.toString());
} catch (NumberFormatException e) {
return 0;
}
}
private String norm(String s) {
return traditionalFilterService.toSimplified(s == null ? "" : s.trim().toLowerCase(Locale.ROOT));
}
private boolean hasExistingCover(Path albumDir) {
try (DirectoryStream<Path> ds = Files.newDirectoryStream(albumDir)) {
for (Path p : ds) {
if (!Files.isRegularFile(p)) continue;
String n = p.getFileName().toString().toLowerCase(Locale.ROOT);
if ((n.equals("cover.jpg") || n.equals("cover.jpeg") || n.equals("cover.png"))
&& sizePositive(p)) {
return true;
}
}
} catch (IOException ignored) {
}
return false;
}
private static boolean sizePositive(Path p) {
try { return Files.size(p) > 0; } catch (IOException e) { return false; }
}
private static boolean isAudioFile(Path p) {
String ext = ext(p.getFileName().toString());
return ext != null && AUDIO_EXTENSIONS.contains(ext);
}
private static String ext(String name) {
int i = name.lastIndexOf('.');
if (i <= 0 || i == name.length() - 1) return null;
return name.substring(i + 1).toLowerCase(Locale.ROOT);
}
private static String baseName(String name) {
int i = name.lastIndexOf('.');
return i > 0 ? name.substring(0, i) : name;
}
private static String rel(Path root, Path p) {
try { return root.relativize(p).toString(); } catch (Exception e) { return p.toString(); }
}
private static String trim(String s) { return s == null ? "" : s.trim(); }
private static void addExample(List<String> list, String value) {
if (list.size() < EXAMPLE_CAP) list.add(value);
}
}
@@ -27,21 +27,46 @@ public class LyricsService {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int MAX_BYTES = 256 * 1024;
/**
* 远程歌词解析结果(供入库报告统计使用)。
* <ul>
* <li>{@link #EXISTS} 目标已存在 .lrc(来自侧车/内嵌),跳过远程查询;</li>
* <li>{@link #FETCHED} 远程命中并成功写入;</li>
* <li>{@link #MISSING} 已查询但无匹配候选;</li>
* <li>{@link #FAILED} 查询/写入发生错误(不影响入库);</li>
* <li>{@link #DISABLED} 歌词功能未启用。</li>
* </ul>
*/
public enum RemoteOutcome { EXISTS, FETCHED, MISSING, FAILED, DISABLED }
/**
* 若目标缺少同名 .lrc 则尝试远程获取。始终不抛异常,不影响入库。
*/
public void fetchIfMissing(Path targetFile, String title, String artist) {
if (targetFile == null || title == null || artist == null) return;
resolveRemote(targetFile, title, artist);
}
/**
* 与 {@link #fetchIfMissing} 相同的副作用,但返回结构化结果供报告统计。
* 任何失败都被吞掉并归类为 {@link RemoteOutcome#FAILED},绝不影响入库。
*/
public RemoteOutcome resolveRemote(Path targetFile, String title, String artist) {
if (targetFile == null || title == null || artist == null) return RemoteOutcome.MISSING;
Path lrc = targetFile.resolveSibling(baseName(targetFile.getFileName().toString()) + ".lrc");
if (Files.isRegularFile(lrc)) return;
if (!enabled()) return;
if (Files.isRegularFile(lrc)) return RemoteOutcome.EXISTS;
if (!enabled()) return RemoteOutcome.DISABLED;
try {
Candidate c = lookup(title, artist);
if (c == null) return;
if (c == null) return RemoteOutcome.MISSING;
Path tmp = lrc.resolveSibling(lrc.getFileName().toString() + ".tmp");
Files.write(tmp, c.lyrics.getBytes(StandardCharsets.UTF_8));
try { Files.move(tmp, lrc, StandardCopyOption.ATOMIC_MOVE); }
catch (Exception e) { Files.move(tmp, lrc, StandardCopyOption.REPLACE_EXISTING); }
log.info("已获取歌词: {} - {} [{}]", artist, title, c.source);
return RemoteOutcome.FETCHED;
} catch (Exception e) {
log.warn("歌词获取失败(不影响入库): {} - {} - {}", artist, title, e.getMessage());
return RemoteOutcome.FAILED;
}
}
@@ -0,0 +1,135 @@
package com.music.service;
import com.music.dto.DependencyStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* 运行时外部依赖检查:探测 ffmpeg / ffprobe 是否可用及其版本行。
* <p>可执行路径通过系统属性 {@code mangtool.ffmpeg.bin} / {@code mangtool.ffprobe.bin} 覆盖,
* 与 {@link AudioValidationService} 保持一致。</p>
*
* <p>输出由守护线程<b>异步</b>排空并限制大小,配合 {@link Process#waitFor(long, TimeUnit)}
* 强制超时;即使子进程持续持有 stdout 不退出,探测也会在超时后强制终止并回收,绝不永久阻塞。</p>
*/
@Service
public class RuntimeDependencyService {
private static final Logger log = LoggerFactory.getLogger(RuntimeDependencyService.class);
private static final int DEFAULT_TIMEOUT_SECONDS = 5;
/** 强制终止后回收子进程 / 合并 drainer 的最长等待秒数 */
private static final int CLEANUP_WAIT_SECONDS = 3;
/** 版本诊断保留的最大字节数 */
private static final int MAX_CAPTURE_BYTES = 8 * 1024;
private static final int BUFFER_SIZE = 4096;
/** 探测超时秒数(可被测试覆盖)。 */
private volatile int timeoutSeconds = DEFAULT_TIMEOUT_SECONDS;
/** 测试注入:缩短超时以验证挂起进程也能在超时内返回。 */
void setTimeoutSeconds(int seconds) {
if (seconds > 0) this.timeoutSeconds = seconds;
}
public DependencyStatus check() {
DependencyStatus status = new DependencyStatus();
String[] ff = probe(ffmpegBin());
status.setFfmpegAvailable(ff[0] != null);
status.setFfmpegVersion(ff[1]);
String[] fp = probe(ffprobeBin());
status.setFfprobeAvailable(fp[0] != null);
status.setFfprobeVersion(fp[1]);
return status;
}
/** @return [okMarker(null=不可用), versionLine]。 */
private String[] probe(String bin) {
Process p = null;
Thread drainer = null;
final ByteArrayOutputStream buffer = new ByteArrayOutputStream(BUFFER_SIZE);
try {
ProcessBuilder pb = new ProcessBuilder(bin, "-version");
pb.redirectErrorStream(true);
p = pb.start();
// 异步排空输出:绝不在 waitFor 之前同步 readLine 到 EOF,避免挂起进程永久阻塞
final InputStream in = p.getInputStream();
drainer = new Thread(() -> {
try {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) != -1) {
synchronized (buffer) {
int remaining = MAX_CAPTURE_BYTES - buffer.size();
if (remaining > 0) {
buffer.write(buf, 0, Math.min(n, remaining));
}
// 超过上限后继续读取以排空管道,丢弃多余数据
}
}
} catch (Exception ignored) {
// 进程终止后流关闭属正常
}
}, "dependency-probe-drainer");
drainer.setDaemon(true);
drainer.start();
boolean finished = p.waitFor(timeoutSeconds, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
p.waitFor(CLEANUP_WAIT_SECONDS, TimeUnit.SECONDS);
joinQuietly(drainer);
return new String[]{null, "探测超时"};
}
joinQuietly(drainer);
String version = firstLine(buffer);
if (p.exitValue() != 0) {
return new String[]{null, version};
}
return new String[]{"ok", version};
} catch (Exception e) {
log.debug("依赖探测失败: {} - {}", bin, e.getMessage());
return new String[]{null, "不可用: " + e.getMessage()};
} finally {
if (p != null && p.isAlive()) {
p.destroyForcibly();
}
}
}
private static void joinQuietly(Thread t) {
if (t == null) return;
try {
t.join(TimeUnit.SECONDS.toMillis(CLEANUP_WAIT_SECONDS));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static String firstLine(ByteArrayOutputStream buffer) {
String out;
synchronized (buffer) {
out = new String(buffer.toByteArray(), StandardCharsets.UTF_8);
}
int nl = out.indexOf('\n');
if (nl >= 0) out = out.substring(0, nl);
return out.trim();
}
private static String ffmpegBin() {
String v = System.getProperty("mangtool.ffmpeg.bin");
return v == null || v.trim().isEmpty() ? "ffmpeg" : v.trim();
}
private static String ffprobeBin() {
String v = System.getProperty("mangtool.ffprobe.bin");
return v == null || v.trim().isEmpty() ? "ffprobe" : v.trim();
}
}
@@ -696,8 +696,136 @@ class IngestServiceE2ETest {
}
}
// ========== 7b. 歌词报告统计(歌词缺失/失败绝不拒绝音频) ==========
@Test
void lyricReportingCountsAndPerFileOutcomeWithoutRejectingAudio() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-lyric-report-");
try {
Path inputDir = tmpDir.resolve("Input");
Path libDir = tmpDir.resolve("Library");
Path rejDir = tmpDir.resolve("Rejected");
Files.createDirectories(inputDir);
Files.createDirectories(libDir);
Files.createDirectories(rejDir);
// 文件 A:附带同名 .lrc 侧车 → lyricsFound(sidecar)
Path a = createTaggedMp3(inputDir, "withlrc.mp3", "Song A", "Artist A", "Album A");
Files.write(inputDir.resolve("withlrc.lrc"),
"[00:01.00]hello".getBytes(StandardCharsets.UTF_8));
// 文件 B:无歌词(lyricsService 未注入,远程被跳过)→ lyricsMissing
createTaggedMp3(inputDir, "nolrc.mp3", "Song B", "Artist B", "Album B");
ConfigService configService = mock(ConfigService.class);
when(configService.getInputDir()).thenReturn(inputDir.toString());
when(configService.getLibraryDir()).thenReturn(libDir.toString());
when(configService.getRejectedDir()).thenReturn(rejDir.toString());
when(configService.getBasePath()).thenReturn(tmpDir.toString());
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
configService,
new AudioValidationService());
service.ingest("lyric-report-task", new AtomicBoolean(true));
// 两个音频都应入库(歌词有无绝不影响音频入库结果 —— AC1)
assertTrue(Files.exists(libDir.resolve("Artist A").resolve("Album A")
.resolve("01 - Song A.mp3")), "有歌词文件应入库");
assertTrue(Files.exists(libDir.resolve("Artist B").resolve("Album B")
.resolve("01 - Song B.mp3")), "无歌词文件仍应入库");
Path reportsDir = rejDir.resolve("Reports");
Path[] reports;
try (Stream<Path> files = Files.list(reportsDir)) {
reports = files.filter(f -> f.toString().endsWith(".json")).toArray(Path[]::new);
}
assertTrue(reports.length > 0, "应存在报告文件");
String report = new String(Files.readAllBytes(reports[0]), StandardCharsets.UTF_8);
assertTrue(report.contains("\"lyricsFound\": 1"), "汇总应含 lyricsFound=1,实际:\n" + report);
assertTrue(report.contains("\"lyricsMissing\": 1"), "汇总应含 lyricsMissing=1");
assertTrue(report.contains("\"lyricsFailed\": 0"), "汇总应含 lyricsFailed=0");
assertTrue(report.contains("\"lyricStatus\": \"found\""), "应有 per-file found 状态");
assertTrue(report.contains("\"lyricSource\": \"sidecar\""), "侧车来源应记录为 sidecar");
assertTrue(report.contains("\"lyricStatus\": \"missing\""), "应有 per-file missing 状态");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 8. 并发锁 ==========
@Test
void cancelStopsFurtherProcessingAndKeepsCompletedFiles() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-cancel-");
try {
Path inputDir = tmpDir.resolve("Input");
Path libDir = tmpDir.resolve("Library");
Path rejDir = tmpDir.resolve("Rejected");
Files.createDirectories(inputDir);
Files.createDirectories(libDir);
Files.createDirectories(rejDir);
// 三个不同专辑的合法文件;取消将在第一个文件完成后触发
createTaggedMp3(inputDir, "one.mp3", "One", "Art One", "Alb One");
createTaggedMp3(inputDir, "two.mp3", "Two", "Art Two", "Alb Two");
createTaggedMp3(inputDir, "three.mp3", "Three", "Art Three", "Alb Three");
ConfigService configService = mock(ConfigService.class);
when(configService.getInputDir()).thenReturn(inputDir.toString());
when(configService.getLibraryDir()).thenReturn(libDir.toString());
when(configService.getRejectedDir()).thenReturn(rejDir.toString());
when(configService.getBasePath()).thenReturn(tmpDir.toString());
// 在第一个文件 recordProcessed 后自动请求取消,制造“中途取消”
IngestTaskStore store = new IngestTaskStore(configService) {
@Override
public synchronized void recordProcessed(String taskId, String fileKey, String outcome,
String lyricSource, String lyricStatus,
Counters counters) {
super.recordProcessed(taskId, fileKey, outcome, lyricSource, lyricStatus, counters);
requestCancel(taskId);
}
};
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
configService,
new AudioValidationService());
setPrivateField(service, "taskStore", store);
AtomicBoolean runningLock = new AtomicBoolean(true);
service.ingest("cancel-task", runningLock);
// 恰好一个文件被处理并入库;其余两个仍留在 Input(未被处理)
long libAudio;
try (Stream<Path> w = Files.walk(libDir)) {
libAudio = w.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".mp3")).count();
}
long inputRemaining;
try (Stream<Path> w = Files.walk(inputDir)) {
inputRemaining = w.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".mp3")).count();
}
assertEquals(1, libAudio, "取消前已完成的文件应入库并保留");
assertEquals(2, inputRemaining, "取消后未处理文件应保留在 Input,供下次导入");
assertEquals(IngestTaskStore.STATUS_CANCELLED, store.get("cancel-task").status,
"任务状态应为 cancelled");
assertFalse(runningLock.get(), "取消后并发锁仍应释放");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void concurrencyLockReleasedOnNormalCompletion() throws Exception {
assumeFfmpeg();
@@ -2408,6 +2536,13 @@ class IngestServiceE2ETest {
} catch (Exception ignored) {}
}
/** 通过反射设置 IngestService 的可选注入字段(测试构造器不设置这些字段)。 */
private static void setPrivateField(Object target, String field, Object value) throws Exception {
java.lang.reflect.Field f = target.getClass().getDeclaredField(field);
f.setAccessible(true);
f.set(target, value);
}
/** 解析系统路径中 ffmpeg 的绝对路径 */
private static String resolveFfmpegPath() throws IOException {
String userDefined = System.getProperty("mangtool.ffmpeg.bin");
@@ -0,0 +1,242 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
/**
* {@link IngestTaskStore} 生命周期持久化与重启恢复测试。
*/
class IngestTaskStoreTest {
@Test
void recoverInterrupted_marksRunningAsInterruptedAcrossReload() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
// 第一个实例:登记一个 running 任务并落盘
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("t-1", 10);
s1.recordProcessed("t-1", "a.mp3");
assertEquals(IngestTaskStore.STATUS_RUNNING, s1.get("t-1").status);
assertTrue(Files.isRegularFile(stateFile), "状态应已落盘");
// 第二个实例:模拟进程重启,加载 + 恢复
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
s2.recoverInterrupted();
IngestTaskStore.TaskState recovered = s2.get("t-1");
assertNotNull(recovered, "重载后应能读到任务");
assertEquals(IngestTaskStore.STATUS_INTERRUPTED, recovered.status,
"重启后仍处于 running 的任务应被标记为 interrupted");
// 已处理文件记录必须随重载保留(每条立即落盘,不丢最近一批)
assertEquals(1, recovered.processed, "processed 计数应随重载保留");
assertEquals(1, recovered.files.size());
assertEquals("a.mp3", recovered.files.get(0).file, "已处理文件键应随重载保留");
// 幂等:再次恢复不改变已终态任务
s2.recoverInterrupted();
assertEquals(IngestTaskStore.STATUS_INTERRUPTED, s2.get("t-1").status);
} finally {
deleteDir(tmpDir);
}
}
@Test
void everyProcessedRecordPersistsImmediately() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("t-batch", 5);
// 仅处理少量(< 旧的 20 批阈值),崩溃重载后不得丢失
for (int i = 0; i < 3; i++) {
s1.recordProcessed("t-batch", "f" + i + ".mp3");
}
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
IngestTaskStore.TaskState st = s2.get("t-batch");
assertNotNull(st);
assertEquals(3, st.processed, "3 条已处理记录应全部落盘(无 batch 丢失)");
assertEquals(3, st.files.size());
} finally {
deleteDir(tmpDir);
}
}
@Test
void interruptedReload_restoresCountersAndPerFileOutcomes() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("mix", 4);
// 文件1:成功入库 + 侧车歌词
s1.recordProcessed("mix", "a.mp3", "ingested", "sidecar", "found",
new IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0));
// 文件2:重复
s1.recordProcessed("mix", "b.mp3", "rejected:duplicate", null, null,
new IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0));
// 文件3:缺元数据
s1.recordProcessed("mix", "c.mp3", "rejected:missing-metadata", null, null,
new IngestTaskStore.Counters(1, 1, 1, 0, 0, 0, 0, 1, 0, 0));
// 文件4:入库但歌词失败
s1.recordProcessed("mix", "d.mp3", "ingested", "none", "failed",
new IngestTaskStore.Counters(2, 1, 1, 0, 0, 0, 0, 1, 0, 1));
// 未 complete → 模拟中断
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
s2.recoverInterrupted();
IngestTaskStore.TaskState st = s2.get("mix");
assertNotNull(st);
assertEquals(IngestTaskStore.STATUS_INTERRUPTED, st.status);
// 汇总计数恢复
assertEquals(2, st.ingested, "ingested 计数应恢复");
assertEquals(1, st.duplicates, "duplicates 计数应恢复");
assertEquals(1, st.missingMetadata, "missingMetadata 计数应恢复");
assertEquals(1, st.lyricsFound, "lyricsFound 计数应恢复");
assertEquals(1, st.lyricsFailed, "lyricsFailed 计数应恢复");
assertEquals(4, st.processed);
// 逐文件结果恢复
assertEquals(4, st.files.size());
assertEquals("rejected:duplicate", st.files.get(1).outcome, "重复曲目逐文件结果应恢复");
assertEquals("sidecar", st.files.get(0).lyricSource);
assertEquals("found", st.files.get(0).lyricStatus);
assertEquals("failed", st.files.get(3).lyricStatus);
} finally {
deleteDir(tmpDir);
}
}
@Test
void retention_keepsLatestTasksAndDropsOldest() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s = new IngestTaskStore(mock(ConfigService.class));
s.setStateFileOverride(stateFile);
int over = IngestTaskStore.MAX_TASKS + 5;
for (int i = 0; i < over; i++) {
s.begin("t-" + i, 1);
s.complete("t-" + i, "done");
}
// 有界:任务数不超过上限
int present = 0;
for (int i = 0; i < over; i++) {
if (s.get("t-" + i) != null) present++;
}
assertTrue(present <= IngestTaskStore.MAX_TASKS, "保留任务数应受 MAX_TASKS 限制,实际=" + present);
// 最新任务保留,最旧任务被压实丢弃
assertNotNull(s.get("t-" + (over - 1)), "最新任务应保留");
assertNull(s.get("t-0"), "最旧任务应被压实丢弃");
} finally {
deleteDir(tmpDir);
}
}
@Test
void perTaskFileWindow_isBounded() {
IngestTaskStore s = new IngestTaskStore(mock(ConfigService.class));
s.begin("big", IngestTaskStore.MAX_FILES_PER_TASK + 50);
for (int i = 0; i < IngestTaskStore.MAX_FILES_PER_TASK + 50; i++) {
s.recordProcessed("big", "f" + i + ".mp3");
}
IngestTaskStore.TaskState st = s.get("big");
assertEquals(IngestTaskStore.MAX_FILES_PER_TASK, st.files.size(),
"逐文件记录应受滚动窗口上限限制");
assertEquals(IngestTaskStore.MAX_FILES_PER_TASK + 50, st.processed,
"processed 计数仍为全量(不受窗口影响)");
// 保留的是最近的记录
assertEquals("f" + (IngestTaskStore.MAX_FILES_PER_TASK + 49) + ".mp3",
st.files.get(st.files.size() - 1).file);
}
@Test
void markFailed_persistsFailedTerminalStatus() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("fail-1", 2);
s1.markFailed("fail-1", "boom");
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
s2.recoverInterrupted();
IngestTaskStore.TaskState st = s2.get("fail-1");
assertEquals(IngestTaskStore.STATUS_FAILED, st.status, "failed 是终态,不应被恢复改成 interrupted");
assertEquals("boom", st.message);
} finally {
deleteDir(tmpDir);
}
}
@Test
void latest_returnsMostRecentlyUpdatedTask() {
IngestTaskStore s = new IngestTaskStore(mock(ConfigService.class));
assertNull(s.latest());
s.begin("old", 1);
s.begin("new", 1);
s.recordProcessed("new", "x.mp3");
IngestTaskStore.TaskState latest = s.latest();
assertNotNull(latest);
assertEquals("new", latest.taskId, "latest 应返回最近更新的任务");
}
@Test
void completedTask_notMarkedInterruptedOnReload() throws Exception {
Path tmpDir = Files.createTempDirectory("task-store-");
Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
IngestTaskStore s1 = new IngestTaskStore(mock(ConfigService.class));
s1.setStateFileOverride(stateFile);
s1.begin("done-1", 1);
s1.complete("done-1", "完成");
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
s2.setStateFileOverride(stateFile);
s2.load();
s2.recoverInterrupted();
assertEquals(IngestTaskStore.STATUS_COMPLETED, s2.get("done-1").status,
"已完成的任务不应被恢复逻辑改成 interrupted");
} finally {
deleteDir(tmpDir);
}
}
@Test
void cancelRequest_flagObservableAndClearedByBegin() {
IngestTaskStore s = new IngestTaskStore(mock(ConfigService.class));
s.requestCancel("x");
assertTrue(s.isCancelRequested("x"));
// begin 应清除历史取消标记,避免新任务误取消
s.begin("x", 3);
assertFalse(s.isCancelRequested("x"));
}
private static void deleteDir(Path dir) {
try {
if (Files.exists(dir)) {
Files.walk(dir).sorted((a, b) -> -a.compareTo(b))
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} });
}
} catch (Exception ignored) {}
}
}
@@ -0,0 +1,348 @@
package com.music.service;
import com.music.dto.LibraryHealthReport;
import com.music.dto.LibraryHealthRepairRequest;
import com.music.dto.LibraryHealthResult;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link LibraryHealthService} 只读扫描与确认后修复的侧车安全测试。
* <p>用占位文件(音频扩展名 + .lrc/.jpg)验证基于文件名的孤立侧车判定与确认门,
* 不依赖 ffmpeg。</p>
*/
class LibraryHealthServiceTest {
private LibraryHealthService newService(Path libraryDir) {
ConfigService cfg = mock(ConfigService.class);
when(cfg.getLibraryDir()).thenReturn(libraryDir.toString());
return new LibraryHealthService(cfg, new TraditionalFilterService());
}
/** 构建:Album 内含 1 音频 + 匹配侧车 + 孤立侧车 + 封面。 */
private Path buildLibrary(Path root) throws Exception {
Path album = root.resolve("Artist").resolve("Album");
Files.createDirectories(album);
Files.write(album.resolve("01 - Song.mp3"), new byte[]{1, 2, 3});
Files.write(album.resolve("01 - Song.lrc"), "[00:01]hi".getBytes(StandardCharsets.UTF_8));
Files.write(album.resolve("orphan.lrc"), "[00:01]orphan".getBytes(StandardCharsets.UTF_8));
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
return album;
}
@Test
void scan_isReadOnly_detectsOrphanSidecarWithoutModifying() throws Exception {
Path root = Files.createTempDirectory("lib-health-");
try {
Path album = buildLibrary(root);
LibraryHealthService svc = newService(root);
LibraryHealthReport report = svc.scan(false);
assertTrue(report.isReadOnly());
assertEquals(1, report.getAlbums());
assertEquals(1, report.getTracks());
assertEquals(1, report.getOrphanSidecars(), "应检出 1 个孤立侧车");
assertEquals(0, report.getAlbumsMissingCover(), "cover.jpg 存在,不应计缺封面");
assertEquals(0, report.getTracksMissingLyrics(), "01 - Song.lrc 匹配,不应计缺歌词");
assertFalse(report.isDecodeChecked(), "未启用解码校验");
// 只读:所有文件仍在
assertTrue(Files.exists(album.resolve("01 - Song.mp3")));
assertTrue(Files.exists(album.resolve("01 - Song.lrc")));
assertTrue(Files.exists(album.resolve("orphan.lrc")), "扫描绝不删除任何文件");
assertTrue(Files.exists(album.resolve("cover.jpg")));
} finally {
deleteDir(root);
}
}
@Test
void repair_withoutConfirm_isDryRunAndDeletesNothing() throws Exception {
Path root = Files.createTempDirectory("lib-health-");
try {
Path album = buildLibrary(root);
LibraryHealthService svc = newService(root);
LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
req.setConfirm(false);
req.setRemoveOrphanSidecars(true);
LibraryHealthResult res = svc.repair(req);
assertFalse(res.isApplied(), "未确认应为演练");
assertEquals(0, res.getOrphanSidecarsRemoved());
assertTrue(Files.exists(album.resolve("orphan.lrc")), "演练不得删除任何文件");
assertTrue(Files.exists(album.resolve("01 - Song.lrc")));
} finally {
deleteDir(root);
}
}
@Test
void repair_confirmed_removesOnlyOrphanSidecar_preservesMatchedAndAudio() throws Exception {
Path root = Files.createTempDirectory("lib-health-");
try {
Path album = buildLibrary(root);
LibraryHealthService svc = newService(root);
LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
req.setConfirm(true);
req.setRemoveOrphanSidecars(true);
LibraryHealthResult res = svc.repair(req);
assertTrue(res.isApplied());
assertEquals(1, res.getOrphanSidecarsRemoved(), "仅应删除 1 个孤立侧车");
assertFalse(Files.exists(album.resolve("orphan.lrc")), "孤立侧车应被删除");
// 侧车安全:匹配侧车与音频、封面均保留
assertTrue(Files.exists(album.resolve("01 - Song.lrc")), "匹配侧车不得删除");
assertTrue(Files.exists(album.resolve("01 - Song.mp3")), "音频不得删除");
assertTrue(Files.exists(album.resolve("cover.jpg")), "封面不得删除");
} finally {
deleteDir(root);
}
}
@Test
void scan_reportsMissingCoverForUncoveredAlbum() throws Exception {
Path root = Files.createTempDirectory("lib-health-");
try {
Path album = root.resolve("A").resolve("NoCover");
Files.createDirectories(album);
Files.write(album.resolve("01 - T.mp3"), new byte[]{9});
LibraryHealthService svc = newService(root);
LibraryHealthReport report = svc.scan(false);
assertEquals(1, report.getAlbumsMissingCover());
assertEquals(1, report.getTracksMissingLyrics());
} finally {
deleteDir(root);
}
}
// ========== codex-1-3: FFprobe 后备 + 无效音频删除同 basename 侧车(需 ffmpeg ==========
@org.junit.jupiter.api.Test
void scan_usesFfprobeFallback_forJaudiotaggerUnreadableM4a() throws Exception {
assumeFfmpeg();
Path root = Files.createTempDirectory("lib-health-fb-");
try {
Path album = root.resolve("FB Artist").resolve("FB Album");
Files.createDirectories(album);
// MP4/AAC 内容但命名为 .aacjaudiotagger 无法解析,FFprobe 可读完整元数据
createMp4AsAac(album.resolve("01 - Song.aac"), "T", "A", "Al");
// 提供封面避免与缺元数据无关的其他计数干扰(缺封面不影响本断言)
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
assertJaudiotaggerCannotRead(album.resolve("01 - Song.aac"));
LibraryHealthService svc = withValidation(newService(root));
LibraryHealthReport report = svc.scan(false);
assertEquals(1, report.getTracks());
assertEquals(0, report.getTracksMissingMetadata(),
"FFprobe 后备可读元数据的合法 M4A 不应被误报为缺元数据");
} finally {
deleteDir(root);
}
}
@org.junit.jupiter.api.Test
void repair_removeDecodeInvalid_alsoDeletesSameBasenameSidecar() throws Exception {
assumeFfmpeg();
Path root = Files.createTempDirectory("lib-health-inv-");
try {
Path album = root.resolve("Art").resolve("Alb");
Files.createDirectories(album);
// 损坏音频(ffprobe/ffmpeg 无法解码)+ 同 basename 侧车
Files.write(album.resolve("bad.mp3"), "not audio".getBytes(StandardCharsets.UTF_8));
Files.write(album.resolve("bad.lrc"), "[00:01]x".getBytes(StandardCharsets.UTF_8));
// 另一首“有效”曲目(真实音频,解码通过)的侧车与公共封面:不得被误删
createValidMp3(album.resolve("good.mp3"));
Files.write(album.resolve("good.lrc"), "[00:01]y".getBytes(StandardCharsets.UTF_8));
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
LibraryHealthService svc = withValidation(newService(root));
LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
req.setConfirm(true);
req.setRemoveDecodeInvalid(true);
LibraryHealthResult res = svc.repair(req);
assertTrue(res.isApplied());
assertTrue(res.getDecodeInvalidRemoved() >= 1, "损坏音频应被删除");
assertFalse(Files.exists(album.resolve("bad.mp3")), "损坏音频应删除");
assertFalse(Files.exists(album.resolve("bad.lrc")), "损坏音频的同名侧车应一并删除,避免遗留孤立 .lrc");
// 侧车安全:其他曲目侧车、公共封面保留
assertTrue(Files.exists(album.resolve("good.lrc")), "其他曲目侧车不得删除");
assertTrue(Files.exists(album.resolve("cover.jpg")), "公共封面不得删除");
} finally {
deleteDir(root);
}
}
// ========== codex-2-2: 去重身份含 Disc/Track + 大小写不敏感 .lrc ==========
@org.junit.jupiter.api.Test
void scan_sameTitleDifferentTrack_notFlaggedDuplicate() throws Exception {
assumeFfmpeg();
Path root = Files.createTempDirectory("lib-health-dup-");
try {
Path album = root.resolve("Art").resolve("Alb");
Files.createDirectories(album);
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
// 同 Title/Artist/Album 但 Track 不同 —— 合法的不同曲目(如 live/remix 同名),不应判重复
createMp4AsAac(album.resolve("01 - Song.aac"), "Song", "Art", "Alb", 1);
createMp4AsAac(album.resolve("02 - Song.aac"), "Song", "Art", "Alb", 2);
LibraryHealthService svc = withValidation(newService(root));
LibraryHealthReport report = svc.scan(false);
assertEquals(2, report.getTracks());
assertEquals(0, report.getTracksMissingMetadata(), "FFprobe 后备应读到元数据");
assertEquals(0, report.getDuplicateGroups(),
"同名不同 Track 的合法曲目不应被误判为重复(身份含 Disc/Track)");
assertEquals(0, report.getDuplicateTracks());
} finally {
deleteDir(root);
}
}
@org.junit.jupiter.api.Test
void scan_sameTitleSameTrack_isDuplicate() throws Exception {
assumeFfmpeg();
Path root = Files.createTempDirectory("lib-health-dup2-");
try {
Path album = root.resolve("Art").resolve("Alb");
Files.createDirectories(album);
Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
// 完全相同的身份(含相同 Track)→ 真重复
createMp4AsAac(album.resolve("a.aac"), "Song", "Art", "Alb", 1);
createMp4AsAac(album.resolve("b.aac"), "Song", "Art", "Alb", 1);
LibraryHealthService svc = withValidation(newService(root));
LibraryHealthReport report = svc.scan(false);
assertEquals(1, report.getDuplicateGroups(), "相同身份应判为一组重复");
assertEquals(2, report.getDuplicateTracks());
} finally {
deleteDir(root);
}
}
@org.junit.jupiter.api.Test
void scan_uppercaseLrc_notReportedMissingLyricsNorOrphan() throws Exception {
Path root = Files.createTempDirectory("lib-health-lrc-");
try {
Path album = root.resolve("A").resolve("B");
Files.createDirectories(album);
// 大写 .LRC 侧车:既不应算缺歌词,也不应算孤立(与 orphan 判定保持一致)
Files.write(album.resolve("01 - Song.mp3"), new byte[]{1, 2, 3});
Files.write(album.resolve("01 - Song.LRC"), "[00:01]hi".getBytes(StandardCharsets.UTF_8));
LibraryHealthService svc = newService(root);
LibraryHealthReport report = svc.scan(false);
assertEquals(0, report.getTracksMissingLyrics(),
"大写 .LRC 应被识别为已有歌词(大小写不敏感)");
assertEquals(0, report.getOrphanSidecars(),
"匹配到音频的 .LRC 不应被判为孤立");
} finally {
deleteDir(root);
}
}
// ---- ffmpeg 辅助 ----
private static void assumeFfmpeg() { boolean ok;
try {
Process p = new ProcessBuilder("ffmpeg", "-version").redirectErrorStream(true).start();
ok = p.waitFor(5, java.util.concurrent.TimeUnit.SECONDS) && p.exitValue() == 0;
if (p.isAlive()) p.destroyForcibly();
} catch (Exception e) {
ok = false;
}
org.junit.jupiter.api.Assumptions.assumeTrue(ok, "此测试需要 ffmpeg");
}
/** 注入真实 AudioValidationService,启用 FFprobe 后备 / 解码校验。 */
private static LibraryHealthService withValidation(LibraryHealthService svc) throws Exception {
java.lang.reflect.Field f = LibraryHealthService.class.getDeclaredField("audioValidationService");
f.setAccessible(true);
f.set(svc, new AudioValidationService());
return svc;
}
/** 生成一个真实可解码的短 MP3(有效音频,不会被解码校验判无效)。 */
private static void createValidMp3(Path out) throws Exception {
run(new ProcessBuilder("ffmpeg", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.2",
"-c:a", "libmp3lame", out.toAbsolutePath().toString()));
}
private static void createMp4AsAac(Path out, String title, String artist, String album) throws Exception {
createMp4AsAac(out, title, artist, album, null);
}
private static void createMp4AsAac(Path out, String title, String artist, String album, Integer track) throws Exception {
Path cover = out.resolveSibling("." + out.getFileName() + ".cover.png");
run(new ProcessBuilder("ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=blue:s=48x48:d=0.1",
"-frames:v", "1", cover.toAbsolutePath().toString()));
java.util.List<String> cmd = new java.util.ArrayList<>(java.util.Arrays.asList("ffmpeg", "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-i", cover.toAbsolutePath().toString(),
"-map", "0:a", "-map", "1:v", "-disposition:v:0", "attached_pic", "-c:v", "png",
"-t", "0.3", "-c:a", "aac", "-f", "mp4",
"-metadata", "title=" + title, "-metadata", "artist=" + artist, "-metadata", "album=" + album));
if (track != null) {
cmd.add("-metadata");
cmd.add("track=" + track);
}
cmd.add(out.toAbsolutePath().toString());
run(new ProcessBuilder(cmd));
Files.deleteIfExists(cover);
}
private static void run(ProcessBuilder pb) throws Exception {
pb.redirectErrorStream(true);
Process p = pb.start();
// 排空输出
try (java.io.InputStream in = p.getInputStream()) {
byte[] buf = new byte[4096];
while (in.read(buf) != -1) { /* drain */ }
}
if (!p.waitFor(20, java.util.concurrent.TimeUnit.SECONDS)) {
p.destroyForcibly();
throw new RuntimeException("ffmpeg 超时");
}
if (p.exitValue() != 0) throw new RuntimeException("ffmpeg 失败 exit=" + p.exitValue());
}
private static void assertJaudiotaggerCannotRead(Path file) {
boolean readable;
try {
org.jaudiotagger.audio.AudioFile af =
org.jaudiotagger.audio.AudioFileIO.read(file.toFile());
readable = af.getTag() != null;
} catch (Exception e) {
readable = false;
}
assertFalse(readable, "前置条件:jaudiotagger 应无法读取该文件的标签");
}
private static void deleteDir(Path dir) {
try {
if (Files.exists(dir)) {
Files.walk(dir).sorted((a, b) -> -a.compareTo(b))
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} });
}
} catch (Exception ignored) {}
}
}
@@ -113,7 +113,8 @@ class ProgressMessageMappingTest {
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class),
new ProgressStore(),
cfgService);
cfgService,
mock(com.music.service.IngestTaskStore.class));
// 模拟配置已设置
com.music.dto.ConfigResponse cfgResp = new com.music.dto.ConfigResponse();
@@ -146,7 +147,8 @@ class ProgressMessageMappingTest {
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class),
store,
cfgService);
cfgService,
mock(com.music.service.IngestTaskStore.class));
java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running");
runningField.setAccessible(true);
@@ -166,7 +168,8 @@ class ProgressMessageMappingTest {
ConfigService cfgService = mock(ConfigService.class);
ProgressStore store = new ProgressStore();
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class), store, cfgService);
mock(IngestService.class), store, cfgService,
mock(com.music.service.IngestTaskStore.class));
com.music.common.Result<com.music.dto.IngestStatusResponse> result =
controller.statusById("expired-task");
@@ -175,4 +178,55 @@ class ProgressMessageMappingTest {
assertTrue(result.getData().isCompleted());
assertEquals("未找到任务或已过期", result.getData().getMessage());
}
@Test
void ingestControllerExposesPersistedInterruptedTaskAfterRestart() throws Exception {
java.nio.file.Path tmpDir = java.nio.file.Files.createTempDirectory("ctrl-restart-");
java.nio.file.Path stateFile = tmpDir.resolve("ingest-tasks.json");
try {
ConfigService cfgService = mock(ConfigService.class);
// 模拟“上次进程”:登记一个 running 任务并记录已处理文件(含汇总计数)后落盘
com.music.service.IngestTaskStore prev = new com.music.service.IngestTaskStore(cfgService);
prev.setStateFileOverride(stateFile);
prev.begin("restart-task", 5);
prev.recordProcessed("restart-task", "done1.mp3", "ingested", "sidecar", "found",
new com.music.service.IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0));
prev.recordProcessed("restart-task", "done2.mp3", "rejected:duplicate", null, null,
new com.music.service.IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0));
// 模拟“重启”:新 store 加载 + 恢复(running -> interrupted
com.music.service.IngestTaskStore fresh = new com.music.service.IngestTaskStore(cfgService);
fresh.setStateFileOverride(stateFile);
fresh.load();
fresh.recoverInterrupted();
// 新进程的控制器:ProgressStore 为空(内存进度已丢失),currentTaskId 为 null
ProgressStore store = new ProgressStore();
com.music.controller.IngestController controller = new com.music.controller.IngestController(
mock(IngestService.class), store, cfgService, fresh);
com.music.common.Result<com.music.dto.IngestStatusResponse> result = controller.statusCurrent();
com.music.dto.IngestStatusResponse resp = result.getData();
assertEquals("restart-task", resp.getTaskId(), "重启后应从持久化恢复最近任务");
assertFalse(resp.isRunning(), "interrupted 任务不再运行");
assertTrue(resp.isCompleted(), "终态任务对前端视为已结束");
assertEquals(2, resp.getProcessed(), "已处理文件数应随重载暴露");
assertEquals(5, resp.getTotal());
// 汇总计数亦应随重启恢复(此前 IngestController 恒置零)
assertEquals(1, resp.getIngestedFiles(), "入库计数应随重启恢复");
assertEquals(1, resp.getDuplicateFiles(), "重复计数应随重启恢复");
assertEquals(1, resp.getLyricsFound(), "歌词计数应随重启恢复");
} finally {
deleteDirRecursive(tmpDir);
}
}
private static void deleteDirRecursive(java.nio.file.Path dir) {
try {
java.nio.file.Files.walk(dir).sorted((a, b) -> -a.compareTo(b))
.forEach(p -> { try { java.nio.file.Files.deleteIfExists(p); } catch (Exception ignored) {} });
} catch (Exception ignored) {}
}
}
@@ -0,0 +1,112 @@
package com.music.service;
import com.music.dto.DependencyStatus;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link RuntimeDependencyService} 依赖探测测试(用伪脚本,不依赖真实 ffmpeg)。
*/
class RuntimeDependencyServiceTest {
private final RuntimeDependencyService service = new RuntimeDependencyService();
@AfterEach
void clear() {
System.clearProperty("mangtool.ffmpeg.bin");
System.clearProperty("mangtool.ffprobe.bin");
}
@Test
void reportsAvailableWithVersionLine() throws Exception {
Path tmp = Files.createTempDirectory("dep-");
try {
Path fake = writeScript(tmp, "fake.sh",
"#!/bin/sh",
"echo 'ffmpeg version 6.1.1 fake'",
"exit 0");
System.setProperty("mangtool.ffmpeg.bin", fake.toString());
System.setProperty("mangtool.ffprobe.bin", fake.toString());
DependencyStatus s = service.check();
assertTrue(s.isFfmpegAvailable());
assertTrue(s.isFfprobeAvailable());
assertTrue(s.isAllAvailable());
assertTrue(s.getFfmpegVersion().contains("6.1.1"), s.getFfmpegVersion());
} finally {
deleteDir(tmp);
}
}
@Test
void reportsUnavailableForMissingBinary() {
System.setProperty("mangtool.ffmpeg.bin", "/nonexistent/ffmpeg-xyz");
System.setProperty("mangtool.ffprobe.bin", "/nonexistent/ffprobe-xyz");
DependencyStatus s = service.check();
assertFalse(s.isFfmpegAvailable());
assertFalse(s.isFfprobeAvailable());
assertFalse(s.isAllAvailable());
}
@Test
void nonZeroExitIsUnavailable() throws Exception {
Path tmp = Files.createTempDirectory("dep-");
try {
Path fake = writeScript(tmp, "bad.sh", "#!/bin/sh", "echo boom >&2", "exit 3");
System.setProperty("mangtool.ffmpeg.bin", fake.toString());
System.setProperty("mangtool.ffprobe.bin", fake.toString());
DependencyStatus s = service.check();
assertFalse(s.isFfmpegAvailable());
} finally {
deleteDir(tmp);
}
}
@Test
void hangingBinaryHonorsTimeoutAndDoesNotBlockForever() throws Exception {
Path tmp = Files.createTempDirectory("dep-");
try {
// 打印一行后长睡且持有 stdout 不退出:若同步 readLine 到 EOF 会永久阻塞
Path hang = writeScript(tmp, "hang.sh",
"#!/bin/sh",
"echo 'ffmpeg version hang'",
"sleep 60");
System.setProperty("mangtool.ffmpeg.bin", hang.toString());
System.setProperty("mangtool.ffprobe.bin", hang.toString());
RuntimeDependencyService fast = new RuntimeDependencyService();
fast.setTimeoutSeconds(1);
long start = System.currentTimeMillis();
DependencyStatus s = fast.check();
long elapsed = System.currentTimeMillis() - start;
assertFalse(s.isFfmpegAvailable(), "挂起进程应因超时被判不可用");
assertFalse(s.isFfprobeAvailable());
// 两次探测各约 1s 超时 + 有界回收,总耗时应远小于 sleep 60s
assertTrue(elapsed < 20_000, "超时应生效而非永久阻塞,耗时(ms)=" + elapsed);
} finally {
deleteDir(tmp);
}
}
private static Path writeScript(Path dir, String name, String... lines) throws Exception {
Path p = dir.resolve(name);
Files.write(p, Arrays.asList(lines));
p.toFile().setExecutable(true);
return p;
}
private static void deleteDir(Path dir) {
try {
Files.walk(dir).sorted((a, b) -> -a.compareTo(b))
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} });
} catch (Exception ignored) {}
}
}