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