Add one-click Navidrome ingestion workflow

This commit is contained in:
2026-07-19 02:23:41 +08:00
parent 81977a157e
commit ff59f341cf
19 changed files with 2340 additions and 46 deletions
+48 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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` 或网络超时):
+8 -4
View File
@@ -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
View File
@@ -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);
+41
View File
@@ -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}`);
}
+7
View File
@@ -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> {
+369
View File
@@ -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>
+20 -1
View File
@@ -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`,
+42 -22
View File
@@ -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 响应式任务 IDRef<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;
}; };