From ff59f341cf0f41e88aae5d9c2540867aa2f3677d Mon Sep 17 00:00:00 2001 From: mangmang <362165265@qq.com> Date: Sun, 19 Jul 2026 02:23:41 +0800 Subject: [PATCH] Add one-click Navidrome ingestion workflow --- README.md | 50 +- .../music/controller/IngestController.java | 126 +++ .../java/com/music/dto/ConfigResponse.java | 3 + .../java/com/music/dto/IngestRequest.java | 11 + .../com/music/dto/IngestStatusResponse.java | 26 + .../java/com/music/dto/ProgressMessage.java | 8 + .../java/com/music/service/ConfigService.java | 22 + .../java/com/music/service/IngestService.java | 959 ++++++++++++++++++ .../service/IngestServiceInternalTest.java | 525 ++++++++++ .../service/ProgressMessageMappingTest.java | 83 ++ docker/Dockerfile | 2 +- docker/README.md | 40 +- docker/docker-compose.yml | 12 +- frontend/src/App.vue | 17 +- frontend/src/api/ingest.ts | 41 + frontend/src/api/progress.ts | 7 + frontend/src/components/IngestTab.vue | 369 +++++++ frontend/src/components/SettingsTab.vue | 21 +- frontend/src/composables/useWebSocket.ts | 64 +- 19 files changed, 2340 insertions(+), 46 deletions(-) create mode 100644 backend/src/main/java/com/music/controller/IngestController.java create mode 100644 backend/src/main/java/com/music/dto/IngestRequest.java create mode 100644 backend/src/main/java/com/music/dto/IngestStatusResponse.java create mode 100644 backend/src/main/java/com/music/service/IngestService.java create mode 100644 backend/src/test/java/com/music/service/IngestServiceInternalTest.java create mode 100644 frontend/src/api/ingest.ts create mode 100644 frontend/src/components/IngestTab.vue diff --git a/README.md b/README.md index 392cf2d..478e159 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,49 @@ -# MyTool +# MangTool —— 音乐文件一键导入工具 -我的工具箱 \ No newline at end of file +面向 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 diff --git a/backend/src/main/java/com/music/controller/IngestController.java b/backend/src/main/java/com/music/controller/IngestController.java new file mode 100644 index 0000000..52f5391 --- /dev/null +++ b/backend/src/main/java/com/music/controller/IngestController.java @@ -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 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 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 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; + } + } +} diff --git a/backend/src/main/java/com/music/dto/ConfigResponse.java b/backend/src/main/java/com/music/dto/ConfigResponse.java index b5477cb..2928c62 100644 --- a/backend/src/main/java/com/music/dto/ConfigResponse.java +++ b/backend/src/main/java/com/music/dto/ConfigResponse.java @@ -10,6 +10,9 @@ import lombok.NoArgsConstructor; public class ConfigResponse { private String basePath; private String inputDir; + private String libraryDir; + private String rejectedDir; + // 保留原有字段以保证向后兼容 private String aggregatedDir; private String formatIssuesDir; private String duplicatesDir; diff --git a/backend/src/main/java/com/music/dto/IngestRequest.java b/backend/src/main/java/com/music/dto/IngestRequest.java new file mode 100644 index 0000000..74fa77a --- /dev/null +++ b/backend/src/main/java/com/music/dto/IngestRequest.java @@ -0,0 +1,11 @@ +package com.music.dto; + +import lombok.Data; + +/** + * 一键导入请求(使用已保存的配置路径,无需额外参数) + */ +@Data +public class IngestRequest { + // 无参请求,所有路径从已保存配置读取 +} diff --git a/backend/src/main/java/com/music/dto/IngestStatusResponse.java b/backend/src/main/java/com/music/dto/IngestStatusResponse.java new file mode 100644 index 0000000..bee2bfa --- /dev/null +++ b/backend/src/main/java/com/music/dto/IngestStatusResponse.java @@ -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; +} diff --git a/backend/src/main/java/com/music/dto/ProgressMessage.java b/backend/src/main/java/com/music/dto/ProgressMessage.java index 129d2f0..0c7e39e 100644 --- a/backend/src/main/java/com/music/dto/ProgressMessage.java +++ b/backend/src/main/java/com/music/dto/ProgressMessage.java @@ -29,4 +29,12 @@ public class ProgressMessage { private Integer tracksMerged; // merge: 已合并曲目数 private Integer upgradedFiles; // 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: 其他原因拒绝文件数 } diff --git a/backend/src/main/java/com/music/service/ConfigService.java b/backend/src/main/java/com/music/service/ConfigService.java index 79c216a..f197a10 100644 --- a/backend/src/main/java/com/music/service/ConfigService.java +++ b/backend/src/main/java/com/music/service/ConfigService.java @@ -22,6 +22,9 @@ public class ConfigService { // 子目录名称常量 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_FORMAT_ISSUES = "Staging_Format_Issues"; private static final String STAGING_DUPLICATES = "Staging_Duplicates"; @@ -114,6 +117,9 @@ public class ConfigService { ConfigResponse response = new ConfigResponse(); response.setBasePath(basePath); 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.setFormatIssuesDir(PathUtils.joinPath(basePath, STAGING_FORMAT_ISSUES)); response.setDuplicatesDir(PathUtils.joinPath(basePath, STAGING_DUPLICATES)); @@ -130,6 +136,8 @@ public class ConfigService { private void createSubDirectories(String basePath) { String[] subDirs = { INPUT_DIR, + LIBRARY_DIR, + REJECTED_DIR, STAGING_AGGREGATED, STAGING_FORMAT_ISSUES, STAGING_DUPLICATES, @@ -158,6 +166,20 @@ public class ConfigService { return getDerivedPath(INPUT_DIR); } + /** + * 获取Library目录路径 + */ + public String getLibraryDir() { + return getDerivedPath(LIBRARY_DIR); + } + + /** + * 获取Rejected目录路径 + */ + public String getRejectedDir() { + return getDerivedPath(REJECTED_DIR); + } + /** * 获取Staging_Aggregated目录路径 */ diff --git a/backend/src/main/java/com/music/service/IngestService.java b/backend/src/main/java/com/music/service/IngestService.java new file mode 100644 index 0000000..b604c2c --- /dev/null +++ b/backend/src/main/java/com/music/service/IngestService.java @@ -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 目录布局。 + * + *

进度字段语义(type = "ingest"): + *

    + *
  • total:扫描到的音频文件总数
  • + *
  • processed:已处理文件数
  • + *
  • success / ingestedFiles:成功入库文件数
  • + *
  • duplicateFiles:跳过重复文件数
  • + *
  • missingMetadataFiles:缺少 Title/Artist/Album 的文件数
  • + *
  • unreadableFiles:无法读取元数据的文件数
  • + *
  • conversionFailedFiles:格式转换失败文件数
  • + *
  • failed / otherRejectedFiles:其他原因拒绝文件数
  • + *
+ */ +@Service +public class IngestService { + + private static final Logger log = LoggerFactory.getLogger(IngestService.class); + + /** 需要转换为 FLAC 的无损格式 */ + private static final Set LOSSLESS_EXTENSIONS = new HashSet<>(Arrays.asList( + "wav", "ape", "aiff", "aif", "wv", "tta" + )); + + /** 可直接保留的格式 */ + private static final Set COMPATIBLE_EXTENSIONS = new HashSet<>(Arrays.asList( + "flac", "mp3", "m4a", "aac", "ogg", "opus", "wma" + )); + + /** 所有支持的音频格式 */ + private static final Set 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; + } + + /** + * 异步执行一键导入任务。 + *

并发锁:{@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 audioFiles = new ArrayList<>(); + Files.walkFileTree(inputPath, new SimpleFileVisitor() { + @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 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 batchIdentities = new HashSet<>(); + + // 文件结果映射(用于报告) + LinkedHashMap 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 libraryIdentities, + Set 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 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 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 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 libraryIdentities, + Set 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 scanLibraryIdentities(Path libraryPath) { + Set identities = new HashSet<>(); + if (!Files.exists(libraryPath)) return identities; + + try { + Files.walkFileTree(libraryPath, new SimpleFileVisitor() { + @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; + } + } +} diff --git a/backend/src/test/java/com/music/service/IngestServiceInternalTest.java b/backend/src/test/java/com/music/service/IngestServiceInternalTest.java new file mode 100644 index 0000000..78cd8ea --- /dev/null +++ b/backend/src/test/java/com/music/service/IngestServiceInternalTest.java @@ -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 library = new java.util.HashSet<>(); + Set 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 library = new java.util.HashSet<>(); + Set 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 library = new java.util.HashSet<>(); + Set 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) {} + } +} diff --git a/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java b/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java index 16b54cc..d7d9688 100644 --- a/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java +++ b/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java @@ -5,9 +5,12 @@ import org.junit.jupiter.api.Test; import org.springframework.messaging.simp.SimpMessagingTemplate; 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.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class ProgressMessageMappingTest { @@ -76,4 +79,84 @@ class ProgressMessageMappingTest { assertEquals(3, pm.getUpgradedFiles().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); + } } diff --git a/docker/Dockerfile b/docker/Dockerfile index 353abd0..e5fe59f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -50,7 +50,7 @@ RUN mvn clean package -DskipTests -B -s /root/.m2/settings.xml FROM eclipse-temurin:8-jre-alpine 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 \ && echo "Asia/Shanghai" > /etc/timezone diff --git a/docker/README.md b/docker/README.md index e373dfe..f8ad267 100644 --- a/docker/README.md +++ b/docker/README.md @@ -16,7 +16,18 @@ git clone <仓库地址> 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:** @@ -42,12 +53,20 @@ cd docker docker compose up -d --build ``` -### 3. 访问应用 +### 4. 访问应用 浏览器打开:**http://localhost:8080** 前端与后端由同一服务提供,无需单独配置 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"`。 -- **数据**:工具读写路径在容器内;若需挂载宿主机目录(如音乐库、输入输出目录),在 `docker-compose.yml` 中取消 `volumes` 注释并改为实际路径。 +- **数据**:工具读写路径在容器内通过 volume 挂载;请确保宿主机目录存在且容器内用户有读写权限。首次启动后系统会在工作根目录下自动创建 `Input/`、`Library/`、`Rejected/` 子目录。 -## 构建说明 +### FFmpeg -- **Dockerfile**:多阶段构建 - 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 镜像加速依赖下载,解决网络问题。 +容器内置 FFmpeg,一键导入过程中的格式转换(WAV/APE/AIFF/WV/TTA → FLAC)自动使用容器的 FFmpeg。 -## 故障排查 +## 常见问题 -### Maven 依赖下载失败 +### 构建失败 如果构建时遇到 Maven 依赖下载失败(如 `handshake_failure` 或网络超时): diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d97a491..7799142 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,7 +10,11 @@ services: ports: - "8080:8080" restart: unless-stopped - # 如需挂载本地目录给工具读写,可取消注释并修改路径 - # volumes: - # - /path/on/host/Input:/app/data/Input - # - /path/on/host/Library_Final:/app/data/Library_Final + # 挂载音乐工作目录(替换 /path/to/MusicWork 为实际路径) + # 工作根目录下应包含 Input/ (待处理) Library/ (曲库)和 Rejected/ (被拒绝)三个子目录 + volumes: + - /path/to/MusicWork:/home/mangtool/MusicWork + environment: + - MANGTOOL_HOME=/home/mangtool + # 如果需要覆盖 ffmpeg 路径: + # - MANGTOOL_FFMPEG_BIN=/usr/bin/ffmpeg diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 20e1746..904151e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,7 +11,7 @@ @select="handleSelect" > @@ -42,6 +42,7 @@ import { computed, defineAsyncComponent, ref } from 'vue'; import type { Component } from 'vue'; +const IngestTab = defineAsyncComponent(() => import('./components/IngestTab.vue')); const AggregateTab = defineAsyncComponent(() => import('./components/AggregateTab.vue')); const ConvertTab = defineAsyncComponent(() => import('./components/ConvertTab.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')); type TabKey = + | 'ingest' | 'aggregate' | 'convert' | 'dedup' @@ -69,6 +71,14 @@ interface TabDefinition { } const tabs: TabDefinition[] = [ + { + key: 'ingest', + menuLabel: '一键导入', + badge: 'INGEST', + title: '一键导入', + subtitle: '自动扫描、校验、转码、去重、整理入库 —— 一步到位。', + component: IngestTab + }, { key: 'aggregate', menuLabel: '01 音频文件汇聚', @@ -132,7 +142,10 @@ const tabMap = tabs.reduce((acc, tab) => { return acc; }, {} as Record); -const activeKey = ref('aggregate'); +const activeKey = ref('ingest'); + +// 用户可见的导航菜单:仅保留一键导入 + 配置(旧版六步标签隐藏但保留功能) +const visibleTabs = computed(() => tabs.filter(t => t.key === 'ingest' || t.key === 'settings')); const currentTab = computed(() => tabMap[activeKey.value] || tabs[0]); const currentComponent = computed(() => currentTab.value.component); diff --git a/frontend/src/api/ingest.ts b/frontend/src/api/ingest.ts new file mode 100644 index 0000000..616c46d --- /dev/null +++ b/frontend/src/api/ingest.ts @@ -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 { + return request.post('/api/ingest/start', {}); +} + +/** + * 获取当前/最近一次导入任务状态 + */ +export function getIngestCurrentStatus(): Promise { + return request.get('/api/ingest/status'); +} + +/** + * 获取导入任务状态 + */ +export function getIngestStatus(taskId: string): Promise { + return request.get(`/api/ingest/status/${taskId}`); +} diff --git a/frontend/src/api/progress.ts b/frontend/src/api/progress.ts index 3a8f3ef..4340449 100644 --- a/frontend/src/api/progress.ts +++ b/frontend/src/api/progress.ts @@ -20,6 +20,13 @@ export interface ProgressMessage { tracksMerged?: number; upgradedFiles?: number; skippedFiles?: number; + // ingest 任务字段 + ingestedFiles?: number; + duplicateFiles?: number; + missingMetadataFiles?: number; + unreadableFiles?: number; + conversionFailedFiles?: number; + otherRejectedFiles?: number; } export function getProgress(taskId: string): Promise { diff --git a/frontend/src/components/IngestTab.vue b/frontend/src/components/IngestTab.vue new file mode 100644 index 0000000..a1505a8 --- /dev/null +++ b/frontend/src/components/IngestTab.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/frontend/src/components/SettingsTab.vue b/frontend/src/components/SettingsTab.vue index 5af78be..761ee52 100644 --- a/frontend/src/components/SettingsTab.vue +++ b/frontend/src/components/SettingsTab.vue @@ -29,7 +29,22 @@ -

派生目录预览

+

派生目录预览(简化模型)

+ + + {{ preview.input || '未配置' }} + + + {{ preview.library || '未配置' }} + + + {{ preview.rejected || '未配置' }} + + + + + +

派生目录预览(旧版,向后兼容)

{{ preview.input || '未配置' }} @@ -91,6 +106,8 @@ const preview = computed(() => { return { input: '', aggregated: '', + library: '', + rejected: '', formatIssues: '', duplicates: '', zhOutput: '', @@ -101,6 +118,8 @@ const preview = computed(() => { return { input: `${root}/Input`, aggregated: `${root}/Staging_Aggregated`, + library: `${root}/Library`, + rejected: `${root}/Rejected`, formatIssues: `${root}/Staging_Format_Issues`, duplicates: `${root}/Staging_Duplicates`, zhOutput: `${root}/Staging_T2S_Output`, diff --git a/frontend/src/composables/useWebSocket.ts b/frontend/src/composables/useWebSocket.ts index 3b35252..257e798 100644 --- a/frontend/src/composables/useWebSocket.ts +++ b/frontend/src/composables/useWebSocket.ts @@ -1,4 +1,4 @@ -import { ref } from 'vue'; +import { ref, type Ref, unref } from 'vue'; import SockJS from 'sockjs-client'; import Stomp from 'stompjs'; @@ -22,15 +22,31 @@ export interface ProgressMessage { tracksMerged?: number; upgradedFiles?: 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),每次 connect 时取 .value + * @param onMessage 收到进度消息时的回调 + */ +export function useWebSocket(taskIdRef: Ref, onMessage: (msg: ProgressMessage) => void) { const connected = ref(false); const error = ref(null); let stompClient: Stomp.Client | null = null; + // 记住订阅了哪个 taskId,以便断线重连时仍使用正确值 + let currentSubscribedId: string | null = null; const connect = () => { - if (!taskId) { + const rawId = unref(taskIdRef); + if (!rawId) { return; } @@ -39,6 +55,8 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes disconnect(); } + currentSubscribedId = rawId; + try { const wsBase = 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'); const socket = new SockJS(`${wsBase}/ws`); stompClient = Stomp.over(socket); - + // 禁用调试日志 stompClient.debug = () => {}; stompClient.connect( {}, () => { - // 连接成功 connected.value = true; error.value = null; - - // 订阅任务进度 - if (stompClient) { - stompClient.subscribe(`/topic/progress/${taskId}`, (message) => { - try { - const progress: ProgressMessage = JSON.parse(message.body); - onMessage(progress); - } catch (e) { - console.error('Failed to parse progress message:', e); + + // 订阅当前 taskId 的进度主题 + const topic = `/topic/progress/${currentSubscribedId}`; + stompClient!.subscribe(topic, (frame) => { + try { + const body = JSON.parse(frame.body) as ProgressMessage; + onMessage(body); + + // 任务完成时自动断开 + if (body.completed) { + disconnect(); } - }); - } + } catch (e) { + console.error('解析 WebSocket 消息失败:', e); + } + }); }, - (errorFrame) => { - // 连接失败 - const errorMsg = errorFrame.headers?.['message'] || errorFrame.toString() || 'WebSocket 连接错误'; - error.value = errorMsg; + (err: unknown) => { 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) { @@ -93,7 +113,6 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes connected.value = false; }); } else { - // 如果连接未成功,直接清理 connected.value = false; } } catch (e) { @@ -102,6 +121,7 @@ export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMes } stompClient = null; } + currentSubscribedId = null; error.value = null; };