Template
Add one-click Navidrome ingestion workflow
This commit is contained in:
@@ -1,3 +1,49 @@
|
|||||||
# MyTool
|
# MangTool —— 音乐文件一键导入工具
|
||||||
|
|
||||||
我的工具箱
|
面向 Navidrome 音乐服务器的音频文件智能导入管线。
|
||||||
|
|
||||||
|
## 一键导入工作流
|
||||||
|
|
||||||
|
将待处理的音频文件放入 `Input/` 目录,点击「开始一键导入」即可自动完成:
|
||||||
|
|
||||||
|
1. **扫描** — 递归扫描 Input 目录下的所有音频文件
|
||||||
|
2. **校验** — 检查 Title/Artist/Album 元数据是否完整,缺失文件移入 Rejected/MissingMetadata
|
||||||
|
3. **繁简转换** — 将繁体中文标签自动转为简体
|
||||||
|
4. **格式转换** — 将 WAV/APE/AIFF/WV/TTA 转为 FLAC(需 FFmpeg),失败文件移入 Rejected/ConversionFailed
|
||||||
|
5. **去重** — 基于 Artist+Album+Disc+Track+Title 归一化身份标识对比 Library 已有文件,重复移入 Rejected/Duplicate
|
||||||
|
6. **入库** — 有效文件按 `Artist/Album (year)/NN - Title.ext` 布局移入 Library
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
BasePath/
|
||||||
|
├── Input/ ← 放入待处理的音频文件
|
||||||
|
├── Library/ ← 整理后的 Navidrome 兼容曲库
|
||||||
|
└── Rejected/ ← 被拒绝的文件按原因分类
|
||||||
|
├── MissingMetadata/
|
||||||
|
├── Duplicate/
|
||||||
|
├── ConversionFailed/
|
||||||
|
├── Unreadable/
|
||||||
|
└── Other/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建与运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端
|
||||||
|
cd backend && mvn clean package -DskipTests && mvn spring-boot:run
|
||||||
|
|
||||||
|
# 前端(开发模式)
|
||||||
|
cd frontend && npm ci && npm run dev
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
cd docker && docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 后端:Spring Boot 2.7 + Java 8 + Maven
|
||||||
|
- 前端:Vue 3 + Vite + TypeScript + Element Plus
|
||||||
|
- 音频元数据:jaudiotagger
|
||||||
|
- 繁简转换:opencc4j
|
||||||
|
- 格式转换:FFmpeg
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.music.controller;
|
||||||
|
|
||||||
|
import com.music.common.Result;
|
||||||
|
import com.music.dto.IngestRequest;
|
||||||
|
import com.music.dto.IngestStatusResponse;
|
||||||
|
import com.music.dto.ProgressMessage;
|
||||||
|
import com.music.exception.BusinessException;
|
||||||
|
import com.music.service.ConfigService;
|
||||||
|
import com.music.service.IngestService;
|
||||||
|
import com.music.service.ProgressStore;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键导入控制器
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ingest")
|
||||||
|
@Validated
|
||||||
|
public class IngestController {
|
||||||
|
|
||||||
|
private final IngestService ingestService;
|
||||||
|
private final ProgressStore progressStore;
|
||||||
|
private final ConfigService configService;
|
||||||
|
|
||||||
|
/** 防止并发执行的生命周期锁,由 IngestService 在 finally 中释放 */
|
||||||
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
/** 当前/最近一次任务的 taskId,用于 GET /api/ingest/status 恢复 */
|
||||||
|
private volatile String currentTaskId;
|
||||||
|
|
||||||
|
public IngestController(IngestService ingestService,
|
||||||
|
ProgressStore progressStore,
|
||||||
|
ConfigService configService) {
|
||||||
|
this.ingestService = ingestService;
|
||||||
|
this.progressStore = progressStore;
|
||||||
|
this.configService = configService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动一键导入任务。
|
||||||
|
* 并发锁在 IngestService 的 finally 块中自动释放。
|
||||||
|
*/
|
||||||
|
@PostMapping("/start")
|
||||||
|
public Result<StartResponse> start(@RequestBody(required = false) IngestRequest request) {
|
||||||
|
if (configService.getBasePath() == null) {
|
||||||
|
throw new BusinessException(400, "请先配置工作根目录");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!running.compareAndSet(false, true)) {
|
||||||
|
throw new BusinessException(409, "导入任务已在运行中,请等待完成");
|
||||||
|
}
|
||||||
|
|
||||||
|
String taskId = UUID.randomUUID().toString();
|
||||||
|
currentTaskId = taskId;
|
||||||
|
ingestService.ingest(taskId, running);
|
||||||
|
return Result.success(new StartResponse(taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前/最近一次导入任务的状态(页面刷新恢复用)。
|
||||||
|
* 优先返回正在运行的任务,否则返回最近完成的任务。
|
||||||
|
*/
|
||||||
|
@GetMapping("/status")
|
||||||
|
public Result<IngestStatusResponse> statusCurrent() {
|
||||||
|
String tid = currentTaskId;
|
||||||
|
if (tid == null) {
|
||||||
|
return Result.success(new IngestStatusResponse(null, false, false,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, "暂无任务记录"));
|
||||||
|
}
|
||||||
|
return statusById(tid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定 taskId 的任务状态
|
||||||
|
*/
|
||||||
|
@GetMapping("/status/{taskId}")
|
||||||
|
public Result<IngestStatusResponse> statusById(@PathVariable("taskId") String taskId) {
|
||||||
|
ProgressMessage pm = progressStore.get(taskId);
|
||||||
|
if (pm == null) {
|
||||||
|
return Result.success(new IngestStatusResponse(taskId, running.get(), false,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, "未找到任务或已过期"));
|
||||||
|
}
|
||||||
|
|
||||||
|
IngestStatusResponse resp = new IngestStatusResponse(
|
||||||
|
taskId, running.get(), pm.isCompleted(),
|
||||||
|
pm.getTotal(), pm.getProcessed(),
|
||||||
|
optInt(pm.getIngestedFiles()),
|
||||||
|
optInt(pm.getDuplicateFiles()),
|
||||||
|
optInt(pm.getMissingMetadataFiles()),
|
||||||
|
optInt(pm.getUnreadableFiles()),
|
||||||
|
optInt(pm.getConversionFailedFiles()),
|
||||||
|
optInt(pm.getOtherRejectedFiles()),
|
||||||
|
pm.getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pm.isCompleted()) {
|
||||||
|
running.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int optInt(Integer val) {
|
||||||
|
return val == null ? 0 : val;
|
||||||
|
}
|
||||||
|
|
||||||
|
static class StartResponse {
|
||||||
|
private String taskId;
|
||||||
|
|
||||||
|
public StartResponse(String taskId) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTaskId() {
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskId(String taskId) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import lombok.NoArgsConstructor;
|
|||||||
public class ConfigResponse {
|
public class ConfigResponse {
|
||||||
private String basePath;
|
private String basePath;
|
||||||
private String inputDir;
|
private String inputDir;
|
||||||
|
private String libraryDir;
|
||||||
|
private String rejectedDir;
|
||||||
|
// 保留原有字段以保证向后兼容
|
||||||
private String aggregatedDir;
|
private String aggregatedDir;
|
||||||
private String formatIssuesDir;
|
private String formatIssuesDir;
|
||||||
private String duplicatesDir;
|
private String duplicatesDir;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.music.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键导入请求(使用已保存的配置路径,无需额外参数)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class IngestRequest {
|
||||||
|
// 无参请求,所有路径从已保存配置读取
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.music.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入任务结果汇总
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class IngestStatusResponse {
|
||||||
|
private String taskId;
|
||||||
|
private boolean running;
|
||||||
|
private boolean completed;
|
||||||
|
private int total;
|
||||||
|
private int processed;
|
||||||
|
private int ingestedFiles;
|
||||||
|
private int duplicateFiles;
|
||||||
|
private int missingMetadataFiles;
|
||||||
|
private int unreadableFiles;
|
||||||
|
private int conversionFailedFiles;
|
||||||
|
private int otherRejectedFiles;
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
@@ -29,4 +29,12 @@ public class ProgressMessage {
|
|||||||
private Integer tracksMerged; // merge: 已合并曲目数
|
private Integer tracksMerged; // merge: 已合并曲目数
|
||||||
private Integer upgradedFiles; // merge: 升级替换文件数
|
private Integer upgradedFiles; // merge: 升级替换文件数
|
||||||
private Integer skippedFiles; // merge: 跳过文件数
|
private Integer skippedFiles; // merge: 跳过文件数
|
||||||
|
|
||||||
|
// ingest 任务专属字段
|
||||||
|
private Integer ingestedFiles; // ingest: 成功入库文件数
|
||||||
|
private Integer duplicateFiles; // ingest: 跳过重复文件数
|
||||||
|
private Integer missingMetadataFiles; // ingest: 缺少元数据文件数
|
||||||
|
private Integer unreadableFiles; // ingest: 无法读取文件数
|
||||||
|
private Integer conversionFailedFiles; // ingest: 转码失败文件数
|
||||||
|
private Integer otherRejectedFiles; // ingest: 其他原因拒绝文件数
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public class ConfigService {
|
|||||||
|
|
||||||
// 子目录名称常量
|
// 子目录名称常量
|
||||||
private static final String INPUT_DIR = "Input";
|
private static final String INPUT_DIR = "Input";
|
||||||
|
private static final String LIBRARY_DIR = "Library";
|
||||||
|
private static final String REJECTED_DIR = "Rejected";
|
||||||
|
// 保留原有子目录以保持向后兼容
|
||||||
private static final String STAGING_AGGREGATED = "Staging_Aggregated";
|
private static final String STAGING_AGGREGATED = "Staging_Aggregated";
|
||||||
private static final String STAGING_FORMAT_ISSUES = "Staging_Format_Issues";
|
private static final String STAGING_FORMAT_ISSUES = "Staging_Format_Issues";
|
||||||
private static final String STAGING_DUPLICATES = "Staging_Duplicates";
|
private static final String STAGING_DUPLICATES = "Staging_Duplicates";
|
||||||
@@ -114,6 +117,9 @@ public class ConfigService {
|
|||||||
ConfigResponse response = new ConfigResponse();
|
ConfigResponse response = new ConfigResponse();
|
||||||
response.setBasePath(basePath);
|
response.setBasePath(basePath);
|
||||||
response.setInputDir(PathUtils.joinPath(basePath, INPUT_DIR));
|
response.setInputDir(PathUtils.joinPath(basePath, INPUT_DIR));
|
||||||
|
response.setLibraryDir(PathUtils.joinPath(basePath, LIBRARY_DIR));
|
||||||
|
response.setRejectedDir(PathUtils.joinPath(basePath, REJECTED_DIR));
|
||||||
|
// 保留原有字段
|
||||||
response.setAggregatedDir(PathUtils.joinPath(basePath, STAGING_AGGREGATED));
|
response.setAggregatedDir(PathUtils.joinPath(basePath, STAGING_AGGREGATED));
|
||||||
response.setFormatIssuesDir(PathUtils.joinPath(basePath, STAGING_FORMAT_ISSUES));
|
response.setFormatIssuesDir(PathUtils.joinPath(basePath, STAGING_FORMAT_ISSUES));
|
||||||
response.setDuplicatesDir(PathUtils.joinPath(basePath, STAGING_DUPLICATES));
|
response.setDuplicatesDir(PathUtils.joinPath(basePath, STAGING_DUPLICATES));
|
||||||
@@ -130,6 +136,8 @@ public class ConfigService {
|
|||||||
private void createSubDirectories(String basePath) {
|
private void createSubDirectories(String basePath) {
|
||||||
String[] subDirs = {
|
String[] subDirs = {
|
||||||
INPUT_DIR,
|
INPUT_DIR,
|
||||||
|
LIBRARY_DIR,
|
||||||
|
REJECTED_DIR,
|
||||||
STAGING_AGGREGATED,
|
STAGING_AGGREGATED,
|
||||||
STAGING_FORMAT_ISSUES,
|
STAGING_FORMAT_ISSUES,
|
||||||
STAGING_DUPLICATES,
|
STAGING_DUPLICATES,
|
||||||
@@ -158,6 +166,20 @@ public class ConfigService {
|
|||||||
return getDerivedPath(INPUT_DIR);
|
return getDerivedPath(INPUT_DIR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Library目录路径
|
||||||
|
*/
|
||||||
|
public String getLibraryDir() {
|
||||||
|
return getDerivedPath(LIBRARY_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Rejected目录路径
|
||||||
|
*/
|
||||||
|
public String getRejectedDir() {
|
||||||
|
return getDerivedPath(REJECTED_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取Staging_Aggregated目录路径
|
* 获取Staging_Aggregated目录路径
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,959 @@
|
|||||||
|
package com.music.service;
|
||||||
|
|
||||||
|
import com.music.common.FileTransferUtils;
|
||||||
|
import com.music.dto.ProgressMessage;
|
||||||
|
import org.jaudiotagger.audio.AudioFile;
|
||||||
|
import org.jaudiotagger.audio.AudioFileIO;
|
||||||
|
import org.jaudiotagger.tag.FieldKey;
|
||||||
|
import org.jaudiotagger.tag.Tag;
|
||||||
|
import org.jaudiotagger.tag.images.Artwork;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.security.DigestInputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键导入服务 —— 将 Input 目录中的音频文件扫描、校验、繁简转换、格式转换、
|
||||||
|
* 去重后直接纳入 Navidrome 兼容的 Library 目录布局。
|
||||||
|
*
|
||||||
|
* <p>进度字段语义(type = "ingest"):
|
||||||
|
* <ul>
|
||||||
|
* <li>total:扫描到的音频文件总数</li>
|
||||||
|
* <li>processed:已处理文件数</li>
|
||||||
|
* <li>success / ingestedFiles:成功入库文件数</li>
|
||||||
|
* <li>duplicateFiles:跳过重复文件数</li>
|
||||||
|
* <li>missingMetadataFiles:缺少 Title/Artist/Album 的文件数</li>
|
||||||
|
* <li>unreadableFiles:无法读取元数据的文件数</li>
|
||||||
|
* <li>conversionFailedFiles:格式转换失败文件数</li>
|
||||||
|
* <li>failed / otherRejectedFiles:其他原因拒绝文件数</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class IngestService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(IngestService.class);
|
||||||
|
|
||||||
|
/** 需要转换为 FLAC 的无损格式 */
|
||||||
|
private static final Set<String> LOSSLESS_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||||
|
"wav", "ape", "aiff", "aif", "wv", "tta"
|
||||||
|
));
|
||||||
|
|
||||||
|
/** 可直接保留的格式 */
|
||||||
|
private static final Set<String> COMPATIBLE_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||||
|
"flac", "mp3", "m4a", "aac", "ogg", "opus", "wma"
|
||||||
|
));
|
||||||
|
|
||||||
|
/** 所有支持的音频格式 */
|
||||||
|
private static final Set<String> ALL_AUDIO_EXTENSIONS = new HashSet<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
ALL_AUDIO_EXTENSIONS.addAll(LOSSLESS_EXTENSIONS);
|
||||||
|
ALL_AUDIO_EXTENSIONS.addAll(COMPATIBLE_EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 需要繁简转换的标签字段 */
|
||||||
|
private static final FieldKey[] TEXT_FIELDS = {
|
||||||
|
FieldKey.TITLE, FieldKey.ARTIST, FieldKey.ALBUM, FieldKey.ALBUM_ARTIST
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final int FFMPEG_COMPRESSION_LEVEL = 5;
|
||||||
|
private static final int FFMPEG_CHECK_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final int FFMPEG_CONVERT_TIMEOUT_SECONDS = 600;
|
||||||
|
private static final String FFMPEG_BIN_PROPERTY = "mangtool.ffmpeg.bin";
|
||||||
|
private static final String REPORT_SUBDIR = "Reports";
|
||||||
|
|
||||||
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
private final ProgressStore progressStore;
|
||||||
|
private final TraditionalFilterService traditionalFilterService;
|
||||||
|
private final ConfigService configService;
|
||||||
|
|
||||||
|
public IngestService(SimpMessagingTemplate messagingTemplate,
|
||||||
|
ProgressStore progressStore,
|
||||||
|
TraditionalFilterService traditionalFilterService,
|
||||||
|
ConfigService configService) {
|
||||||
|
this.messagingTemplate = messagingTemplate;
|
||||||
|
this.progressStore = progressStore;
|
||||||
|
this.traditionalFilterService = traditionalFilterService;
|
||||||
|
this.configService = configService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步执行一键导入任务。
|
||||||
|
* <p><b>并发锁</b>:{@code runningLock} 由控制器创建,本方法在 finally 块中释放,
|
||||||
|
* 保证无论正常完成、异常、预检失败都能解锁。
|
||||||
|
*
|
||||||
|
* @param taskId 唯一任务标识
|
||||||
|
* @param runningLock 由控制器传入的 AtomicBoolean,任务结束时置为 false
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void ingest(String taskId, AtomicBoolean runningLock) {
|
||||||
|
try {
|
||||||
|
String inputDir = configService.getInputDir();
|
||||||
|
String libraryDir = configService.getLibraryDir();
|
||||||
|
String rejectedDir = configService.getRejectedDir();
|
||||||
|
|
||||||
|
if (inputDir == null || libraryDir == null || rejectedDir == null) {
|
||||||
|
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
null, "请先配置工作根目录", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Path inputPath = Paths.get(inputDir);
|
||||||
|
Path libraryPath = Paths.get(libraryDir);
|
||||||
|
Path rejectedPath = Paths.get(rejectedDir);
|
||||||
|
|
||||||
|
// 路径合法性校验:三者必须互不相同且不互相包含
|
||||||
|
String pathError = validatePaths(inputPath, libraryPath, rejectedPath);
|
||||||
|
if (pathError != null) {
|
||||||
|
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
null, pathError, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
Files.createDirectories(inputPath);
|
||||||
|
Files.createDirectories(libraryPath);
|
||||||
|
Files.createDirectories(rejectedPath);
|
||||||
|
|
||||||
|
// 扫描 Input 目录中的音频文件
|
||||||
|
List<Path> audioFiles = new ArrayList<>();
|
||||||
|
Files.walkFileTree(inputPath, new SimpleFileVisitor<Path>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||||
|
if (isAudioFile(file)) {
|
||||||
|
audioFiles.add(file);
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
int total = audioFiles.size();
|
||||||
|
if (total == 0) {
|
||||||
|
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
null, "Input 目录中未发现音频文件", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预检查 FFmpeg(如果需要转换)
|
||||||
|
boolean mayNeedFfmpeg = audioFiles.stream().anyMatch(f -> isLosslessFormat(f));
|
||||||
|
if (mayNeedFfmpeg) {
|
||||||
|
String ffmpegError = checkFfmpegAvailable();
|
||||||
|
if (ffmpegError != null) {
|
||||||
|
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
null, ffmpegError, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描 Library 中已有的文件作为重复检测参考(t2s 归一化)
|
||||||
|
Set<IdentityKey> libraryIdentities = scanLibraryIdentities(libraryPath);
|
||||||
|
log.info("Library 中已有 {} 个曲目用于重复检测", libraryIdentities.size());
|
||||||
|
|
||||||
|
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
null, "扫描完成,开始导入...", false);
|
||||||
|
|
||||||
|
// 处理每个文件
|
||||||
|
AtomicInteger ingested = new AtomicInteger(0);
|
||||||
|
AtomicInteger duplicates = new AtomicInteger(0);
|
||||||
|
AtomicInteger missingMeta = new AtomicInteger(0);
|
||||||
|
AtomicInteger unreadable = new AtomicInteger(0);
|
||||||
|
AtomicInteger convFailed = new AtomicInteger(0);
|
||||||
|
AtomicInteger otherRejected = new AtomicInteger(0);
|
||||||
|
AtomicInteger processed = new AtomicInteger(0);
|
||||||
|
|
||||||
|
// 批内重复检测集合
|
||||||
|
Set<IdentityKey> batchIdentities = new HashSet<>();
|
||||||
|
|
||||||
|
// 文件结果映射(用于报告)
|
||||||
|
LinkedHashMap<String, String> fileOutcomes = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (Path srcFile : audioFiles) {
|
||||||
|
String fileName = srcFile.getFileName().toString();
|
||||||
|
// 使用相对于 Input 的路径作为唯一跟踪键(防止不同子目录中的同名文件互相覆盖)
|
||||||
|
String relativeKey = inputPath.relativize(srcFile).toString();
|
||||||
|
String outcome = "unknown";
|
||||||
|
try {
|
||||||
|
outcome = processSingleFile(srcFile, libraryPath, rejectedPath,
|
||||||
|
libraryIdentities, batchIdentities,
|
||||||
|
ingested, duplicates, missingMeta, unreadable,
|
||||||
|
convFailed, otherRejected);
|
||||||
|
} catch (Exception e) {
|
||||||
|
otherRejected.incrementAndGet();
|
||||||
|
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||||
|
outcome = "rejected:exception";
|
||||||
|
log.warn("处理文件异常: {} - {}", relativeKey, e.getMessage());
|
||||||
|
}
|
||||||
|
fileOutcomes.put(relativeKey, outcome);
|
||||||
|
|
||||||
|
int p = processed.incrementAndGet();
|
||||||
|
sendProgress(taskId, total, p, ingested.get(), duplicates.get(),
|
||||||
|
missingMeta.get(), unreadable.get(), convFailed.get(),
|
||||||
|
otherRejected.get(), relativeKey,
|
||||||
|
String.format("已处理 (%d/%d): %s", p, total, relativeKey),
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入结构化报告
|
||||||
|
writeReport(taskId, fileOutcomes, rejectedPath, ingested.get(), duplicates.get(),
|
||||||
|
missingMeta.get(), unreadable.get(), convFailed.get(), otherRejected.get());
|
||||||
|
|
||||||
|
// 完成
|
||||||
|
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
|
||||||
|
missingMeta.get(), unreadable.get(), convFailed.get(),
|
||||||
|
otherRejected.get(), null,
|
||||||
|
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 不可读: %d, 转码失败: %d, 其他: %d",
|
||||||
|
ingested.get(), duplicates.get(), missingMeta.get(),
|
||||||
|
unreadable.get(), convFailed.get(), otherRejected.get()),
|
||||||
|
true);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("导入任务执行失败", e);
|
||||||
|
// 发送 terminal 进度消息,确保前端停止轮询并显示错误
|
||||||
|
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
null, "任务内部错误: " + e.getMessage(), true);
|
||||||
|
} finally {
|
||||||
|
if (runningLock != null) {
|
||||||
|
runningLock.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 路径校验 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 Input / Library / Rejected 路径:
|
||||||
|
* 三者必须互不相同且不互相嵌套(不能互为子目录)。
|
||||||
|
*
|
||||||
|
* @return 错误描述(null 表示通过)
|
||||||
|
*/
|
||||||
|
static String validatePaths(Path input, Path library, Path rejected) {
|
||||||
|
Path absInput = input.toAbsolutePath().normalize();
|
||||||
|
Path absLib = library.toAbsolutePath().normalize();
|
||||||
|
Path absRej = rejected.toAbsolutePath().normalize();
|
||||||
|
|
||||||
|
if (absInput.equals(absLib)) return "Input 和 Library 不能是同一目录";
|
||||||
|
if (absInput.equals(absRej)) return "Input 和 Rejected 不能是同一目录";
|
||||||
|
if (absLib.equals(absRej)) return "Library 和 Rejected 不能是同一目录";
|
||||||
|
|
||||||
|
// 检查嵌套关系
|
||||||
|
if (absLib.startsWith(absInput)) return "Library 不能位于 Input 目录内";
|
||||||
|
if (absRej.startsWith(absInput)) return "Rejected 不能位于 Input 目录内";
|
||||||
|
if (absInput.startsWith(absLib)) return "Input 不能位于 Library 目录内";
|
||||||
|
if (absInput.startsWith(absRej)) return "Input 不能位于 Rejected 目录内";
|
||||||
|
if (absLib.startsWith(absRej)) return "Library 不能位于 Rejected 目录内";
|
||||||
|
if (absRej.startsWith(absLib)) return "Rejected 不能位于 Library 目录内";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 核心处理 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理单个文件:读取元数据 → 校验 → 繁简转换 → 持久化标签 → MD5 →
|
||||||
|
* 格式转换 → 去重 → 入库。同时处理关联 LRC 文件、嵌入式歌词和封面。
|
||||||
|
*
|
||||||
|
* @return 文件结果描述(用于报告)
|
||||||
|
*/
|
||||||
|
private String processSingleFile(Path srcFile, Path libraryPath, Path rejectedPath,
|
||||||
|
Set<IdentityKey> libraryIdentities,
|
||||||
|
Set<IdentityKey> batchIdentities,
|
||||||
|
AtomicInteger ingested, AtomicInteger duplicates,
|
||||||
|
AtomicInteger missingMeta, AtomicInteger unreadable,
|
||||||
|
AtomicInteger convFailed, AtomicInteger otherRejected) throws Exception {
|
||||||
|
|
||||||
|
String fileName = srcFile.getFileName().toString();
|
||||||
|
String baseName = getBaseName(fileName);
|
||||||
|
|
||||||
|
// 1. 读取元数据
|
||||||
|
AudioFile audioFile;
|
||||||
|
Tag tag;
|
||||||
|
try {
|
||||||
|
audioFile = AudioFileIO.read(srcFile.toFile());
|
||||||
|
tag = audioFile.getTag();
|
||||||
|
if (tag == null) {
|
||||||
|
unreadable.incrementAndGet();
|
||||||
|
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||||
|
return "rejected:unreadable";
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
unreadable.incrementAndGet();
|
||||||
|
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||||
|
log.warn("无法读取文件元数据: {} - {}", fileName, e.getMessage());
|
||||||
|
return "rejected:unreadable";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读取关键字段
|
||||||
|
String title = trim(tag.getFirst(FieldKey.TITLE));
|
||||||
|
String artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||||
|
String album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||||
|
|
||||||
|
// 3. 校验 Title/Artist/Album 非空
|
||||||
|
if (title.isEmpty() || artist.isEmpty() || album.isEmpty()) {
|
||||||
|
missingMeta.incrementAndGet();
|
||||||
|
moveToRejected(srcFile, rejectedPath, "MissingMetadata", fileName);
|
||||||
|
return "rejected:missing-metadata";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 繁简转换文本标签
|
||||||
|
boolean tagsModified = false;
|
||||||
|
for (FieldKey key : TEXT_FIELDS) {
|
||||||
|
String value = trim(tag.getFirst(key));
|
||||||
|
if (!value.isEmpty()) {
|
||||||
|
String converted = traditionalFilterService.toSimplified(value);
|
||||||
|
if (!converted.equals(value)) {
|
||||||
|
tag.setField(key, converted);
|
||||||
|
tagsModified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对 albumArtist 也进行繁简转换
|
||||||
|
String albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||||
|
if (!albumArtist.isEmpty()) {
|
||||||
|
String converted = traditionalFilterService.toSimplified(albumArtist);
|
||||||
|
if (!converted.equals(albumArtist)) {
|
||||||
|
tag.setField(FieldKey.ALBUM_ARTIST, converted);
|
||||||
|
tagsModified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 将简化后的标签写回源文件
|
||||||
|
if (tagsModified) {
|
||||||
|
try {
|
||||||
|
audioFile.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
otherRejected.incrementAndGet();
|
||||||
|
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||||
|
log.warn("标签写入失败且无法恢复: {} - {}", fileName, e.getMessage());
|
||||||
|
return "rejected:tag-write-failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 提取其他可选字段
|
||||||
|
String yearStr = trim(tag.getFirst(FieldKey.YEAR));
|
||||||
|
String trackRaw = trim(tag.getFirst(FieldKey.TRACK));
|
||||||
|
String discRaw = trim(tag.getFirst(FieldKey.DISC_NO));
|
||||||
|
|
||||||
|
int trackNum = parseInt(trackRaw, 0);
|
||||||
|
int discNum = parseInt(discRaw, 0);
|
||||||
|
|
||||||
|
// 7. 计算 MD5 用于去重兜底
|
||||||
|
String fileMd5 = computeMd5(srcFile);
|
||||||
|
|
||||||
|
// 8. 构建归一化身份标识
|
||||||
|
IdentityKey identity = new IdentityKey(
|
||||||
|
traditionalFilterService.toSimplified(artist.trim().toLowerCase()),
|
||||||
|
traditionalFilterService.toSimplified(album.trim().toLowerCase()),
|
||||||
|
discNum, trackNum,
|
||||||
|
traditionalFilterService.toSimplified(title.trim().toLowerCase()),
|
||||||
|
fileMd5
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9. 去重检测
|
||||||
|
String duplicateReason = checkDuplicate(identity, libraryIdentities, batchIdentities);
|
||||||
|
if (duplicateReason != null) {
|
||||||
|
duplicates.incrementAndGet();
|
||||||
|
moveToRejected(srcFile, rejectedPath, "Duplicate", fileName);
|
||||||
|
return "rejected:duplicate";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 格式转换(如需要)
|
||||||
|
Path effectiveFile = srcFile;
|
||||||
|
boolean needsConversion = isLosslessFormat(srcFile);
|
||||||
|
if (needsConversion) {
|
||||||
|
try {
|
||||||
|
Path flacFile = convertToFlac(srcFile, srcFile.getParent());
|
||||||
|
effectiveFile = flacFile;
|
||||||
|
} catch (Exception e) {
|
||||||
|
convFailed.incrementAndGet();
|
||||||
|
moveToRejected(srcFile, rejectedPath, "ConversionFailed", fileName);
|
||||||
|
log.warn("转码失败: {} - {}", fileName, e.getMessage());
|
||||||
|
return "rejected:conversion-failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 确定最终格式与文件名
|
||||||
|
String ext = needsConversion ? "flac" : getExtension(fileName);
|
||||||
|
if (ext == null) ext = "flac";
|
||||||
|
|
||||||
|
// 12. 构建目标路径
|
||||||
|
String effectiveArtist = !albumArtist.isEmpty()
|
||||||
|
? traditionalFilterService.toSimplified(albumArtist.trim())
|
||||||
|
: traditionalFilterService.toSimplified(artist.trim());
|
||||||
|
String effectiveAlbum = traditionalFilterService.toSimplified(album.trim());
|
||||||
|
String year = extractYear(yearStr);
|
||||||
|
String trackStr = trackNum > 0 ? String.format("%02d", trackNum) : "01";
|
||||||
|
|
||||||
|
String albumDirName = year.isEmpty() ? effectiveAlbum : effectiveAlbum + " (" + year + ")";
|
||||||
|
String artistDir = sanitizePathComponent(effectiveArtist);
|
||||||
|
String albumDir = sanitizePathComponent(albumDirName);
|
||||||
|
Path targetDir = libraryPath.resolve(artistDir).resolve(albumDir);
|
||||||
|
Files.createDirectories(targetDir);
|
||||||
|
|
||||||
|
String safeTitle = sanitizePathComponent(title);
|
||||||
|
String destFileName = trackStr + " - " + safeTitle + "." + ext;
|
||||||
|
Path targetFile = resolveUniqueFile(targetDir, destFileName);
|
||||||
|
|
||||||
|
// 13. 移动/复制到目标位置
|
||||||
|
try {
|
||||||
|
FileTransferUtils.moveWithFallback(effectiveFile, targetFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
otherRejected.incrementAndGet();
|
||||||
|
// 如果经过转换,删除临时 FLAC 并隔离源文件
|
||||||
|
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||||
|
deleteIfExists(effectiveFile);
|
||||||
|
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||||
|
} else {
|
||||||
|
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||||
|
}
|
||||||
|
log.warn("移动文件到 Library 失败: {} - {}", fileName, e.getMessage());
|
||||||
|
return "rejected:move-failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. 如果进行了格式转换且成功,删除原文件
|
||||||
|
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||||
|
deleteIfExists(srcFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. 更新身份标识集合
|
||||||
|
libraryIdentities.add(identity);
|
||||||
|
batchIdentities.add(identity);
|
||||||
|
ingested.incrementAndGet();
|
||||||
|
|
||||||
|
// 16. 处理关联 LRC 文件(可选)
|
||||||
|
handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
|
||||||
|
|
||||||
|
// 17. 提取嵌入式歌词(可选)写入 LRC
|
||||||
|
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
|
||||||
|
|
||||||
|
// 18. 提取封面(可选)
|
||||||
|
extractCover(tag, targetDir);
|
||||||
|
|
||||||
|
return "ingested";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 关联文件处理 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将同名 .lrc 文件随音频一起移入目标目录
|
||||||
|
*/
|
||||||
|
private void handleAssociatedLrc(Path srcFile, Path targetDir, String baseName, String destFileName) {
|
||||||
|
Path lrcSource = srcFile.resolveSibling(baseName + ".lrc");
|
||||||
|
if (!Files.exists(lrcSource)) {
|
||||||
|
// 也检查小写扩展名
|
||||||
|
lrcSource = srcFile.resolveSibling(baseName + ".LRC");
|
||||||
|
if (!Files.exists(lrcSource)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String lrcDestName = getBaseName(destFileName) + ".lrc";
|
||||||
|
Path lrcTarget = resolveUniqueFile(targetDir, lrcDestName);
|
||||||
|
FileTransferUtils.moveWithFallback(lrcSource, lrcTarget);
|
||||||
|
log.info("已移动关联 LRC 文件: {} -> {}", lrcSource.getFileName(), lrcTarget);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("移动 LRC 文件失败: {} - {}", lrcSource, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从标签中提取嵌入式歌词(可选),写入 LRC 文件
|
||||||
|
*/
|
||||||
|
private void extractEmbeddedLyrics(Tag tag, Path targetDir, String trackStr,
|
||||||
|
String safeTitle, String originalTitle, String artist) {
|
||||||
|
try {
|
||||||
|
String lyrics = tag.getFirst(FieldKey.LYRICS);
|
||||||
|
if (lyrics == null || lyrics.trim().isEmpty()) return;
|
||||||
|
|
||||||
|
// 构建简易 LRC 内容(无时间戳,仅作为歌词文本)
|
||||||
|
StringBuilder lrcContent = new StringBuilder();
|
||||||
|
lrcContent.append("[ti:").append(originalTitle).append("]\n");
|
||||||
|
lrcContent.append("[ar:").append(artist).append("]\n");
|
||||||
|
lrcContent.append("\n");
|
||||||
|
lrcContent.append(lyrics.trim()).append("\n");
|
||||||
|
|
||||||
|
String lrcFileName = trackStr + " - " + safeTitle + ".lrc";
|
||||||
|
Path lrcFile = resolveUniqueFile(targetDir, lrcFileName);
|
||||||
|
Files.write(lrcFile, lrcContent.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
log.info("已提取嵌入式歌词到: {}", lrcFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("提取嵌入式歌词失败(可选,忽略): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从标签中提取封面(可选),写入 cover.jpg
|
||||||
|
*/
|
||||||
|
private void extractCover(Tag tag, Path targetDir) {
|
||||||
|
try {
|
||||||
|
List<Artwork> artworks = tag.getArtworkList();
|
||||||
|
if (artworks == null || artworks.isEmpty()) return;
|
||||||
|
|
||||||
|
Artwork artwork = artworks.get(0);
|
||||||
|
byte[] data = artwork.getBinaryData();
|
||||||
|
if (data == null || data.length == 0) return;
|
||||||
|
|
||||||
|
String mime = artwork.getMimeType();
|
||||||
|
String coverExt = "jpg";
|
||||||
|
if (mime != null) {
|
||||||
|
if (mime.contains("png")) coverExt = "png";
|
||||||
|
else if (mime.contains("gif")) coverExt = "gif";
|
||||||
|
}
|
||||||
|
|
||||||
|
Path coverFile = resolveUniqueFile(targetDir, "cover." + coverExt);
|
||||||
|
Files.write(coverFile, data);
|
||||||
|
log.info("已提取封面到: {}", coverFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("提取封面失败(可选,忽略): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 结构化报告 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入 JSON 格式的结构化报告
|
||||||
|
*/
|
||||||
|
private void writeReport(String taskId, LinkedHashMap<String, String> fileOutcomes,
|
||||||
|
Path rejectedPath, int ingestedCount, int duplicateCount,
|
||||||
|
int missingMetaCount, int unreadableCount,
|
||||||
|
int convFailedCount, int otherRejectedCount) {
|
||||||
|
try {
|
||||||
|
Path reportsDir = rejectedPath.resolve(REPORT_SUBDIR);
|
||||||
|
Files.createDirectories(reportsDir);
|
||||||
|
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
|
||||||
|
String reportFileName = "ingest-report-" + timestamp + ".json";
|
||||||
|
Path reportFile = resolveUniqueFile(reportsDir, reportFileName);
|
||||||
|
|
||||||
|
StringBuilder json = new StringBuilder();
|
||||||
|
json.append("{\n");
|
||||||
|
json.append(" \"taskId\": \"").append(escapeJson(taskId)).append("\",\n");
|
||||||
|
json.append(" \"timestamp\": \"").append(timestamp).append("\",\n");
|
||||||
|
json.append(" \"summary\": {\n");
|
||||||
|
json.append(" \"total\": ").append(fileOutcomes.size()).append(",\n");
|
||||||
|
json.append(" \"ingested\": ").append(ingestedCount).append(",\n");
|
||||||
|
json.append(" \"duplicates\": ").append(duplicateCount).append(",\n");
|
||||||
|
json.append(" \"missingMetadata\": ").append(missingMetaCount).append(",\n");
|
||||||
|
json.append(" \"unreadable\": ").append(unreadableCount).append(",\n");
|
||||||
|
json.append(" \"conversionFailed\": ").append(convFailedCount).append(",\n");
|
||||||
|
json.append(" \"otherRejected\": ").append(otherRejectedCount).append("\n");
|
||||||
|
json.append(" },\n");
|
||||||
|
json.append(" \"files\": [\n");
|
||||||
|
|
||||||
|
int idx = 0;
|
||||||
|
int size = fileOutcomes.size();
|
||||||
|
for (Map.Entry<String, String> entry : fileOutcomes.entrySet()) {
|
||||||
|
json.append(" {\"file\": \"").append(escapeJson(entry.getKey()));
|
||||||
|
json.append("\", \"outcome\": \"").append(escapeJson(entry.getValue())).append("\"}");
|
||||||
|
if (++idx < size) json.append(",");
|
||||||
|
json.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
json.append(" ]\n");
|
||||||
|
json.append("}\n");
|
||||||
|
|
||||||
|
Files.write(reportFile, json.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
log.info("已完成结构化报告: {}", reportFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("写入报告失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeJson(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\t", "\\t");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 去重检测 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否与已有集合中的任何条目重复。
|
||||||
|
*
|
||||||
|
* @return 重复原因(null 表示不重复)
|
||||||
|
*/
|
||||||
|
String checkDuplicate(IdentityKey identity,
|
||||||
|
Set<IdentityKey> libraryIdentities,
|
||||||
|
Set<IdentityKey> batchIdentities) {
|
||||||
|
// 先检查精确的 metadata 匹配(Artist|Album|Disc|Track|Title)
|
||||||
|
if (libraryIdentities.contains(identity) || batchIdentities.contains(identity)) {
|
||||||
|
return "metadata";
|
||||||
|
}
|
||||||
|
|
||||||
|
// MD5 兜底:检查是否有相同 MD5 的文件(即使元数据不同)
|
||||||
|
if (identity.md5 != null && !identity.md5.isEmpty()) {
|
||||||
|
for (IdentityKey existing : libraryIdentities) {
|
||||||
|
if (identity.md5.equals(existing.md5)) {
|
||||||
|
return "md5";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (IdentityKey existing : batchIdentities) {
|
||||||
|
if (identity.md5.equals(existing.md5)) {
|
||||||
|
return "md5";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 工具方法 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算文件的 MD5 哈希
|
||||||
|
*/
|
||||||
|
static String computeMd5(Path file) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||||
|
try (InputStream is = Files.newInputStream(file);
|
||||||
|
DigestInputStream dis = new DigestInputStream(is, md)) {
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
while (dis.read(buf) != -1) {
|
||||||
|
// 读取即可,DigestInputStream 自动更新摘要
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byte[] digest = md.digest();
|
||||||
|
StringBuilder sb = new StringBuilder(32);
|
||||||
|
for (byte b : digest) {
|
||||||
|
sb.append(String.format("%02x", b & 0xff));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (NoSuchAlgorithmException | IOException e) {
|
||||||
|
log.warn("计算 MD5 失败: {} - {}", file, e.getMessage());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将无损格式文件转换为 FLAC。
|
||||||
|
* 转换失败时清理残留输出文件。
|
||||||
|
*/
|
||||||
|
private Path convertToFlac(Path input, Path outputDir) throws IOException, InterruptedException {
|
||||||
|
String baseName = getBaseName(input.getFileName().toString());
|
||||||
|
Path output = outputDir.resolve(baseName + ".flac");
|
||||||
|
output = resolveUniqueFile(outputDir, baseName + ".flac");
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
|
getFfmpegCommand(),
|
||||||
|
"-y",
|
||||||
|
"-i", input.toAbsolutePath().toString(),
|
||||||
|
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
|
||||||
|
output.toAbsolutePath().toString()
|
||||||
|
);
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process p = pb.start();
|
||||||
|
boolean finished = p.waitFor(FFMPEG_CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
p.destroyForcibly();
|
||||||
|
deleteIfExists(output);
|
||||||
|
throw new RuntimeException("ffmpeg 转码超时(" + FFMPEG_CONVERT_TIMEOUT_SECONDS + "s)");
|
||||||
|
}
|
||||||
|
int exit = p.exitValue();
|
||||||
|
if (exit != 0) {
|
||||||
|
deleteIfExists(output);
|
||||||
|
throw new RuntimeException("ffmpeg 退出码: " + exit);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteIfExists(Path path) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("清理临时文件失败: {} - {}", path, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描 Library 目录中已有的音频文件,构建身份标识集合
|
||||||
|
* (所有文本字段经过 t2s + lowercase 归一化,与 incoming 文件一致)
|
||||||
|
*/
|
||||||
|
private Set<IdentityKey> scanLibraryIdentities(Path libraryPath) {
|
||||||
|
Set<IdentityKey> identities = new HashSet<>();
|
||||||
|
if (!Files.exists(libraryPath)) return identities;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.walkFileTree(libraryPath, new SimpleFileVisitor<Path>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||||
|
if (!isAudioFile(file)) return FileVisitResult.CONTINUE;
|
||||||
|
try {
|
||||||
|
AudioFile af = AudioFileIO.read(file.toFile());
|
||||||
|
Tag tag = af.getTag();
|
||||||
|
if (tag == null) return FileVisitResult.CONTINUE;
|
||||||
|
|
||||||
|
String title = trim(tag.getFirst(FieldKey.TITLE));
|
||||||
|
String artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||||
|
String album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||||
|
if (title.isEmpty() || artist.isEmpty() || album.isEmpty()) {
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int trackNum = parseInt(trim(tag.getFirst(FieldKey.TRACK)), 0);
|
||||||
|
int discNum = parseInt(trim(tag.getFirst(FieldKey.DISC_NO)), 0);
|
||||||
|
|
||||||
|
String md5 = computeMd5(file);
|
||||||
|
identities.add(new IdentityKey(
|
||||||
|
traditionalFilterService.toSimplified(artist.toLowerCase()),
|
||||||
|
traditionalFilterService.toSimplified(album.toLowerCase()),
|
||||||
|
discNum, trackNum,
|
||||||
|
traditionalFilterService.toSimplified(title.toLowerCase()),
|
||||||
|
md5
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("读取 Library 文件元数据失败: {} - {}", file, e.getMessage());
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("扫描 Library 目录失败", e);
|
||||||
|
}
|
||||||
|
return identities;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 小工具 ==========
|
||||||
|
|
||||||
|
private boolean isAudioFile(Path file) {
|
||||||
|
String ext = getExtension(file.getFileName().toString());
|
||||||
|
return ext != null && ALL_AUDIO_EXTENSIONS.contains(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isLosslessFormat(Path file) {
|
||||||
|
String ext = getExtension(file.getFileName().toString());
|
||||||
|
return ext != null && LOSSLESS_EXTENSIONS.contains(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String fileName) {
|
||||||
|
if (fileName == null || fileName.isEmpty()) return null;
|
||||||
|
int i = fileName.lastIndexOf('.');
|
||||||
|
if (i <= 0 || i == fileName.length() - 1) return null;
|
||||||
|
return fileName.substring(i + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getBaseName(String fileName) {
|
||||||
|
int i = fileName.lastIndexOf('.');
|
||||||
|
if (i <= 0) return fileName;
|
||||||
|
return fileName.substring(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trim(String s) {
|
||||||
|
return s == null ? "" : s.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parseInt(String s, int defaultValue) {
|
||||||
|
if (s == null || s.trim().isEmpty()) return defaultValue;
|
||||||
|
try {
|
||||||
|
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 defaultValue;
|
||||||
|
return Integer.parseInt(digits.toString());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractYear(String dateStr) {
|
||||||
|
if (dateStr == null || dateStr.isEmpty()) return "";
|
||||||
|
for (int i = 0; i < dateStr.length(); i++) {
|
||||||
|
if (Character.isDigit(dateStr.charAt(i))) {
|
||||||
|
int end = Math.min(i + 4, dateStr.length());
|
||||||
|
String y = dateStr.substring(i, end);
|
||||||
|
if (y.length() == 4) return y;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void moveToRejected(Path file, Path rejectedRoot, String reason, String fileName) {
|
||||||
|
try {
|
||||||
|
Path reasonDir = rejectedRoot.resolve(reason);
|
||||||
|
Files.createDirectories(reasonDir);
|
||||||
|
Path target = resolveUniqueFile(reasonDir, fileName);
|
||||||
|
FileTransferUtils.moveWithFallback(file, target);
|
||||||
|
// 将关联的 .lrc 侧车文件一同移入
|
||||||
|
moveSidecarLrc(file, reasonDir);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("移动文件到 Rejected 失败: {} - {}", file, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将音频文件同名的 .lrc 侧车文件移入目标目录(碰撞安全)
|
||||||
|
*/
|
||||||
|
private void moveSidecarLrc(Path audioFile, Path targetDir) {
|
||||||
|
String baseName = getBaseName(audioFile.getFileName().toString());
|
||||||
|
for (String ext : new String[]{".lrc", ".LRC"}) {
|
||||||
|
Path lrcSource = audioFile.resolveSibling(baseName + ext);
|
||||||
|
if (Files.exists(lrcSource)) {
|
||||||
|
try {
|
||||||
|
Path lrcTarget = resolveUniqueFile(targetDir, baseName + ".lrc");
|
||||||
|
FileTransferUtils.moveWithFallback(lrcSource, lrcTarget);
|
||||||
|
log.info("已移动关联 LRC 侧车文件: {} -> {}", lrcSource.getFileName(), lrcTarget);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("移动关联 LRC 侧车文件失败: {} - {}", lrcSource, e.getMessage());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path resolveUniqueFile(Path dir, String fileName) throws IOException {
|
||||||
|
Path target = dir.resolve(fileName);
|
||||||
|
if (!Files.exists(target)) return target;
|
||||||
|
|
||||||
|
int lastDot = fileName.lastIndexOf('.');
|
||||||
|
String base = lastDot > 0 ? fileName.substring(0, lastDot) : fileName;
|
||||||
|
String ext = lastDot > 0 ? fileName.substring(lastDot) : "";
|
||||||
|
int n = 1;
|
||||||
|
while (Files.exists(target)) {
|
||||||
|
target = dir.resolve(base + " (" + n + ")" + ext);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitizePathComponent(String s) {
|
||||||
|
if (s == null || s.isEmpty()) return "_";
|
||||||
|
String cleaned = s.replaceAll("[\\\\/:*?\"<>|]", "_")
|
||||||
|
.replaceAll("\\s+", " ").trim();
|
||||||
|
return cleaned.isEmpty() ? "_" : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getFfmpegCommand() {
|
||||||
|
String configured = System.getProperty(FFMPEG_BIN_PROPERTY);
|
||||||
|
if (configured == null || configured.trim().isEmpty()) {
|
||||||
|
return "ffmpeg";
|
||||||
|
}
|
||||||
|
return configured.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String checkFfmpegAvailable() {
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(getFfmpegCommand(), "-version");
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process p = pb.start();
|
||||||
|
boolean finished = p.waitFor(FFMPEG_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
p.destroyForcibly();
|
||||||
|
return "ffmpeg 预检查超时,请检查环境配置";
|
||||||
|
}
|
||||||
|
if (p.exitValue() != 0) {
|
||||||
|
return "ffmpeg 不可用,请确认已正确安装并加入 PATH";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "ffmpeg 不可用,请确认已正确安装并加入 PATH";
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return "ffmpeg 预检查被中断";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 进度消息 ==========
|
||||||
|
|
||||||
|
private void sendProgress(String taskId, int total, int processed,
|
||||||
|
int ingestedFiles, int duplicateFiles,
|
||||||
|
int missingMetadataFiles, int unreadableFiles,
|
||||||
|
int conversionFailedFiles, int otherRejectedFiles,
|
||||||
|
String currentFile, String message, boolean completed) {
|
||||||
|
try {
|
||||||
|
ProgressMessage pm = new ProgressMessage();
|
||||||
|
pm.setTaskId(taskId);
|
||||||
|
pm.setType("ingest");
|
||||||
|
pm.setTotal(total);
|
||||||
|
pm.setProcessed(processed);
|
||||||
|
pm.setSuccess(ingestedFiles);
|
||||||
|
pm.setFailed(otherRejectedFiles);
|
||||||
|
pm.setIngestedFiles(ingestedFiles);
|
||||||
|
pm.setDuplicateFiles(duplicateFiles);
|
||||||
|
pm.setMissingMetadataFiles(missingMetadataFiles);
|
||||||
|
pm.setUnreadableFiles(unreadableFiles);
|
||||||
|
pm.setConversionFailedFiles(conversionFailedFiles);
|
||||||
|
pm.setOtherRejectedFiles(otherRejectedFiles);
|
||||||
|
pm.setCurrentFile(currentFile);
|
||||||
|
pm.setMessage(message);
|
||||||
|
pm.setCompleted(completed);
|
||||||
|
progressStore.put(pm);
|
||||||
|
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
|
||||||
|
log.debug("发送 ingest 进度: taskId={}, total={}, processed={}, ingested={}",
|
||||||
|
taskId, total, processed, ingestedFiles);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("发送进度消息失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 内部类 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件身份标识,用于去重比较。
|
||||||
|
* 包括元数据身份(Artist|Album|Disc|Track|Title)和 MD5 哈希兜底。
|
||||||
|
*/
|
||||||
|
static class IdentityKey {
|
||||||
|
private final String artist;
|
||||||
|
private final String album;
|
||||||
|
private final int disc;
|
||||||
|
private final int track;
|
||||||
|
private final String title;
|
||||||
|
private final String md5;
|
||||||
|
|
||||||
|
IdentityKey(String artist, String album, int disc, int track, String title) {
|
||||||
|
this(artist, album, disc, track, title, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
IdentityKey(String artist, String album, int disc, int track, String title, String md5) {
|
||||||
|
this.artist = artist;
|
||||||
|
this.album = album;
|
||||||
|
this.disc = disc;
|
||||||
|
this.track = track;
|
||||||
|
this.title = title;
|
||||||
|
this.md5 = md5 == null ? "" : md5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof IdentityKey)) return false;
|
||||||
|
IdentityKey that = (IdentityKey) o;
|
||||||
|
return disc == that.disc && track == that.track &&
|
||||||
|
Objects.equals(artist, that.artist) &&
|
||||||
|
Objects.equals(album, that.album) &&
|
||||||
|
Objects.equals(title, that.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(artist, album, disc, track, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return artist + "|" + album + "|" + disc + "|" + track + "|" + title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
package com.music.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
class IngestServiceInternalTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identityKeyEquality() throws Exception {
|
||||||
|
Class<?> ikClass = getIdentityKeyClass();
|
||||||
|
Constructor<?> ctor = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class);
|
||||||
|
ctor.setAccessible(true);
|
||||||
|
|
||||||
|
Object a = ctor.newInstance("artist1", "album1", 1, 3, "title1");
|
||||||
|
Object b = ctor.newInstance("artist1", "album1", 1, 3, "title1");
|
||||||
|
Object c = ctor.newInstance("artist2", "album1", 1, 3, "title1");
|
||||||
|
Object d = ctor.newInstance("artist1", "album1", 1, 4, "title1");
|
||||||
|
|
||||||
|
assertEquals(a, b);
|
||||||
|
assertEquals(a.hashCode(), b.hashCode());
|
||||||
|
assertNotEquals(a, c);
|
||||||
|
assertNotEquals(a, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identityKeyConstructorAndGetters() throws Exception {
|
||||||
|
Class<?> ikClass = getIdentityKeyClass();
|
||||||
|
Constructor<?> ctor = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class);
|
||||||
|
ctor.setAccessible(true);
|
||||||
|
|
||||||
|
Object key = ctor.newInstance("test artist", "test album", 2, 5, "test title");
|
||||||
|
|
||||||
|
Method toString = ikClass.getDeclaredMethod("toString");
|
||||||
|
String str = (String) toString.invoke(key);
|
||||||
|
assertTrue(str.contains("test artist"));
|
||||||
|
assertTrue(str.contains("test album"));
|
||||||
|
assertTrue(str.contains("2"));
|
||||||
|
assertTrue(str.contains("5"));
|
||||||
|
assertTrue(str.contains("test title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizePathComponentRemovesInvalidChars() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("sanitizePathComponent", String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
assertEquals("Hello World", m.invoke(service, "Hello World"));
|
||||||
|
assertEquals("A_B", m.invoke(service, "A:B"));
|
||||||
|
assertEquals("A_B", m.invoke(service, "A/B"));
|
||||||
|
assertEquals("_", m.invoke(service, ""));
|
||||||
|
assertEquals("_", m.invoke(service, (String) null));
|
||||||
|
assertEquals("Trimmed", m.invoke(service, " Trimmed "));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseIntHandlesVariousInputs() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("parseInt", String.class, int.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
assertEquals(3, (int) m.invoke(service, "3", 0));
|
||||||
|
assertEquals(5, (int) m.invoke(service, "05", 0));
|
||||||
|
assertEquals(0, (int) m.invoke(service, (String) null, 0));
|
||||||
|
assertEquals(0, (int) m.invoke(service, "", 0));
|
||||||
|
assertEquals(42, (int) m.invoke(service, " 42 ", 0));
|
||||||
|
assertEquals(1, (int) m.invoke(service, "1/10", 0));
|
||||||
|
assertEquals(0, (int) m.invoke(service, "abc", 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractYearParsesDates() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("extractYear", String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
assertEquals("2024", m.invoke(service, "2024"));
|
||||||
|
assertEquals("1999", m.invoke(service, "1999-01-01"));
|
||||||
|
assertEquals("2000", m.invoke(service, "2000-12-31"));
|
||||||
|
assertEquals("", m.invoke(service, (String) null));
|
||||||
|
assertEquals("", m.invoke(service, ""));
|
||||||
|
assertEquals("", m.invoke(service, "abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getExtensionWorksCorrectly() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("getExtension", String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
assertEquals("flac", m.invoke(service, "song.flac"));
|
||||||
|
assertEquals("mp3", m.invoke(service, "song.MP3"));
|
||||||
|
assertEquals("wav", m.invoke(service, "song.Wav"));
|
||||||
|
assertNull(m.invoke(service, "noext"));
|
||||||
|
assertNull(m.invoke(service, ".hidden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBaseNameWorksCorrectly() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("getBaseName", String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
assertEquals("song", m.invoke(service, "song.flac"));
|
||||||
|
assertEquals("song.name", m.invoke(service, "song.name.flac"));
|
||||||
|
assertEquals("noext", m.invoke(service, "noext"));
|
||||||
|
assertEquals("", m.invoke(service, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveUniqueFileCreatesNonConflictingPaths() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("resolveUniqueFile", Path.class, String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
Path tmpDir = Files.createTempDirectory("ingest-unique-");
|
||||||
|
Path result = (Path) m.invoke(service, tmpDir, "test.flac");
|
||||||
|
assertEquals(tmpDir.resolve("test.flac"), result);
|
||||||
|
|
||||||
|
// 已存在时应该添加后缀
|
||||||
|
Files.write(result, "existing".getBytes());
|
||||||
|
Path result2 = (Path) m.invoke(service, tmpDir, "test.flac");
|
||||||
|
assertEquals(tmpDir.resolve("test (1).flac"), result2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isLosslessFormatDetectsCorrectFormats() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("isLosslessFormat", Path.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.wav")));
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.ape")));
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.aiff")));
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.wv")));
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.tta")));
|
||||||
|
assertFalse((boolean) m.invoke(service, Paths.get("test.flac")));
|
||||||
|
assertFalse((boolean) m.invoke(service, Paths.get("test.mp3")));
|
||||||
|
assertFalse((boolean) m.invoke(service, Paths.get("test.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isAudioFileDetectsAllSupportedFormats() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("isAudioFile", Path.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.flac")));
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.mp3")));
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.wav")));
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.ape")));
|
||||||
|
assertTrue((boolean) m.invoke(service, Paths.get("test.m4a")));
|
||||||
|
assertFalse((boolean) m.invoke(service, Paths.get("test.txt")));
|
||||||
|
assertFalse((boolean) m.invoke(service, Paths.get("test.jpg")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过反射获取嵌套类 IdentityKey
|
||||||
|
*/
|
||||||
|
private Class<?> getIdentityKeyClass() throws ClassNotFoundException {
|
||||||
|
return Class.forName("com.music.service.IngestService$IdentityKey");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identityKeyWithMd5Constructor() throws Exception {
|
||||||
|
Class<?> ikClass = getIdentityKeyClass();
|
||||||
|
// 6 参数构造器:artist, album, disc, track, title, md5
|
||||||
|
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
|
||||||
|
ctor6.setAccessible(true);
|
||||||
|
|
||||||
|
Object keyA = ctor6.newInstance("art", "alb", 1, 2, "ttl", "abc123");
|
||||||
|
Object keyB = ctor6.newInstance("art", "alb", 1, 2, "ttl", "def456");
|
||||||
|
|
||||||
|
// equals/hashCode 应忽略 MD5,仅基于元数据
|
||||||
|
assertEquals(keyA, keyB);
|
||||||
|
assertEquals(keyA.hashCode(), keyB.hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identityKeyMd5StoredAndAccessible() throws Exception {
|
||||||
|
Class<?> ikClass = getIdentityKeyClass();
|
||||||
|
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
|
||||||
|
ctor6.setAccessible(true);
|
||||||
|
|
||||||
|
Object key = ctor6.newInstance("a", "b", 0, 0, "c", "md5hashvalue");
|
||||||
|
Method toString = ikClass.getDeclaredMethod("toString");
|
||||||
|
String str = (String) toString.invoke(key);
|
||||||
|
assertTrue(str.contains("a|b|0|0|c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void computeMd5ReturnsNonEmptyString() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("computeMd5", Path.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
Path tmpFile = Files.createTempFile("ingest-md5-", ".tmp");
|
||||||
|
Files.write(tmpFile, "hello test content".getBytes());
|
||||||
|
String md5 = (String) m.invoke(service, tmpFile);
|
||||||
|
assertNotNull(md5);
|
||||||
|
assertEquals(32, md5.length());
|
||||||
|
assertTrue(md5.matches("[0-9a-f]{32}"));
|
||||||
|
|
||||||
|
// 相同内容 → 相同 MD5
|
||||||
|
String md5b = (String) m.invoke(service, tmpFile);
|
||||||
|
assertEquals(md5, md5b);
|
||||||
|
|
||||||
|
// 不同内容 → 不同 MD5
|
||||||
|
Path tmpFile2 = Files.createTempFile("ingest-md5-", ".tmp");
|
||||||
|
Files.write(tmpFile2, "different content".getBytes());
|
||||||
|
String md5c = (String) m.invoke(service, tmpFile2);
|
||||||
|
assertNotEquals(md5, md5c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identityKeyDifferentMetadataNotEqual() throws Exception {
|
||||||
|
Class<?> ikClass = getIdentityKeyClass();
|
||||||
|
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
|
||||||
|
ctor6.setAccessible(true);
|
||||||
|
|
||||||
|
Object key1 = ctor6.newInstance("artist1", "album1", 1, 1, "title1", "sameMd5");
|
||||||
|
Object key2 = ctor6.newInstance("artist2", "album1", 1, 1, "title1", "sameMd5");
|
||||||
|
Object key3 = ctor6.newInstance("artist1", "album2", 1, 1, "title1", "sameMd5");
|
||||||
|
|
||||||
|
assertNotEquals(key1, key2);
|
||||||
|
assertNotEquals(key1, key3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatePathsRejectsSamePaths() throws Exception {
|
||||||
|
Path p = Paths.get("/tmp/ingest-test");
|
||||||
|
String inputVsLib = IngestService.validatePaths(p, p, Paths.get("/tmp/ingest-test-rej"));
|
||||||
|
assertNotNull(inputVsLib);
|
||||||
|
assertTrue(inputVsLib.contains("不能是同一目录"));
|
||||||
|
|
||||||
|
String libVsRej = IngestService.validatePaths(Paths.get("/tmp/ingest-test-in"), p, p);
|
||||||
|
assertNotNull(libVsRej);
|
||||||
|
assertTrue(libVsRej.contains("不能是同一目录"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatePathsRejectsNestedPaths() throws Exception {
|
||||||
|
Path root = Paths.get("/tmp/ingest-nest-test");
|
||||||
|
Path nested = root.resolve("sub");
|
||||||
|
|
||||||
|
String libInInput = IngestService.validatePaths(root, nested, Paths.get("/tmp/ingest-nest-rej"));
|
||||||
|
assertNotNull(libInInput);
|
||||||
|
assertTrue(libInInput.contains("不能位于") || libInInput.contains("不能是同一"));
|
||||||
|
|
||||||
|
String rejInInput = IngestService.validatePaths(root, Paths.get("/tmp/ingest-nest-lib"), nested);
|
||||||
|
assertNotNull(rejInInput);
|
||||||
|
assertTrue(rejInInput.contains("不能位于") || rejInInput.contains("不能是同一"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatePathsAcceptsValidPaths() throws Exception {
|
||||||
|
Path input = Paths.get("/tmp/ingest-valid-in");
|
||||||
|
Path lib = Paths.get("/tmp/ingest-valid-lib");
|
||||||
|
Path rej = Paths.get("/tmp/ingest-valid-rej");
|
||||||
|
assertNull(IngestService.validatePaths(input, lib, rej));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkDuplicateReturnsNullForNewIdentity() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Class<?> ikClass = getIdentityKeyClass();
|
||||||
|
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
|
||||||
|
ctor6.setAccessible(true);
|
||||||
|
|
||||||
|
Set<Object> library = new java.util.HashSet<>();
|
||||||
|
Set<Object> batch = new java.util.HashSet<>();
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
|
||||||
|
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
Object newKey = ctor6.newInstance("new", "album", 1, 1, "title", "md5_1");
|
||||||
|
// 两个集合都为空 → 不重复
|
||||||
|
Object result = m.invoke(service, newKey, library, batch);
|
||||||
|
assertNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkDuplicateDetectsMetadataMatch() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Class<?> ikClass = getIdentityKeyClass();
|
||||||
|
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
|
||||||
|
ctor6.setAccessible(true);
|
||||||
|
|
||||||
|
Set<Object> library = new java.util.HashSet<>();
|
||||||
|
Set<Object> batch = new java.util.HashSet<>();
|
||||||
|
|
||||||
|
Object existing = ctor6.newInstance("artist", "album", 1, 3, "title", "md5_existing");
|
||||||
|
library.add(existing);
|
||||||
|
|
||||||
|
Object dupe = ctor6.newInstance("artist", "album", 1, 3, "title", "md5_other");
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
|
||||||
|
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
Object result = m.invoke(service, dupe, library, batch);
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals("metadata", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkDuplicateDetectsMd5Fallback() throws Exception {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Class<?> ikClass = getIdentityKeyClass();
|
||||||
|
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
|
||||||
|
ctor6.setAccessible(true);
|
||||||
|
|
||||||
|
Set<Object> library = new java.util.HashSet<>();
|
||||||
|
Set<Object> batch = new java.util.HashSet<>();
|
||||||
|
|
||||||
|
// 元数据不同但 MD5 相同
|
||||||
|
Object existing = ctor6.newInstance("artist1", "album1", 1, 1, "title1", "same_md5_hash");
|
||||||
|
library.add(existing);
|
||||||
|
|
||||||
|
Object dupByMd5 = ctor6.newInstance("artist2", "album2", 2, 2, "title2", "same_md5_hash");
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
|
||||||
|
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
|
||||||
|
Object result = m.invoke(service, dupByMd5, library, batch);
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals("md5", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== codex-4-2: LRC 侧车文件传播 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void moveToRejectedMovesLrcSidecar() throws Exception {
|
||||||
|
Path tmpDir = Files.createTempDirectory("ingest-lrc-propagate-");
|
||||||
|
try {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建模拟音频文件 + .lrc 侧车
|
||||||
|
Path audioFile = tmpDir.resolve("song.flac");
|
||||||
|
Files.write(audioFile, "audio-data".getBytes());
|
||||||
|
Path lrcFile = tmpDir.resolve("song.lrc");
|
||||||
|
Files.write(lrcFile, "[00:01]lyrics".getBytes());
|
||||||
|
|
||||||
|
Path rejectedRoot = tmpDir.resolve("Rejected");
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod(
|
||||||
|
"moveToRejected", Path.class, Path.class, String.class, String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
m.invoke(service, audioFile, rejectedRoot, "Other", "song.flac");
|
||||||
|
|
||||||
|
// 音频被移到 Rejected/Other
|
||||||
|
assertTrue(Files.exists(rejectedRoot.resolve("Other/song.flac")),
|
||||||
|
"音频应被移到 Rejected/Other");
|
||||||
|
// LRC 侧车也被移到 Rejected/Other
|
||||||
|
assertTrue(Files.exists(rejectedRoot.resolve("Other/song.lrc")),
|
||||||
|
"LRC 侧车文件应被一同移到 Rejected/Other");
|
||||||
|
// 源文件不再存在
|
||||||
|
assertFalse(Files.exists(audioFile), "源音频应已被移动");
|
||||||
|
assertFalse(Files.exists(lrcFile), "源 LRC 应已被移动");
|
||||||
|
} finally {
|
||||||
|
deleteDirectory(tmpDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void moveToRejectedSkipsMissingLrc() throws Exception {
|
||||||
|
Path tmpDir = Files.createTempDirectory("ingest-lrc-skip-");
|
||||||
|
try {
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
Path audioFile = tmpDir.resolve("track.flac");
|
||||||
|
Files.write(audioFile, "data".getBytes());
|
||||||
|
// 故意不创建 .lrc 文件
|
||||||
|
|
||||||
|
Path rejectedRoot = tmpDir.resolve("Rej");
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod(
|
||||||
|
"moveToRejected", Path.class, Path.class, String.class, String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
m.invoke(service, audioFile, rejectedRoot, "Duplicate", "track.flac");
|
||||||
|
|
||||||
|
assertTrue(Files.exists(rejectedRoot.resolve("Duplicate/track.flac")));
|
||||||
|
// 不应报错(无 LRC 时静默跳过)
|
||||||
|
} finally {
|
||||||
|
deleteDirectory(tmpDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== codex-4-3: 唯一报告条目(相对路径键) ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportOutcomesKeyedByRelativePathAvoidsCollision() throws Exception {
|
||||||
|
Path tmpDir = Files.createTempDirectory("ingest-relpath-");
|
||||||
|
try {
|
||||||
|
// 模拟 Input 目录中有两个同名文件位于不同子目录
|
||||||
|
Path inputRoot = tmpDir.resolve("Input");
|
||||||
|
Path sub1 = inputRoot.resolve("album_a");
|
||||||
|
Path sub2 = inputRoot.resolve("album_b");
|
||||||
|
Files.createDirectories(sub1);
|
||||||
|
Files.createDirectories(sub2);
|
||||||
|
|
||||||
|
Path file1 = sub1.resolve("01.flac");
|
||||||
|
Path file2 = sub2.resolve("01.flac");
|
||||||
|
Files.write(file1, "a".getBytes());
|
||||||
|
Files.write(file2, "b".getBytes());
|
||||||
|
|
||||||
|
// relativize 应产生差异化路径
|
||||||
|
String rel1 = inputRoot.relativize(file1).toString();
|
||||||
|
String rel2 = inputRoot.relativize(file2).toString();
|
||||||
|
assertEquals("album_a/01.flac", rel1);
|
||||||
|
assertEquals("album_b/01.flac", rel2);
|
||||||
|
assertNotEquals(rel1, rel2, "不同子目录中的同名文件应有不同 relative 路径");
|
||||||
|
} finally {
|
||||||
|
deleteDirectory(tmpDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 工具方法 ==========
|
||||||
|
|
||||||
|
private void deleteDirectory(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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class ProgressMessageMappingTest {
|
class ProgressMessageMappingTest {
|
||||||
|
|
||||||
@@ -76,4 +79,84 @@ class ProgressMessageMappingTest {
|
|||||||
assertEquals(3, pm.getUpgradedFiles().intValue());
|
assertEquals(3, pm.getUpgradedFiles().intValue());
|
||||||
assertEquals(2, pm.getSkippedFiles().intValue());
|
assertEquals(2, pm.getSkippedFiles().intValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ingestProgressContainsDedicatedFields() throws Exception {
|
||||||
|
ProgressStore store = new ProgressStore();
|
||||||
|
IngestService service = new IngestService(
|
||||||
|
mock(SimpMessagingTemplate.class), store,
|
||||||
|
mock(TraditionalFilterService.class),
|
||||||
|
mock(ConfigService.class));
|
||||||
|
|
||||||
|
Method m = IngestService.class.getDeclaredMethod(
|
||||||
|
"sendProgress", String.class, int.class, int.class,
|
||||||
|
int.class, int.class, int.class, int.class,
|
||||||
|
int.class, int.class, String.class, String.class, boolean.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
m.invoke(service, "t-ingest", 100, 50, 10, 5, 3, 2, 1, 4, "f.mp3", "msg", false);
|
||||||
|
|
||||||
|
ProgressMessage pm = store.get("t-ingest");
|
||||||
|
assertEquals(10, pm.getIngestedFiles().intValue());
|
||||||
|
assertEquals(5, pm.getDuplicateFiles().intValue());
|
||||||
|
assertEquals(3, pm.getMissingMetadataFiles().intValue());
|
||||||
|
assertEquals(2, pm.getUnreadableFiles().intValue());
|
||||||
|
assertEquals(1, pm.getConversionFailedFiles().intValue());
|
||||||
|
assertEquals(4, pm.getOtherRejectedFiles().intValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ingestControllerRejectsConcurrentStarts() throws Exception {
|
||||||
|
ConfigService cfgService = mock(ConfigService.class);
|
||||||
|
when(cfgService.getBasePath()).thenReturn("/tmp");
|
||||||
|
|
||||||
|
com.music.controller.IngestController controller = new com.music.controller.IngestController(
|
||||||
|
mock(IngestService.class),
|
||||||
|
new ProgressStore(),
|
||||||
|
cfgService);
|
||||||
|
|
||||||
|
// 模拟配置已设置
|
||||||
|
com.music.dto.ConfigResponse cfgResp = new com.music.dto.ConfigResponse();
|
||||||
|
cfgResp.setBasePath("/tmp");
|
||||||
|
|
||||||
|
java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running");
|
||||||
|
runningField.setAccessible(true);
|
||||||
|
AtomicBoolean running = (AtomicBoolean) runningField.get(controller);
|
||||||
|
|
||||||
|
// 设置为运行中
|
||||||
|
running.set(true);
|
||||||
|
|
||||||
|
// 验证并发被拒绝
|
||||||
|
com.music.exception.BusinessException ex = org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
com.music.exception.BusinessException.class,
|
||||||
|
() -> controller.start(new com.music.dto.IngestRequest())
|
||||||
|
);
|
||||||
|
assertEquals(409, ex.getCode());
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
running.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ingestControllerAcceptsSecondStartAfterCompletion() throws Exception {
|
||||||
|
ConfigService cfgService = mock(ConfigService.class);
|
||||||
|
when(cfgService.getBasePath()).thenReturn("/tmp");
|
||||||
|
|
||||||
|
ProgressStore store = new ProgressStore();
|
||||||
|
com.music.controller.IngestController controller = new com.music.controller.IngestController(
|
||||||
|
mock(IngestService.class),
|
||||||
|
store,
|
||||||
|
cfgService);
|
||||||
|
|
||||||
|
java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running");
|
||||||
|
runningField.setAccessible(true);
|
||||||
|
AtomicBoolean running = (AtomicBoolean) runningField.get(controller);
|
||||||
|
|
||||||
|
// 模拟第一个任务完成:IngestService.finally 释放了锁
|
||||||
|
running.set(true);
|
||||||
|
running.set(false); // 模拟 finally 释放
|
||||||
|
|
||||||
|
// 第二个 start 应成功(没有 409 异常)
|
||||||
|
com.music.common.Result<?> result = controller.start(new com.music.dto.IngestRequest());
|
||||||
|
assertTrue(result.getCode() == 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ RUN mvn clean package -DskipTests -B -s /root/.m2/settings.xml
|
|||||||
FROM eclipse-temurin:8-jre-alpine
|
FROM eclipse-temurin:8-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata wget \
|
RUN apk add --no-cache tzdata wget ffmpeg \
|
||||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
&& echo "Asia/Shanghai" > /etc/timezone
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
|
||||||
|
|||||||
+26
-14
@@ -16,7 +16,18 @@ git clone <仓库地址>
|
|||||||
cd MyTool
|
cd MyTool
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 启动服务
|
### 2. 配置工作目录
|
||||||
|
|
||||||
|
编辑 `docker/docker-compose.yml`,将 `/path/to/MusicWork` 替换为宿主机上的音乐工作目录:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /your/actual/path/MusicWork:/home/mangtool/MusicWork
|
||||||
|
```
|
||||||
|
|
||||||
|
该目录下包含 `Input/`(放入待处理的音频文件)、`Library/`(整理后的曲库)和 `Rejected/`(被拒绝的文件)三个子目录。
|
||||||
|
|
||||||
|
### 3. 启动服务
|
||||||
|
|
||||||
**Linux / macOS:**
|
**Linux / macOS:**
|
||||||
|
|
||||||
@@ -42,12 +53,20 @@ cd docker
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 访问应用
|
### 4. 访问应用
|
||||||
|
|
||||||
浏览器打开:**http://localhost:8080**
|
浏览器打开:**http://localhost:8080**
|
||||||
|
|
||||||
前端与后端由同一服务提供,无需单独配置 API 地址。
|
前端与后端由同一服务提供,无需单独配置 API 地址。
|
||||||
|
|
||||||
|
### 5. 一键导入
|
||||||
|
|
||||||
|
1. 在 **配置** 页面设置工作根目录(与挂载路径一致,如 `/home/mangtool/MusicWork`)。
|
||||||
|
2. 将待处理的音频文件放入 `Input/` 目录。
|
||||||
|
3. 切换到 **一键导入** 页面,点击「开始一键导入」。
|
||||||
|
4. 系统自动完成:扫描 → 校验元数据 → 繁简转换 → 转码为 FLAC → 去重 → 整理入库。
|
||||||
|
5. 成功文件进入 `Library/` 目录,被拒绝的文件进入 `Rejected/` 下的对应子目录。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
| 操作 | 命令 |
|
| 操作 | 命令 |
|
||||||
@@ -62,22 +81,15 @@ docker compose up -d --build
|
|||||||
## 端口与数据
|
## 端口与数据
|
||||||
|
|
||||||
- **端口**:宿主机 `8080` 映射容器 `8080`,可在 `docker-compose.yml` 中修改左侧端口,例如 `"8888:8080"`。
|
- **端口**:宿主机 `8080` 映射容器 `8080`,可在 `docker-compose.yml` 中修改左侧端口,例如 `"8888:8080"`。
|
||||||
- **数据**:工具读写路径在容器内;若需挂载宿主机目录(如音乐库、输入输出目录),在 `docker-compose.yml` 中取消 `volumes` 注释并改为实际路径。
|
- **数据**:工具读写路径在容器内通过 volume 挂载;请确保宿主机目录存在且容器内用户有读写权限。首次启动后系统会在工作根目录下自动创建 `Input/`、`Library/`、`Rejected/` 子目录。
|
||||||
|
|
||||||
## 构建说明
|
### FFmpeg
|
||||||
|
|
||||||
- **Dockerfile**:多阶段构建
|
容器内置 FFmpeg,一键导入过程中的格式转换(WAV/APE/AIFF/WV/TTA → FLAC)自动使用容器的 FFmpeg。
|
||||||
1. 使用 Node 20 构建前端(Vite),产出到 `dist`
|
|
||||||
2. 使用 Maven + JDK 8 构建后端,并将前端 `dist` 拷贝到 `src/main/resources/static`
|
|
||||||
3. 运行阶段使用 `eclipse-temurin:8-jre-alpine`,仅运行打包好的 Spring Boot jar
|
|
||||||
4. 内置健康检查(每 30 秒检查 `/api/health` 端点)
|
|
||||||
- 生产环境前端 API/WebSocket 使用相对路径,与后端同源,无需再配 CORS。
|
|
||||||
- 已配置 `.dockerignore` 优化构建上下文,加快构建速度。
|
|
||||||
- **Maven 镜像**:已配置使用阿里云 Maven 镜像加速依赖下载,解决网络问题。
|
|
||||||
|
|
||||||
## 故障排查
|
## 常见问题
|
||||||
|
|
||||||
### Maven 依赖下载失败
|
### 构建失败
|
||||||
|
|
||||||
如果构建时遇到 Maven 依赖下载失败(如 `handshake_failure` 或网络超时):
|
如果构建时遇到 Maven 依赖下载失败(如 `handshake_failure` 或网络超时):
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# 如需挂载本地目录给工具读写,可取消注释并修改路径
|
# 挂载音乐工作目录(替换 /path/to/MusicWork 为实际路径)
|
||||||
# volumes:
|
# 工作根目录下应包含 Input/ (待处理) Library/ (曲库)和 Rejected/ (被拒绝)三个子目录
|
||||||
# - /path/on/host/Input:/app/data/Input
|
volumes:
|
||||||
# - /path/on/host/Library_Final:/app/data/Library_Final
|
- /path/to/MusicWork:/home/mangtool/MusicWork
|
||||||
|
environment:
|
||||||
|
- MANGTOOL_HOME=/home/mangtool
|
||||||
|
# 如果需要覆盖 ffmpeg 路径:
|
||||||
|
# - MANGTOOL_FFMPEG_BIN=/usr/bin/ffmpeg
|
||||||
|
|||||||
+15
-2
@@ -11,7 +11,7 @@
|
|||||||
@select="handleSelect"
|
@select="handleSelect"
|
||||||
>
|
>
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
v-for="tab in tabs"
|
v-for="tab in visibleTabs"
|
||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
:index="tab.key"
|
:index="tab.key"
|
||||||
>
|
>
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
import { computed, defineAsyncComponent, ref } from 'vue';
|
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
|
const IngestTab = defineAsyncComponent(() => import('./components/IngestTab.vue'));
|
||||||
const AggregateTab = defineAsyncComponent(() => import('./components/AggregateTab.vue'));
|
const AggregateTab = defineAsyncComponent(() => import('./components/AggregateTab.vue'));
|
||||||
const ConvertTab = defineAsyncComponent(() => import('./components/ConvertTab.vue'));
|
const ConvertTab = defineAsyncComponent(() => import('./components/ConvertTab.vue'));
|
||||||
const DedupTab = defineAsyncComponent(() => import('./components/DedupTab.vue'));
|
const DedupTab = defineAsyncComponent(() => import('./components/DedupTab.vue'));
|
||||||
@@ -51,6 +52,7 @@ const MergeTab = defineAsyncComponent(() => import('./components/MergeTab.vue'))
|
|||||||
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'));
|
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'));
|
||||||
|
|
||||||
type TabKey =
|
type TabKey =
|
||||||
|
| 'ingest'
|
||||||
| 'aggregate'
|
| 'aggregate'
|
||||||
| 'convert'
|
| 'convert'
|
||||||
| 'dedup'
|
| 'dedup'
|
||||||
@@ -69,6 +71,14 @@ interface TabDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs: TabDefinition[] = [
|
const tabs: TabDefinition[] = [
|
||||||
|
{
|
||||||
|
key: 'ingest',
|
||||||
|
menuLabel: '一键导入',
|
||||||
|
badge: 'INGEST',
|
||||||
|
title: '一键导入',
|
||||||
|
subtitle: '自动扫描、校验、转码、去重、整理入库 —— 一步到位。',
|
||||||
|
component: IngestTab
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'aggregate',
|
key: 'aggregate',
|
||||||
menuLabel: '01 音频文件汇聚',
|
menuLabel: '01 音频文件汇聚',
|
||||||
@@ -132,7 +142,10 @@ const tabMap = tabs.reduce((acc, tab) => {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<TabKey, TabDefinition>);
|
}, {} as Record<TabKey, TabDefinition>);
|
||||||
|
|
||||||
const activeKey = ref<TabKey>('aggregate');
|
const activeKey = ref<TabKey>('ingest');
|
||||||
|
|
||||||
|
// 用户可见的导航菜单:仅保留一键导入 + 配置(旧版六步标签隐藏但保留功能)
|
||||||
|
const visibleTabs = computed(() => tabs.filter(t => t.key === 'ingest' || t.key === 'settings'));
|
||||||
|
|
||||||
const currentTab = computed(() => tabMap[activeKey.value] || tabs[0]);
|
const currentTab = computed(() => tabMap[activeKey.value] || tabs[0]);
|
||||||
const currentComponent = computed(() => currentTab.value.component);
|
const currentComponent = computed(() => currentTab.value.component);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export interface IngestResponse {
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestStatusResponse {
|
||||||
|
taskId: string;
|
||||||
|
running: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
total: number;
|
||||||
|
processed: number;
|
||||||
|
ingestedFiles: number;
|
||||||
|
duplicateFiles: number;
|
||||||
|
missingMetadataFiles: number;
|
||||||
|
unreadableFiles: number;
|
||||||
|
conversionFailedFiles: number;
|
||||||
|
otherRejectedFiles: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动一键导入任务
|
||||||
|
*/
|
||||||
|
export function startIngest(): Promise<IngestResponse> {
|
||||||
|
return request.post('/api/ingest/start', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前/最近一次导入任务状态
|
||||||
|
*/
|
||||||
|
export function getIngestCurrentStatus(): Promise<IngestStatusResponse> {
|
||||||
|
return request.get('/api/ingest/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入任务状态
|
||||||
|
*/
|
||||||
|
export function getIngestStatus(taskId: string): Promise<IngestStatusResponse> {
|
||||||
|
return request.get(`/api/ingest/status/${taskId}`);
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ export interface ProgressMessage {
|
|||||||
tracksMerged?: number;
|
tracksMerged?: number;
|
||||||
upgradedFiles?: number;
|
upgradedFiles?: number;
|
||||||
skippedFiles?: number;
|
skippedFiles?: number;
|
||||||
|
// ingest 任务字段
|
||||||
|
ingestedFiles?: number;
|
||||||
|
duplicateFiles?: number;
|
||||||
|
missingMetadataFiles?: number;
|
||||||
|
unreadableFiles?: number;
|
||||||
|
conversionFailedFiles?: number;
|
||||||
|
otherRejectedFiles?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProgress(taskId: string): Promise<ProgressMessage | null> {
|
export function getProgress(taskId: string): Promise<ProgressMessage | null> {
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ingest-container">
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<!-- 任务控制卡片 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||||
|
<el-card class="config-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<task-card-header :icon="Upload" title="一键导入" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="ingest-description">
|
||||||
|
<p>自动扫描 Input 目录中的音频文件,校验元数据、繁简转换、转码为 FLAC、去重后整理入 Library 目录。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form label-width="100px" label-position="left">
|
||||||
|
<el-form-item label="输入目录">
|
||||||
|
<el-input :model-value="config.inputDir" disabled>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="库目录">
|
||||||
|
<el-input :model-value="config.libraryDir" disabled>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="拒绝目录">
|
||||||
|
<el-input :model-value="config.rejectedDir" disabled>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!canStart"
|
||||||
|
@click="startIngest"
|
||||||
|
size="large"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
|
||||||
|
<span style="margin-left: 4px">{{ submitting ? '导入中...' : '开始一键导入' }}</span>
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
:disabled="!config.basePath"
|
||||||
|
@click="loadConfig"
|
||||||
|
size="default"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span style="margin-left: 4px">刷新配置</span>
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 配置提示 -->
|
||||||
|
<el-alert
|
||||||
|
v-if="!config.basePath"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
请先在<strong>配置</strong>页面设置工作根目录
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 任务进度卡片 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||||
|
<el-card class="progress-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<task-card-header :icon="DataLine" title="导入进度" :status="connectionStatus" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="!progress.taskId" class="empty-state">
|
||||||
|
<el-icon class="empty-icon"><Upload /></el-icon>
|
||||||
|
<p class="empty-text">准备就绪,点击「开始一键导入」...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="progress-content">
|
||||||
|
<!-- 统计信息 -->
|
||||||
|
<task-stats-grid :items="progressStats" />
|
||||||
|
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<div class="progress-section">
|
||||||
|
<el-progress
|
||||||
|
:percentage="percentage"
|
||||||
|
:status="progress.completed ? 'success' : progress.message?.includes('失败') ? 'exception' : undefined"
|
||||||
|
:stroke-width="12"
|
||||||
|
:show-text="true"
|
||||||
|
:format="formatProgress"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前文件 -->
|
||||||
|
<div v-if="progress.currentFile" class="current-file-section">
|
||||||
|
<el-icon class="file-icon"><Document /></el-icon>
|
||||||
|
<span class="file-text">{{ progress.currentFile }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息提示 -->
|
||||||
|
<div v-if="progress.message" class="message-section">
|
||||||
|
<el-alert
|
||||||
|
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<span>{{ progress.message }}</span>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Upload, VideoPlay, Refresh, DataLine, FolderOpened, Document } from '@element-plus/icons-vue';
|
||||||
|
import { getConfig } from '../api/config';
|
||||||
|
import type { ConfigResponse } from '../api/config';
|
||||||
|
import { startIngest as apiStartIngest, getIngestStatus } from '../api/ingest';
|
||||||
|
import type { IngestStatusResponse } from '../api/ingest';
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket';
|
||||||
|
import type { ProgressMessage } from '../composables/useWebSocket';
|
||||||
|
import TaskCardHeader from './common/TaskCardHeader.vue';
|
||||||
|
import TaskStatsGrid from './common/TaskStatsGrid.vue';
|
||||||
|
import type { StatItem } from './common/TaskStatsGrid.vue';
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
const taskId = ref<string | null>(null);
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const config = reactive<{ basePath: string; inputDir: string; libraryDir: string; rejectedDir: string }>({
|
||||||
|
basePath: '',
|
||||||
|
inputDir: '',
|
||||||
|
libraryDir: '',
|
||||||
|
rejectedDir: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 进度
|
||||||
|
const progress = reactive<ProgressMessage>({
|
||||||
|
taskId: '',
|
||||||
|
type: '',
|
||||||
|
total: 0,
|
||||||
|
processed: 0,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
currentFile: '',
|
||||||
|
message: '',
|
||||||
|
completed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接状态
|
||||||
|
const connectionStatus = ref<'connected' | 'connecting' | 'completed' | null>(null);
|
||||||
|
|
||||||
|
const canStart = computed(() => !!config.basePath && !submitting.value);
|
||||||
|
|
||||||
|
const percentage = computed(() => {
|
||||||
|
if (!progress.total || !progress.processed) return 0;
|
||||||
|
if (progress.completed) return 100;
|
||||||
|
return Math.round((progress.processed / progress.total) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressStats = computed((): StatItem[] => {
|
||||||
|
if (!progress.taskId) return [];
|
||||||
|
return [
|
||||||
|
{ label: '已处理', value: progress.processed ?? 0 },
|
||||||
|
{ label: '已入库', value: progress.ingestedFiles ?? 0, tone: 'success' },
|
||||||
|
{ label: '重复跳过', value: progress.duplicateFiles ?? 0, tone: 'warning' },
|
||||||
|
{ label: '缺元数据', value: progress.missingMetadataFiles ?? 0, tone: 'failed' },
|
||||||
|
{ label: '不可读', value: progress.unreadableFiles ?? 0, tone: 'failed' },
|
||||||
|
{ label: '转码失败', value: progress.conversionFailedFiles ?? 0, tone: 'failed' },
|
||||||
|
{ label: '其他拒绝', value: progress.otherRejectedFiles ?? 0, tone: 'failed' },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatProgress() {
|
||||||
|
if (progress.completed) return '完成';
|
||||||
|
return `${progress.processed ?? 0} / ${progress.total ?? 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket 消息处理
|
||||||
|
function handleProgressMessage(msg: ProgressMessage) {
|
||||||
|
Object.assign(progress, msg);
|
||||||
|
connectionStatus.value = msg.completed ? 'completed' : 'connected';
|
||||||
|
|
||||||
|
if (msg.completed) {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
const { connect: wsConnect, disconnect: wsDisconnect } = useWebSocket(taskId, handleProgressMessage);
|
||||||
|
|
||||||
|
// 轮询兜底
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling();
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (!taskId.value) return;
|
||||||
|
try {
|
||||||
|
const resp = await getIngestStatus(taskId.value);
|
||||||
|
if (resp) {
|
||||||
|
handleProgressMessage({
|
||||||
|
taskId: resp.taskId,
|
||||||
|
type: 'ingest',
|
||||||
|
total: resp.total,
|
||||||
|
processed: resp.processed,
|
||||||
|
success: resp.ingestedFiles,
|
||||||
|
failed: resp.otherRejectedFiles,
|
||||||
|
currentFile: '',
|
||||||
|
message: resp.message,
|
||||||
|
completed: resp.completed,
|
||||||
|
ingestedFiles: resp.ingestedFiles,
|
||||||
|
duplicateFiles: resp.duplicateFiles,
|
||||||
|
missingMetadataFiles: resp.missingMetadataFiles,
|
||||||
|
unreadableFiles: resp.unreadableFiles,
|
||||||
|
conversionFailedFiles: resp.conversionFailedFiles,
|
||||||
|
otherRejectedFiles: resp.otherRejectedFiles,
|
||||||
|
});
|
||||||
|
if (resp.completed) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略轮询错误
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始导入
|
||||||
|
async function startIngest() {
|
||||||
|
if (submitting.value) return;
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiStartIngest();
|
||||||
|
taskId.value = resp.taskId;
|
||||||
|
|
||||||
|
// 重置进度
|
||||||
|
Object.assign(progress, {
|
||||||
|
taskId: resp.taskId, type: 'ingest', total: 0, processed: 0,
|
||||||
|
success: 0, failed: 0, currentFile: '', message: '开始导入任务...',
|
||||||
|
completed: false, ingestedFiles: 0, duplicateFiles: 0,
|
||||||
|
missingMetadataFiles: 0, unreadableFiles: 0,
|
||||||
|
conversionFailedFiles: 0, otherRejectedFiles: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接 WebSocket
|
||||||
|
wsConnect();
|
||||||
|
// 开启轮询兜底
|
||||||
|
startPolling();
|
||||||
|
} catch (e: any) {
|
||||||
|
submitting.value = false;
|
||||||
|
const msg = e?.response?.data?.message || e?.message || '启动任务失败';
|
||||||
|
ElMessage.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面挂载恢复:查询是否存在正在运行或最近完成的任务
|
||||||
|
async function resumeFromCurrentTask() {
|
||||||
|
try {
|
||||||
|
const { getIngestCurrentStatus } = await import('../api/ingest');
|
||||||
|
let resp: IngestStatusResponse | null = null;
|
||||||
|
try {
|
||||||
|
resp = await getIngestCurrentStatus();
|
||||||
|
} catch {
|
||||||
|
// 没有当前任务或接口不可用
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp || (!resp.taskId && !resp.running)) return;
|
||||||
|
|
||||||
|
taskId.value = resp.taskId;
|
||||||
|
submitting.value = resp.running;
|
||||||
|
|
||||||
|
// 组装进度消息
|
||||||
|
handleProgressMessage({
|
||||||
|
taskId: resp.taskId,
|
||||||
|
type: 'ingest',
|
||||||
|
total: resp.total,
|
||||||
|
processed: resp.processed,
|
||||||
|
success: resp.ingestedFiles,
|
||||||
|
failed: resp.otherRejectedFiles,
|
||||||
|
currentFile: '',
|
||||||
|
message: resp.message || (resp.running ? '恢复轮询...' : '已完成'),
|
||||||
|
completed: resp.completed,
|
||||||
|
ingestedFiles: resp.ingestedFiles,
|
||||||
|
duplicateFiles: resp.duplicateFiles,
|
||||||
|
missingMetadataFiles: resp.missingMetadataFiles,
|
||||||
|
unreadableFiles: resp.unreadableFiles,
|
||||||
|
conversionFailedFiles: resp.conversionFailedFiles,
|
||||||
|
otherRejectedFiles: resp.otherRejectedFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.running) {
|
||||||
|
wsConnect();
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 恢复失败不阻塞页面
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const cfg: ConfigResponse | null = await getConfig();
|
||||||
|
if (cfg) {
|
||||||
|
config.basePath = cfg.basePath || '';
|
||||||
|
config.inputDir = cfg.inputDir || '';
|
||||||
|
config.libraryDir = cfg.libraryDir || '';
|
||||||
|
config.rejectedDir = cfg.rejectedDir || '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载配置失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConfig();
|
||||||
|
resumeFromCurrentTask();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (wsDisconnect) wsDisconnect();
|
||||||
|
stopPolling();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import '../styles/panel-shared.css';
|
||||||
|
|
||||||
|
.ingest-description {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,7 +29,22 @@
|
|||||||
|
|
||||||
<el-divider />
|
<el-divider />
|
||||||
|
|
||||||
<h4 class="preview-title">派生目录预览</h4>
|
<h4 class="preview-title">派生目录预览(简化模型)</h4>
|
||||||
|
<el-descriptions :column="1" size="small" border>
|
||||||
|
<el-descriptions-item label="Input">
|
||||||
|
<span class="path-text">{{ preview.input || '未配置' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="Library">
|
||||||
|
<span class="path-text">{{ preview.library || '未配置' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="Rejected">
|
||||||
|
<span class="path-text">{{ preview.rejected || '未配置' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<h4 class="preview-title">派生目录预览(旧版,向后兼容)</h4>
|
||||||
<el-descriptions :column="1" size="small" border>
|
<el-descriptions :column="1" size="small" border>
|
||||||
<el-descriptions-item label="Input (SRC_ACC_DIR)">
|
<el-descriptions-item label="Input (SRC_ACC_DIR)">
|
||||||
<span class="path-text">{{ preview.input || '未配置' }}</span>
|
<span class="path-text">{{ preview.input || '未配置' }}</span>
|
||||||
@@ -91,6 +106,8 @@ const preview = computed(() => {
|
|||||||
return {
|
return {
|
||||||
input: '',
|
input: '',
|
||||||
aggregated: '',
|
aggregated: '',
|
||||||
|
library: '',
|
||||||
|
rejected: '',
|
||||||
formatIssues: '',
|
formatIssues: '',
|
||||||
duplicates: '',
|
duplicates: '',
|
||||||
zhOutput: '',
|
zhOutput: '',
|
||||||
@@ -101,6 +118,8 @@ const preview = computed(() => {
|
|||||||
return {
|
return {
|
||||||
input: `${root}/Input`,
|
input: `${root}/Input`,
|
||||||
aggregated: `${root}/Staging_Aggregated`,
|
aggregated: `${root}/Staging_Aggregated`,
|
||||||
|
library: `${root}/Library`,
|
||||||
|
rejected: `${root}/Rejected`,
|
||||||
formatIssues: `${root}/Staging_Format_Issues`,
|
formatIssues: `${root}/Staging_Format_Issues`,
|
||||||
duplicates: `${root}/Staging_Duplicates`,
|
duplicates: `${root}/Staging_Duplicates`,
|
||||||
zhOutput: `${root}/Staging_T2S_Output`,
|
zhOutput: `${root}/Staging_T2S_Output`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref } from 'vue';
|
import { ref, type Ref, unref } from 'vue';
|
||||||
import SockJS from 'sockjs-client';
|
import SockJS from 'sockjs-client';
|
||||||
import Stomp from 'stompjs';
|
import Stomp from 'stompjs';
|
||||||
|
|
||||||
@@ -22,15 +22,31 @@ export interface ProgressMessage {
|
|||||||
tracksMerged?: number;
|
tracksMerged?: number;
|
||||||
upgradedFiles?: number;
|
upgradedFiles?: number;
|
||||||
skippedFiles?: number;
|
skippedFiles?: number;
|
||||||
|
// ingest 任务字段
|
||||||
|
ingestedFiles?: number;
|
||||||
|
duplicateFiles?: number;
|
||||||
|
missingMetadataFiles?: number;
|
||||||
|
unreadableFiles?: number;
|
||||||
|
conversionFailedFiles?: number;
|
||||||
|
otherRejectedFiles?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMessage) => void) {
|
/**
|
||||||
|
* WebSocket 连接组合式函数。
|
||||||
|
*
|
||||||
|
* @param taskIdRef 响应式任务 ID(Ref<string | null>),每次 connect 时取 .value
|
||||||
|
* @param onMessage 收到进度消息时的回调
|
||||||
|
*/
|
||||||
|
export function useWebSocket(taskIdRef: Ref<string | null>, onMessage: (msg: ProgressMessage) => void) {
|
||||||
const connected = ref(false);
|
const connected = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
let stompClient: Stomp.Client | null = null;
|
let stompClient: Stomp.Client | null = null;
|
||||||
|
// 记住订阅了哪个 taskId,以便断线重连时仍使用正确值
|
||||||
|
let currentSubscribedId: string | null = null;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (!taskId) {
|
const rawId = unref(taskIdRef);
|
||||||
|
if (!rawId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +55,8 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes
|
|||||||
disconnect();
|
disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentSubscribedId = rawId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsBase =
|
const wsBase =
|
||||||
import.meta.env.VITE_WS_BASE_URL !== undefined && import.meta.env.VITE_WS_BASE_URL !== ''
|
import.meta.env.VITE_WS_BASE_URL !== undefined && import.meta.env.VITE_WS_BASE_URL !== ''
|
||||||
@@ -46,35 +64,37 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes
|
|||||||
: (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080');
|
: (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080');
|
||||||
const socket = new SockJS(`${wsBase}/ws`);
|
const socket = new SockJS(`${wsBase}/ws`);
|
||||||
stompClient = Stomp.over(socket);
|
stompClient = Stomp.over(socket);
|
||||||
|
|
||||||
// 禁用调试日志
|
// 禁用调试日志
|
||||||
stompClient.debug = () => {};
|
stompClient.debug = () => {};
|
||||||
|
|
||||||
stompClient.connect(
|
stompClient.connect(
|
||||||
{},
|
{},
|
||||||
() => {
|
() => {
|
||||||
// 连接成功
|
|
||||||
connected.value = true;
|
connected.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
// 订阅任务进度
|
// 订阅当前 taskId 的进度主题
|
||||||
if (stompClient) {
|
const topic = `/topic/progress/${currentSubscribedId}`;
|
||||||
stompClient.subscribe(`/topic/progress/${taskId}`, (message) => {
|
stompClient!.subscribe(topic, (frame) => {
|
||||||
try {
|
try {
|
||||||
const progress: ProgressMessage = JSON.parse(message.body);
|
const body = JSON.parse(frame.body) as ProgressMessage;
|
||||||
onMessage(progress);
|
onMessage(body);
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse progress message:', e);
|
// 任务完成时自动断开
|
||||||
|
if (body.completed) {
|
||||||
|
disconnect();
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
}
|
console.error('解析 WebSocket 消息失败:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
(errorFrame) => {
|
(err: unknown) => {
|
||||||
// 连接失败
|
|
||||||
const errorMsg = errorFrame.headers?.['message'] || errorFrame.toString() || 'WebSocket 连接错误';
|
|
||||||
error.value = errorMsg;
|
|
||||||
connected.value = false;
|
connected.value = false;
|
||||||
console.error('WebSocket 连接失败:', errorMsg, errorFrame);
|
const errMsg = err instanceof Error ? err.message : 'WebSocket 连接失败';
|
||||||
|
error.value = errMsg;
|
||||||
|
console.error('WebSocket 连接失败:', err);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -93,7 +113,6 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes
|
|||||||
connected.value = false;
|
connected.value = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 如果连接未成功,直接清理
|
|
||||||
connected.value = false;
|
connected.value = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -102,6 +121,7 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes
|
|||||||
}
|
}
|
||||||
stompClient = null;
|
stompClient = null;
|
||||||
}
|
}
|
||||||
|
currentSubscribedId = null;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user