diff --git a/README.md b/README.md index c9227a0..fbfc15c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ 3. **繁简转换** — 将繁体中文标签自动转为简体 4. **格式转换** — 将 WAV/APE/AIFF/WV/TTA 转为 FLAC(需 FFmpeg),失败文件移入 Rejected/ConversionFailed 5. **去重** — 基于 Artist+Album+Disc+Track+Title 归一化身份标识对比 Library 已有文件,重复移入 Rejected/Duplicate -6. **入库** — 有效文件按 `Artist/Album (year)/NN - Title.ext` 布局移入 Library +6. **封面** — 专辑目录须有 `cover.jpg/png` 或音频含内嵌封面,二者皆无移入 Rejected/MissingCover +7. **入库** — 有效文件按 `Artist/Album (year)/NN - Title.ext` 布局移入 Library +8. **歌词(可选)** — 从侧车 `.lrc`、内嵌歌词或远程接口补齐歌词;**歌词的有/无/失败绝不影响音频入库**,仅计入报告统计 ## 目录结构 @@ -19,14 +21,47 @@ BasePath/ ├── Input/ ← 放入待处理的音频文件 ├── Library/ ← 整理后的 Navidrome 兼容曲库 +├── .mangtool/ ← 任务生命周期状态(重启恢复用,勿手动改动) └── Rejected/ ← 被拒绝的文件按原因分类 ├── MissingMetadata/ + ├── MissingCover/ ├── Duplicate/ ├── ConversionFailed/ ├── Unreadable/ - └── Other/ + ├── Other/ + └── Reports/ ← 每次导入的结构化 JSON 报告(逐文件结果 + 歌词统计) ``` +## 可观测性与报告 + +- **导入报告**:每次导入在 `Rejected/Reports/` 写入一份结构化 JSON,含逐文件结果(`outcome`)与歌词来源/状态(`lyricSource`/`lyricStatus`),以及汇总的 `lyricsFound/lyricsMissing/lyricsFailed` 等计数。既有字段保持向后兼容。 +- **UI 下载**:一键导入页可查看歌词「有/无/失败」实时统计,并「下载导入报告」(`GET /api/ingest/report/{taskId}`,`taskId` 传 `latest` 取最近一份)。 +- **依赖自检**:`GET /api/health/dependencies` 报告 FFmpeg/FFprobe 是否可用及版本,便于部署自检与排障。 + +## 任务生命周期、取消与恢复 + +- **取消**:`POST /api/ingest/cancel` 取消当前任务;取消只停止后续文件处理,**已入库(已移动)的文件不会被删除**,未处理文件保留在 `Input/` 供下次导入。 +- **重启恢复**:任务生命周期与逐文件完成情况持久化在 `BasePath/.mangtool/ingest-tasks.json`。服务重启后,仍处于运行中的任务会被标记为 `interrupted`;由于已完成文件已移出 `Input/`,重新导入自然只处理未完成文件,不会重复搬运。 + +## Library 健康检查(只读,修复需确认) + +- **只读扫描**:`POST /api/library/health/scan`(可选 `?checkDecode=true` 执行完整解码校验)统计缺元数据、缺封面、缺歌词、孤立侧车(无对应音频的 `.lrc`)与重复曲目,**绝不修改任何文件**。 +- **确认后修复**:`POST /api/library/health/repair` 需请求体 `confirm=true` 才会执行(歌词回填、删除孤立侧车、可选删除解码失败音频);`confirm=false` 仅演练,不改动磁盘。匹配到音频的侧车永不删除。 + +## 备份与恢复 + +导入是「移动」语义,请在批量导入前对关键目录做快照备份: + +- **必备**:`Library/`(成品曲库,含 `cover.jpg/png` 与 `.lrc`)。 +- **建议**:`Rejected/`(含 `Reports/` 报告,便于追溯)与 `BasePath/.mangtool/`(任务状态)。 + +```bash +# 简易快照(示例) +tar czf mangtool-backup-$(date +%Y%m%d).tgz -C /path/to/BasePath Library Rejected .mangtool +``` + +恢复时将快照解压回原 `BasePath` 即可;Navidrome 直接扫描 `Library/` 目录,无需额外数据库。 + ## 构建与运行 ```bash diff --git a/backend/src/main/java/com/music/controller/HealthController.java b/backend/src/main/java/com/music/controller/HealthController.java index 608e1bc..b7ab24b 100644 --- a/backend/src/main/java/com/music/controller/HealthController.java +++ b/backend/src/main/java/com/music/controller/HealthController.java @@ -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 health() { return Result.success("OK"); } -} + /** + * 运行时依赖检查:报告 ffmpeg / ffprobe 是否可用及版本,便于部署自检与排障。 + */ + @GetMapping("/api/health/dependencies") + public Result dependencies() { + return Result.success(dependencyService.check()); + } +} diff --git a/backend/src/main/java/com/music/controller/IngestController.java b/backend/src/main/java/com/music/controller/IngestController.java index 03544ea..62e5ebf 100644 --- a/backend/src/main/java/com/music/controller/IngestController.java +++ b/backend/src/main/java/com/music/controller/IngestController.java @@ -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 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 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 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 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 files = java.nio.file.Files.list(reportsDir)) { + java.util.List 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; diff --git a/backend/src/main/java/com/music/controller/LibraryHealthController.java b/backend/src/main/java/com/music/controller/LibraryHealthController.java new file mode 100644 index 0000000..187d969 --- /dev/null +++ b/backend/src/main/java/com/music/controller/LibraryHealthController.java @@ -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 健康检查控制器。 + *
    + *
  • {@code POST /api/library/health/scan} —— 只读扫描,绝不修改文件;
  • + *
  • {@code POST /api/library/health/repair} —— 修复动作,需请求体 {@code confirm=true} 显式确认。
  • + *
+ */ +@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 scan( + @RequestParam(name = "checkDecode", required = false, defaultValue = "false") boolean checkDecode) { + return Result.success(service.scan(checkDecode)); + } + + /** + * 确认后修复。未确认(confirm=false)时仅演练,不修改任何文件。 + */ + @PostMapping("/repair") + public Result repair(@RequestBody(required = false) LibraryHealthRepairRequest request) { + if (request == null) request = new LibraryHealthRepairRequest(); + return Result.success(service.repair(request)); + } +} diff --git a/backend/src/main/java/com/music/dto/DependencyStatus.java b/backend/src/main/java/com/music/dto/DependencyStatus.java new file mode 100644 index 0000000..8d6bf5b --- /dev/null +++ b/backend/src/main/java/com/music/dto/DependencyStatus.java @@ -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; + } +} diff --git a/backend/src/main/java/com/music/dto/IngestStatusResponse.java b/backend/src/main/java/com/music/dto/IngestStatusResponse.java index bee2bfa..55b79ef 100644 --- a/backend/src/main/java/com/music/dto/IngestStatusResponse.java +++ b/backend/src/main/java/com/music/dto/IngestStatusResponse.java @@ -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; } diff --git a/backend/src/main/java/com/music/dto/LibraryHealthRepairRequest.java b/backend/src/main/java/com/music/dto/LibraryHealthRepairRequest.java new file mode 100644 index 0000000..5965312 --- /dev/null +++ b/backend/src/main/java/com/music/dto/LibraryHealthRepairRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/music/dto/LibraryHealthReport.java b/backend/src/main/java/com/music/dto/LibraryHealthReport.java new file mode 100644 index 0000000..d7e65af --- /dev/null +++ b/backend/src/main/java/com/music/dto/LibraryHealthReport.java @@ -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 examplesMissingMetadata = new ArrayList<>(); + private List examplesMissingCover = new ArrayList<>(); + private List examplesMissingLyrics = new ArrayList<>(); + private List examplesOrphanSidecars = new ArrayList<>(); + private List examplesDuplicates = new ArrayList<>(); + private List examplesDecodeInvalid = new ArrayList<>(); + + private String message; +} diff --git a/backend/src/main/java/com/music/dto/LibraryHealthResult.java b/backend/src/main/java/com/music/dto/LibraryHealthResult.java new file mode 100644 index 0000000..49858b9 --- /dev/null +++ b/backend/src/main/java/com/music/dto/LibraryHealthResult.java @@ -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 changed = new ArrayList<>(); + + private String message; +} diff --git a/backend/src/main/java/com/music/dto/ProgressMessage.java b/backend/src/main/java/com/music/dto/ProgressMessage.java index 0c7e39e..104771e 100644 --- a/backend/src/main/java/com/music/dto/ProgressMessage.java +++ b/backend/src/main/java/com/music/dto/ProgressMessage.java @@ -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: 歌词查询/写入出错的入库文件数 } diff --git a/backend/src/main/java/com/music/service/IngestService.java b/backend/src/main/java/com/music/service/IngestService.java index f505869..d1c2492 100644 --- a/backend/src/main/java/com/music/service/IngestService.java +++ b/backend/src/main/java/com/music/service/IngestService.java @@ -93,6 +93,49 @@ public class IngestService { @Autowired(required = false) private LyricsService lyricsService; + /** 任务生命周期持久化与取消(可选注入;测试构造器不设置时为 null,行为不变)。 */ + @Autowired(required = false) + private IngestTaskStore taskStore; + + /** + * 当前 ingest 运行的歌词统计收集器;仅由 {@link #ingest} 在循环前设置、结束后清空。 + *

直接调用 {@link #processSingleFile}(测试)时为 null,此时不记录(不影响返回值/行为)。 + * ingest 由 running 锁保证同一时刻仅一个任务,故单实例字段安全。

+ */ + 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 构造器。 *

包级 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 batchIdentities = new HashSet<>(); - // 文件结果映射(用于报告) - LinkedHashMap fileOutcomes = new LinkedHashMap<>(); + // 文件结果映射(用于报告):outcome + 歌词来源/状态 + LinkedHashMap 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 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 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 entry : fileOutcomes.entrySet()) { + for (Map.Entry 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); diff --git a/backend/src/main/java/com/music/service/IngestTaskStore.java b/backend/src/main/java/com/music/service/IngestTaskStore.java new file mode 100644 index 0000000..f8c2761 --- /dev/null +++ b/backend/src/main/java/com/music/service/IngestTaskStore.java @@ -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; + +/** + * 导入任务生命周期持久化:将每个任务的状态、汇总计数与逐文件结果落盘,用于 + *

    + *
  • 重启恢复:进程重启后把仍处于 running 的任务标记为 interrupted(中断), + * 并通过 {@code /api/ingest/status} 恢复其汇总计数与逐文件结果;下一次导入只处理未完成文件 + * (已完成文件已被移出 Input)。
  • + *
  • 取消:记录取消请求,供导入循环在处理下一个文件前检查,取消只停止后续处理, + * 绝不删除已完成(已移动)的文件。
  • + *
+ * + *

有界保留:最多保留最近 {@value #MAX_TASKS} 个任务(按 updatedAt 压实,丢弃最旧), + * 每个任务最多保留最近 {@value #MAX_FILES_PER_TASK} 条逐文件记录(滚动窗口), + * 避免历史无界增长导致每次写盘二次方膨胀。

+ * + *

持久化文件默认位于工作根目录下 {@code .mangtool/ingest-tasks.json};无根目录时退化为 + * 纯内存(仍支持进程内取消与状态查询)。落盘尽力而为,失败不影响导入主流程。

+ */ +@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 files = new ArrayList<>(); + } + + private final ConfigService configService; + private final Map tasks = new ConcurrentHashMap<>(); + private final java.util.Set 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(); + } + + /** + * 记录一个源文件已处理完成,附带结构化结果与最新汇总计数。 + *

每条都立即落盘,保证进程崩溃时不丢失最近一批已处理记录;逐文件记录以滚动窗口有界保留, + * 汇总计数始终为最新值,二者共同保证重启后可经 /api/ingest/status 恢复观测数据。

+ */ + 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 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 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()); + } + } +} diff --git a/backend/src/main/java/com/music/service/LibraryHealthService.java b/backend/src/main/java/com/music/service/LibraryHealthService.java new file mode 100644 index 0000000..9d8898b --- /dev/null +++ b/backend/src/main/java/com/music/service/LibraryHealthService.java @@ -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 健康检查服务。 + *

只读扫描:统计解码有效性(可选)、必需元数据、封面/歌词齐备、孤立侧车、重复曲目, + * 绝不修改任何文件。修复:所有破坏性/写入动作都必须由 {@link LibraryHealthRepairRequest#isConfirm()} + * 显式确认,未确认时仅返回演练结果,绝不改动磁盘。

+ */ +@Service +public class LibraryHealthService { + + private static final Logger log = LoggerFactory.getLogger(LibraryHealthService.class); + + private static final Set 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> albumToAudio = new TreeMap<>(); + List allAudio = new ArrayList<>(); + try (java.util.stream.Stream 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 identityCount = new HashMap<>(); + Map 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 audioBaseNames = new HashSet<>(); + for (Path a : albumToAudio.get(album)) { + audioBaseNames.add(baseName(a.getFileName().toString()).toLowerCase(Locale.ROOT)); + } + Set lrcBaseNames = new HashSet<>(); + try (DirectoryStream 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 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 allAudio = new ArrayList<>(); + try (java.util.stream.Stream 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 dirs = new ArrayList<>(); + try (java.util.stream.Stream walk = Files.walk(libraryPath)) { + walk.filter(Files::isDirectory).forEach(dirs::add); + } catch (IOException e) { + log.warn("孤立侧车清理扫描失败: {}", e.getMessage()); + return; + } + for (Path dir : dirs) { + Set audioBase = new HashSet<>(); + List lrcs = new ArrayList<>(); + try (DirectoryStream 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 sameBasenameSidecars(Path audio) { + List result = new ArrayList<>(); + Path dir = audio.getParent(); + if (dir == null) return result; + String base = baseName(audio.getFileName().toString()).toLowerCase(Locale.ROOT); + try (DirectoryStream 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 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。 + *

jaudiotagger 无法解析(抛异常或无 tag)时,回退到 FFprobe 结构化元数据, + * 使合法但 jaudiotagger 不支持的 M4A/AAC 不被误报为缺元数据。 + * 去重身份与 ingest 一致,需包含 Disc/Track。

+ */ + 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|Title(Disc/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 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 list, String value) { + if (list.size() < EXAMPLE_CAP) list.add(value); + } +} diff --git a/backend/src/main/java/com/music/service/LyricsService.java b/backend/src/main/java/com/music/service/LyricsService.java index f80a80e..c50ad2a 100644 --- a/backend/src/main/java/com/music/service/LyricsService.java +++ b/backend/src/main/java/com/music/service/LyricsService.java @@ -27,21 +27,46 @@ public class LyricsService { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final int MAX_BYTES = 256 * 1024; + /** + * 远程歌词解析结果(供入库报告统计使用)。 + *
    + *
  • {@link #EXISTS} 目标已存在 .lrc(来自侧车/内嵌),跳过远程查询;
  • + *
  • {@link #FETCHED} 远程命中并成功写入;
  • + *
  • {@link #MISSING} 已查询但无匹配候选;
  • + *
  • {@link #FAILED} 查询/写入发生错误(不影响入库);
  • + *
  • {@link #DISABLED} 歌词功能未启用。
  • + *
+ */ + 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; } } diff --git a/backend/src/main/java/com/music/service/RuntimeDependencyService.java b/backend/src/main/java/com/music/service/RuntimeDependencyService.java new file mode 100644 index 0000000..8bcb147 --- /dev/null +++ b/backend/src/main/java/com/music/service/RuntimeDependencyService.java @@ -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 是否可用及其版本行。 + *

可执行路径通过系统属性 {@code mangtool.ffmpeg.bin} / {@code mangtool.ffprobe.bin} 覆盖, + * 与 {@link AudioValidationService} 保持一致。

+ * + *

输出由守护线程异步排空并限制大小,配合 {@link Process#waitFor(long, TimeUnit)} + * 强制超时;即使子进程持续持有 stdout 不退出,探测也会在超时后强制终止并回收,绝不永久阻塞。

+ */ +@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(); + } +} diff --git a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java index d6428a3..93fc40d 100644 --- a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java +++ b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java @@ -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 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 w = Files.walk(libDir)) { + libAudio = w.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(".mp3")).count(); + } + long inputRemaining; + try (Stream 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"); diff --git a/backend/src/test/java/com/music/service/IngestTaskStoreTest.java b/backend/src/test/java/com/music/service/IngestTaskStoreTest.java new file mode 100644 index 0000000..80de7c8 --- /dev/null +++ b/backend/src/test/java/com/music/service/IngestTaskStoreTest.java @@ -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) {} + } +} diff --git a/backend/src/test/java/com/music/service/LibraryHealthServiceTest.java b/backend/src/test/java/com/music/service/LibraryHealthServiceTest.java new file mode 100644 index 0000000..e558f04 --- /dev/null +++ b/backend/src/test/java/com/music/service/LibraryHealthServiceTest.java @@ -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} 只读扫描与确认后修复的侧车安全测试。 + *

用占位文件(音频扩展名 + .lrc/.jpg)验证基于文件名的孤立侧车判定与确认门, + * 不依赖 ffmpeg。

+ */ +class LibraryHealthServiceTest { + + private LibraryHealthService newService(Path libraryDir) { + ConfigService cfg = mock(ConfigService.class); + when(cfg.getLibraryDir()).thenReturn(libraryDir.toString()); + return new LibraryHealthService(cfg, new TraditionalFilterService()); + } + + /** 构建:Album 内含 1 音频 + 匹配侧车 + 孤立侧车 + 封面。 */ + private Path buildLibrary(Path root) throws Exception { + Path album = root.resolve("Artist").resolve("Album"); + Files.createDirectories(album); + Files.write(album.resolve("01 - Song.mp3"), new byte[]{1, 2, 3}); + Files.write(album.resolve("01 - Song.lrc"), "[00:01]hi".getBytes(StandardCharsets.UTF_8)); + Files.write(album.resolve("orphan.lrc"), "[00:01]orphan".getBytes(StandardCharsets.UTF_8)); + Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + return album; + } + + @Test + void scan_isReadOnly_detectsOrphanSidecarWithoutModifying() throws Exception { + Path root = Files.createTempDirectory("lib-health-"); + try { + Path album = buildLibrary(root); + LibraryHealthService svc = newService(root); + + LibraryHealthReport report = svc.scan(false); + + assertTrue(report.isReadOnly()); + assertEquals(1, report.getAlbums()); + assertEquals(1, report.getTracks()); + assertEquals(1, report.getOrphanSidecars(), "应检出 1 个孤立侧车"); + assertEquals(0, report.getAlbumsMissingCover(), "cover.jpg 存在,不应计缺封面"); + assertEquals(0, report.getTracksMissingLyrics(), "01 - Song.lrc 匹配,不应计缺歌词"); + assertFalse(report.isDecodeChecked(), "未启用解码校验"); + + // 只读:所有文件仍在 + assertTrue(Files.exists(album.resolve("01 - Song.mp3"))); + assertTrue(Files.exists(album.resolve("01 - Song.lrc"))); + assertTrue(Files.exists(album.resolve("orphan.lrc")), "扫描绝不删除任何文件"); + assertTrue(Files.exists(album.resolve("cover.jpg"))); + } finally { + deleteDir(root); + } + } + + @Test + void repair_withoutConfirm_isDryRunAndDeletesNothing() throws Exception { + Path root = Files.createTempDirectory("lib-health-"); + try { + Path album = buildLibrary(root); + LibraryHealthService svc = newService(root); + + LibraryHealthRepairRequest req = new LibraryHealthRepairRequest(); + req.setConfirm(false); + req.setRemoveOrphanSidecars(true); + + LibraryHealthResult res = svc.repair(req); + + assertFalse(res.isApplied(), "未确认应为演练"); + assertEquals(0, res.getOrphanSidecarsRemoved()); + assertTrue(Files.exists(album.resolve("orphan.lrc")), "演练不得删除任何文件"); + assertTrue(Files.exists(album.resolve("01 - Song.lrc"))); + } finally { + deleteDir(root); + } + } + + @Test + void repair_confirmed_removesOnlyOrphanSidecar_preservesMatchedAndAudio() throws Exception { + Path root = Files.createTempDirectory("lib-health-"); + try { + Path album = buildLibrary(root); + LibraryHealthService svc = newService(root); + + LibraryHealthRepairRequest req = new LibraryHealthRepairRequest(); + req.setConfirm(true); + req.setRemoveOrphanSidecars(true); + + LibraryHealthResult res = svc.repair(req); + + assertTrue(res.isApplied()); + assertEquals(1, res.getOrphanSidecarsRemoved(), "仅应删除 1 个孤立侧车"); + assertFalse(Files.exists(album.resolve("orphan.lrc")), "孤立侧车应被删除"); + // 侧车安全:匹配侧车与音频、封面均保留 + assertTrue(Files.exists(album.resolve("01 - Song.lrc")), "匹配侧车不得删除"); + assertTrue(Files.exists(album.resolve("01 - Song.mp3")), "音频不得删除"); + assertTrue(Files.exists(album.resolve("cover.jpg")), "封面不得删除"); + } finally { + deleteDir(root); + } + } + + @Test + void scan_reportsMissingCoverForUncoveredAlbum() throws Exception { + Path root = Files.createTempDirectory("lib-health-"); + try { + Path album = root.resolve("A").resolve("NoCover"); + Files.createDirectories(album); + Files.write(album.resolve("01 - T.mp3"), new byte[]{9}); + LibraryHealthService svc = newService(root); + + LibraryHealthReport report = svc.scan(false); + assertEquals(1, report.getAlbumsMissingCover()); + assertEquals(1, report.getTracksMissingLyrics()); + } finally { + deleteDir(root); + } + } + + // ========== codex-1-3: FFprobe 后备 + 无效音频删除同 basename 侧车(需 ffmpeg) ========== + + @org.junit.jupiter.api.Test + void scan_usesFfprobeFallback_forJaudiotaggerUnreadableM4a() throws Exception { + assumeFfmpeg(); + Path root = Files.createTempDirectory("lib-health-fb-"); + try { + Path album = root.resolve("FB Artist").resolve("FB Album"); + Files.createDirectories(album); + // MP4/AAC 内容但命名为 .aac:jaudiotagger 无法解析,FFprobe 可读完整元数据 + createMp4AsAac(album.resolve("01 - Song.aac"), "T", "A", "Al"); + // 提供封面避免与缺元数据无关的其他计数干扰(缺封面不影响本断言) + Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + assertJaudiotaggerCannotRead(album.resolve("01 - Song.aac")); + + LibraryHealthService svc = withValidation(newService(root)); + LibraryHealthReport report = svc.scan(false); + + assertEquals(1, report.getTracks()); + assertEquals(0, report.getTracksMissingMetadata(), + "FFprobe 后备可读元数据的合法 M4A 不应被误报为缺元数据"); + } finally { + deleteDir(root); + } + } + + @org.junit.jupiter.api.Test + void repair_removeDecodeInvalid_alsoDeletesSameBasenameSidecar() throws Exception { + assumeFfmpeg(); + Path root = Files.createTempDirectory("lib-health-inv-"); + try { + Path album = root.resolve("Art").resolve("Alb"); + Files.createDirectories(album); + // 损坏音频(ffprobe/ffmpeg 无法解码)+ 同 basename 侧车 + Files.write(album.resolve("bad.mp3"), "not audio".getBytes(StandardCharsets.UTF_8)); + Files.write(album.resolve("bad.lrc"), "[00:01]x".getBytes(StandardCharsets.UTF_8)); + // 另一首“有效”曲目(真实音频,解码通过)的侧车与公共封面:不得被误删 + createValidMp3(album.resolve("good.mp3")); + Files.write(album.resolve("good.lrc"), "[00:01]y".getBytes(StandardCharsets.UTF_8)); + Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + + LibraryHealthService svc = withValidation(newService(root)); + LibraryHealthRepairRequest req = new LibraryHealthRepairRequest(); + req.setConfirm(true); + req.setRemoveDecodeInvalid(true); + + LibraryHealthResult res = svc.repair(req); + + assertTrue(res.isApplied()); + assertTrue(res.getDecodeInvalidRemoved() >= 1, "损坏音频应被删除"); + assertFalse(Files.exists(album.resolve("bad.mp3")), "损坏音频应删除"); + assertFalse(Files.exists(album.resolve("bad.lrc")), "损坏音频的同名侧车应一并删除,避免遗留孤立 .lrc"); + // 侧车安全:其他曲目侧车、公共封面保留 + assertTrue(Files.exists(album.resolve("good.lrc")), "其他曲目侧车不得删除"); + assertTrue(Files.exists(album.resolve("cover.jpg")), "公共封面不得删除"); + } finally { + deleteDir(root); + } + } + + // ========== codex-2-2: 去重身份含 Disc/Track + 大小写不敏感 .lrc ========== + + @org.junit.jupiter.api.Test + void scan_sameTitleDifferentTrack_notFlaggedDuplicate() throws Exception { + assumeFfmpeg(); + Path root = Files.createTempDirectory("lib-health-dup-"); + try { + Path album = root.resolve("Art").resolve("Alb"); + Files.createDirectories(album); + Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + // 同 Title/Artist/Album 但 Track 不同 —— 合法的不同曲目(如 live/remix 同名),不应判重复 + createMp4AsAac(album.resolve("01 - Song.aac"), "Song", "Art", "Alb", 1); + createMp4AsAac(album.resolve("02 - Song.aac"), "Song", "Art", "Alb", 2); + + LibraryHealthService svc = withValidation(newService(root)); + LibraryHealthReport report = svc.scan(false); + + assertEquals(2, report.getTracks()); + assertEquals(0, report.getTracksMissingMetadata(), "FFprobe 后备应读到元数据"); + assertEquals(0, report.getDuplicateGroups(), + "同名不同 Track 的合法曲目不应被误判为重复(身份含 Disc/Track)"); + assertEquals(0, report.getDuplicateTracks()); + } finally { + deleteDir(root); + } + } + + @org.junit.jupiter.api.Test + void scan_sameTitleSameTrack_isDuplicate() throws Exception { + assumeFfmpeg(); + Path root = Files.createTempDirectory("lib-health-dup2-"); + try { + Path album = root.resolve("Art").resolve("Alb"); + Files.createDirectories(album); + Files.write(album.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + // 完全相同的身份(含相同 Track)→ 真重复 + createMp4AsAac(album.resolve("a.aac"), "Song", "Art", "Alb", 1); + createMp4AsAac(album.resolve("b.aac"), "Song", "Art", "Alb", 1); + + LibraryHealthService svc = withValidation(newService(root)); + LibraryHealthReport report = svc.scan(false); + + assertEquals(1, report.getDuplicateGroups(), "相同身份应判为一组重复"); + assertEquals(2, report.getDuplicateTracks()); + } finally { + deleteDir(root); + } + } + + @org.junit.jupiter.api.Test + void scan_uppercaseLrc_notReportedMissingLyricsNorOrphan() throws Exception { + Path root = Files.createTempDirectory("lib-health-lrc-"); + try { + Path album = root.resolve("A").resolve("B"); + Files.createDirectories(album); + // 大写 .LRC 侧车:既不应算缺歌词,也不应算孤立(与 orphan 判定保持一致) + Files.write(album.resolve("01 - Song.mp3"), new byte[]{1, 2, 3}); + Files.write(album.resolve("01 - Song.LRC"), "[00:01]hi".getBytes(StandardCharsets.UTF_8)); + + LibraryHealthService svc = newService(root); + LibraryHealthReport report = svc.scan(false); + + assertEquals(0, report.getTracksMissingLyrics(), + "大写 .LRC 应被识别为已有歌词(大小写不敏感)"); + assertEquals(0, report.getOrphanSidecars(), + "匹配到音频的 .LRC 不应被判为孤立"); + } finally { + deleteDir(root); + } + } + + // ---- ffmpeg 辅助 ---- + + private static void assumeFfmpeg() { boolean ok; + try { + Process p = new ProcessBuilder("ffmpeg", "-version").redirectErrorStream(true).start(); + ok = p.waitFor(5, java.util.concurrent.TimeUnit.SECONDS) && p.exitValue() == 0; + if (p.isAlive()) p.destroyForcibly(); + } catch (Exception e) { + ok = false; + } + org.junit.jupiter.api.Assumptions.assumeTrue(ok, "此测试需要 ffmpeg"); + } + + /** 注入真实 AudioValidationService,启用 FFprobe 后备 / 解码校验。 */ + private static LibraryHealthService withValidation(LibraryHealthService svc) throws Exception { + java.lang.reflect.Field f = LibraryHealthService.class.getDeclaredField("audioValidationService"); + f.setAccessible(true); + f.set(svc, new AudioValidationService()); + return svc; + } + + /** 生成一个真实可解码的短 MP3(有效音频,不会被解码校验判无效)。 */ + private static void createValidMp3(Path out) throws Exception { + run(new ProcessBuilder("ffmpeg", "-y", + "-f", "lavfi", "-i", "sine=frequency=440:duration=0.2", + "-c:a", "libmp3lame", out.toAbsolutePath().toString())); + } + + private static void createMp4AsAac(Path out, String title, String artist, String album) throws Exception { + createMp4AsAac(out, title, artist, album, null); + } + + private static void createMp4AsAac(Path out, String title, String artist, String album, Integer track) throws Exception { + Path cover = out.resolveSibling("." + out.getFileName() + ".cover.png"); + run(new ProcessBuilder("ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=blue:s=48x48:d=0.1", + "-frames:v", "1", cover.toAbsolutePath().toString())); + java.util.List 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) {} + } +} diff --git a/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java b/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java index fdd161e..fe98a80 100644 --- a/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java +++ b/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java @@ -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 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 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) {} + } } diff --git a/backend/src/test/java/com/music/service/RuntimeDependencyServiceTest.java b/backend/src/test/java/com/music/service/RuntimeDependencyServiceTest.java new file mode 100644 index 0000000..6c77463 --- /dev/null +++ b/backend/src/test/java/com/music/service/RuntimeDependencyServiceTest.java @@ -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) {} + } +} diff --git a/docker/README.md b/docker/README.md index c0a8157..4e13898 100644 --- a/docker/README.md +++ b/docker/README.md @@ -99,6 +99,47 @@ docker compose up -d --build 容器内置 FFmpeg 和 FFprobe(FFmpeg 套件自带),一键导入过程中的格式转换(WAV/APE/AIFF/WV/TTA → FLAC)和音频完整性验证自动使用容器内的工具。 +## 运维与可观测性 + +### 依赖自检 + +部署后可用依赖自检确认外部工具就绪: + +```bash +docker compose exec mangtool wget -q -O- http://localhost:8080/api/health/dependencies +``` + +返回 `ffmpegAvailable`/`ffprobeAvailable` 及版本行;任一为 false 时导入会在预检阶段直接报错。 + +### 导入报告与歌词统计 + +每次导入在工作根目录 `Rejected/Reports/` 下写入结构化 JSON 报告(逐文件结果 + 歌词 有/无/失败 统计)。前端「一键导入」页可实时查看歌词统计并「下载导入报告」。歌词的有无与失败**绝不影响音频入库**。 + +### 任务取消与重启恢复 + +- 取消:`POST /api/ingest/cancel`,只停止后续文件处理,已入库文件保留,未处理文件留在 `Input/`。 +- 重启恢复:任务状态持久化于 `工作根目录/.mangtool/ingest-tasks.json`;容器重启后仍在运行的任务标记为 `interrupted`,重新导入只处理未完成文件(已完成文件已移出 `Input/`,不会重复搬运)。 + +### Library 健康检查(只读) + +```bash +# 只读扫描(不修改任何文件) +docker compose exec mangtool wget -q -O- --post-data='' http://localhost:8080/api/library/health/scan +``` + +修复动作(歌词回填、删除孤立侧车等)必须在请求体显式 `confirm=true` 才会执行,否则仅演练。 + +### 备份与恢复 + +导入为「移动」语义,批量导入前建议对宿主机工作目录做快照: + +```bash +# 在挂载的宿主机数据目录(默认 docker/data)执行 +tar czf mangtool-backup-$(date +%Y%m%d).tgz -C /path/to/MusicWork Library Rejected .mangtool +``` + +`Library/` 为必备成品数据;`Rejected/Reports/` 便于追溯;`.mangtool/` 为任务状态。恢复时解压回原数据目录即可,Navidrome 直接扫描 `Library/`,无需外部数据库。 + ## 常见问题 ### 构建失败 diff --git a/frontend/src/api/ingest.ts b/frontend/src/api/ingest.ts index 616c46d..9831047 100644 --- a/frontend/src/api/ingest.ts +++ b/frontend/src/api/ingest.ts @@ -16,6 +16,9 @@ export interface IngestStatusResponse { unreadableFiles: number; conversionFailedFiles: number; otherRejectedFiles: number; + lyricsFound: number; + lyricsMissing: number; + lyricsFailed: number; message: string; } @@ -26,6 +29,20 @@ export function startIngest(): Promise { return request.post('/api/ingest/start', {}); } +/** + * 请求取消当前导入任务(已完成文件不会被删除) + */ +export function cancelIngest(): Promise { + return request.post('/api/ingest/cancel', {}); +} + +/** + * 获取指定任务的结构化导入报告 JSON 文本(taskId 传 "latest" 取最近一份) + */ +export function getIngestReport(taskId: string): Promise { + return request.get(`/api/ingest/report/${encodeURIComponent(taskId)}`); +} + /** * 获取当前/最近一次导入任务状态 */ diff --git a/frontend/src/components/IngestTab.vue b/frontend/src/components/IngestTab.vue index d4b1694..caa5536 100644 --- a/frontend/src/components/IngestTab.vue +++ b/frontend/src/components/IngestTab.vue @@ -128,6 +128,29 @@ 正在导入分析… ({{ percentage }}%) + + + +