From 89be3ba0bdab3e163e7c2fb59c748ca122b3a445 Mon Sep 17 00:00:00 2001 From: liu <362165265@qq.com> Date: Fri, 30 Jan 2026 00:04:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pom.xml | 7 + .../main/java/com/music/common/PathUtils.java | 104 ++ .../music/controller/ConfigController.java | 40 + .../com/music/controller/MergeController.java | 59 ++ .../music/controller/OrganizeController.java | 65 ++ .../java/com/music/dto/ConfigRequest.java | 12 + .../java/com/music/dto/ConfigResponse.java | 19 + .../main/java/com/music/dto/MergeRequest.java | 34 + .../java/com/music/dto/OrganizeRequest.java | 44 + .../java/com/music/service/ConfigService.java | 213 ++++ .../music/service/LibraryMergeService.java | 350 +++++++ .../com/music/service/OrganizeService.java | 473 +++++++++ .../com/music/service/ZhConvertService.java | 89 +- frontend/src/api/config.ts | 30 + frontend/src/api/merge.ts | 19 + frontend/src/api/organize.ts | 21 + frontend/src/components/AggregateTab.vue | 27 +- frontend/src/components/ConvertTab.vue | 361 ++++++- frontend/src/components/DedupTab.vue | 558 +++++++++- frontend/src/components/MergeTab.vue | 950 ++++++++++++++++-- frontend/src/components/RenameTab.vue | 871 ++++++++++++++-- frontend/src/components/SettingsTab.vue | 198 +++- .../src/components/TraditionalFilterTab.vue | 569 ++++++++++- 23 files changed, 4934 insertions(+), 179 deletions(-) create mode 100644 backend/src/main/java/com/music/common/PathUtils.java create mode 100644 backend/src/main/java/com/music/controller/ConfigController.java create mode 100644 backend/src/main/java/com/music/controller/MergeController.java create mode 100644 backend/src/main/java/com/music/controller/OrganizeController.java create mode 100644 backend/src/main/java/com/music/dto/ConfigRequest.java create mode 100644 backend/src/main/java/com/music/dto/ConfigResponse.java create mode 100644 backend/src/main/java/com/music/dto/MergeRequest.java create mode 100644 backend/src/main/java/com/music/dto/OrganizeRequest.java create mode 100644 backend/src/main/java/com/music/service/ConfigService.java create mode 100644 backend/src/main/java/com/music/service/LibraryMergeService.java create mode 100644 backend/src/main/java/com/music/service/OrganizeService.java create mode 100644 frontend/src/api/config.ts create mode 100644 frontend/src/api/merge.ts create mode 100644 frontend/src/api/organize.ts diff --git a/backend/pom.xml b/backend/pom.xml index 1afb35e..eca2bdf 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -65,6 +65,13 @@ jaudiotagger 2.2.5 + + + + com.belerweb + pinyin4j + 2.5.1 + diff --git a/backend/src/main/java/com/music/common/PathUtils.java b/backend/src/main/java/com/music/common/PathUtils.java new file mode 100644 index 0000000..aefd97e --- /dev/null +++ b/backend/src/main/java/com/music/common/PathUtils.java @@ -0,0 +1,104 @@ +package com.music.common; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 路径工具类,用于处理跨平台路径兼容性 + */ +public class PathUtils { + + /** + * 规范化路径,去除多余的路径分隔符和相对路径部分 + */ + public static String normalizePath(String path) { + if (path == null || path.isEmpty()) { + return path; + } + // 使用Java的Path API进行规范化 + Path normalized = Paths.get(path).normalize(); + return normalized.toString(); + } + + /** + * 将路径转换为POSIX格式(使用/作为分隔符) + * 用于前端和后端之间的路径传输 + */ + public static String toPosixPath(String path) { + if (path == null || path.isEmpty()) { + return path; + } + // 统一使用 / 作为分隔符 + return path.replace('\\', '/'); + } + + /** + * 将路径转换为平台特定格式 + * 用于实际的文件系统操作 + */ + public static String toPlatformPath(String path) { + if (path == null || path.isEmpty()) { + return path; + } + // Java的Path API会自动处理平台差异 + return Paths.get(path).toString(); + } + + /** + * 验证路径是否合法 + * 注意:Windows路径中的冒号(:)在盘符后是合法的,需要特殊处理 + */ + public static boolean isValidPath(String path) { + if (path == null || path.isEmpty()) { + return false; + } + + // 提取路径中需要验证的部分 + String pathToValidate = path; + + // 如果是Windows路径(如 C:\ 或 C:/),移除盘符部分 + if (pathToValidate.matches("^[A-Za-z]:[/\\\\].*")) { + pathToValidate = pathToValidate.substring(2); + } + + // 检查是否包含非法字符:< > " | ? * + // 注意:冒号(:)在Windows盘符后是合法的,已在上一步移除 + String invalidChars = "[<>\"|?*]"; + if (pathToValidate.matches(".*" + invalidChars + ".*")) { + return false; + } + + return true; + } + + /** + * 拼接路径,使用平台无关的方式 + * 返回POSIX格式的路径(用于前后端传输) + */ + public static String joinPath(String parent, String child) { + if (parent == null || parent.isEmpty()) { + return child; + } + if (child == null || child.isEmpty()) { + return parent; + } + + // 统一转换为POSIX格式 + String parentPosix = toPosixPath(parent).replaceAll("/+$", ""); + String childPosix = toPosixPath(child).replaceAll("^/+", ""); + + return parentPosix + "/" + childPosix; + } + + /** + * 规范化并转换为POSIX格式 + * 用于保存配置时统一格式 + */ + public static String normalizeAndToPosix(String path) { + if (path == null || path.isEmpty()) { + return path; + } + String normalized = normalizePath(path); + return toPosixPath(normalized); + } +} diff --git a/backend/src/main/java/com/music/controller/ConfigController.java b/backend/src/main/java/com/music/controller/ConfigController.java new file mode 100644 index 0000000..592c07c --- /dev/null +++ b/backend/src/main/java/com/music/controller/ConfigController.java @@ -0,0 +1,40 @@ +package com.music.controller; + +import com.music.common.Result; +import com.music.dto.ConfigRequest; +import com.music.dto.ConfigResponse; +import com.music.service.ConfigService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/config") +@Validated +public class ConfigController { + + private final ConfigService configService; + + public ConfigController(ConfigService configService) { + this.configService = configService; + } + + /** + * 保存基础路径配置 + */ + @PostMapping("/base-path") + public Result saveBasePath(@Valid @RequestBody ConfigRequest request) { + configService.saveBasePath(request.getBasePath()); + return Result.success("配置保存成功", null); + } + + /** + * 获取完整配置(包含所有派生路径) + */ + @GetMapping("/base-path") + public Result getConfig() { + ConfigResponse config = configService.getConfig(); + return Result.success(config); + } +} diff --git a/backend/src/main/java/com/music/controller/MergeController.java b/backend/src/main/java/com/music/controller/MergeController.java new file mode 100644 index 0000000..84134d2 --- /dev/null +++ b/backend/src/main/java/com/music/controller/MergeController.java @@ -0,0 +1,59 @@ +package com.music.controller; + +import com.music.common.Result; +import com.music.dto.MergeRequest; +import com.music.exception.BusinessException; +import com.music.service.LibraryMergeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.UUID; + +/** + * 整理入库任务控制器 + */ +@RestController +@RequestMapping("/api/merge") +@Validated +public class MergeController { + + private final LibraryMergeService libraryMergeService; + + public MergeController(LibraryMergeService libraryMergeService) { + this.libraryMergeService = libraryMergeService; + } + + /** + * 启动整理入库任务 + */ + @PostMapping("/start") + public Result start(@Valid @RequestBody MergeRequest request) { + String taskId = UUID.randomUUID().toString(); + libraryMergeService.merge( + taskId, + request.getSrcDir(), + request.getDstDir(), + request.isSmartUpgrade(), + request.isKeepBackup() + ); + + return Result.success(new StartResponse(taskId)); + } + + public 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/controller/OrganizeController.java b/backend/src/main/java/com/music/controller/OrganizeController.java new file mode 100644 index 0000000..46bb760 --- /dev/null +++ b/backend/src/main/java/com/music/controller/OrganizeController.java @@ -0,0 +1,65 @@ +package com.music.controller; + +import com.music.common.Result; +import com.music.dto.OrganizeRequest; +import com.music.exception.BusinessException; +import com.music.service.OrganizeService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.UUID; + +/** + * 音乐整理任务控制器 + */ +@RestController +@RequestMapping("/api/organize") +@Validated +public class OrganizeController { + + private final OrganizeService organizeService; + + public OrganizeController(OrganizeService organizeService) { + this.organizeService = organizeService; + } + + /** + * 启动音乐整理任务 + */ + @PostMapping("/start") + public Result start(@Valid @RequestBody OrganizeRequest request) { + if (!"strict".equalsIgnoreCase(request.getMode()) && !"lenient".equalsIgnoreCase(request.getMode())) { + throw new BusinessException(400, "模式参数错误,必须是 strict 或 lenient"); + } + + String taskId = UUID.randomUUID().toString(); + organizeService.organize( + taskId, + request.getSrcDir(), + request.getDstDir(), + request.getMode(), + request.isExtractCover(), + request.isExtractLyrics(), + request.isGenerateReport() + ); + + return Result.success(new OrganizeController.StartResponse(taskId)); + } + + public 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/ConfigRequest.java b/backend/src/main/java/com/music/dto/ConfigRequest.java new file mode 100644 index 0000000..94b4b91 --- /dev/null +++ b/backend/src/main/java/com/music/dto/ConfigRequest.java @@ -0,0 +1,12 @@ +package com.music.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +public class ConfigRequest { + + @NotBlank(message = "根路径不能为空") + private String basePath; +} diff --git a/backend/src/main/java/com/music/dto/ConfigResponse.java b/backend/src/main/java/com/music/dto/ConfigResponse.java new file mode 100644 index 0000000..b5477cb --- /dev/null +++ b/backend/src/main/java/com/music/dto/ConfigResponse.java @@ -0,0 +1,19 @@ +package com.music.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ConfigResponse { + private String basePath; + private String inputDir; + private String aggregatedDir; + private String formatIssuesDir; + private String duplicatesDir; + private String zhOutputDir; + private String organizedDir; + private String libraryFinalDir; +} diff --git a/backend/src/main/java/com/music/dto/MergeRequest.java b/backend/src/main/java/com/music/dto/MergeRequest.java new file mode 100644 index 0000000..7abeb75 --- /dev/null +++ b/backend/src/main/java/com/music/dto/MergeRequest.java @@ -0,0 +1,34 @@ +package com.music.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 整理入库任务请求 + */ +@Data +public class MergeRequest { + + /** + * 源目录(staging,已整理完成的目录) + */ + @NotBlank(message = "源目录不能为空") + private String srcDir; + + /** + * 目标目录(Navidrome 主库根目录) + */ + @NotBlank(message = "目标目录不能为空") + private String dstDir; + + /** + * 是否启用智能升级(文件大小优先策略) + */ + private boolean smartUpgrade = true; + + /** + * 是否保留旧版本备份 + */ + private boolean keepBackup = false; +} diff --git a/backend/src/main/java/com/music/dto/OrganizeRequest.java b/backend/src/main/java/com/music/dto/OrganizeRequest.java new file mode 100644 index 0000000..04b46cd --- /dev/null +++ b/backend/src/main/java/com/music/dto/OrganizeRequest.java @@ -0,0 +1,44 @@ +package com.music.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 音乐整理任务请求 + */ +@Data +public class OrganizeRequest { + + /** + * 源目录(staging,已汇聚/转码/去重后的目录) + */ + @NotBlank(message = "源目录不能为空") + private String srcDir; + + /** + * 目标目录(规范输出目录或正式库) + */ + @NotBlank(message = "目标目录不能为空") + private String dstDir; + + /** + * 标签完整度模式:strict 严格(需 Title+Artist+Album),lenient 宽松(仅需 Title) + */ + private String mode = "strict"; + + /** + * 是否提取封面到专辑目录 cover.jpg + */ + private boolean extractCover = true; + + /** + * 是否提取内嵌歌词到 .lrc + */ + private boolean extractLyrics = true; + + /** + * 是否生成整理报告到 _Reports/ + */ + private boolean generateReport = true; +} diff --git a/backend/src/main/java/com/music/service/ConfigService.java b/backend/src/main/java/com/music/service/ConfigService.java new file mode 100644 index 0000000..79c216a --- /dev/null +++ b/backend/src/main/java/com/music/service/ConfigService.java @@ -0,0 +1,213 @@ +package com.music.service; + +import com.music.common.PathUtils; +import com.music.dto.ConfigResponse; +import com.music.exception.BusinessException; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +/** + * 配置服务类,负责保存和读取全局路径配置 + */ +@Service +public class ConfigService { + + private static final String CONFIG_FILE_NAME = "config.properties"; + private static final String BASE_PATH_KEY = "basePath"; + + // 子目录名称常量 + private static final String INPUT_DIR = "Input"; + 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"; + private static final String STAGING_T2S_OUTPUT = "Staging_T2S_Output"; + private static final String STAGING_ORGANIZED = "Staging_Organized"; + private static final String LIBRARY_FINAL = "Library_Final"; + + /** + * 获取配置文件路径 + */ + private Path getConfigFilePath() { + // 配置文件保存在用户目录下的 .mangtool 文件夹中 + String userHome = System.getProperty("user.home"); + Path configDir = Paths.get(userHome, ".mangtool"); + try { + Files.createDirectories(configDir); + } catch (IOException e) { + throw new BusinessException(500, "无法创建配置目录: " + e.getMessage()); + } + return configDir.resolve(CONFIG_FILE_NAME); + } + + /** + * 保存基础路径配置 + */ + public void saveBasePath(String basePath) { + // 验证路径 + if (!PathUtils.isValidPath(basePath)) { + throw new BusinessException(400, "路径包含非法字符,请检查"); + } + + // 规范化路径并转换为POSIX格式(用于存储) + String normalizedPath = PathUtils.normalizeAndToPosix(basePath); + + // 验证路径是否存在(如果不存在,尝试创建) + // 使用原始路径进行文件系统操作(Java会自动处理平台差异) + Path pathObj = Paths.get(basePath); + if (!Files.exists(pathObj)) { + try { + Files.createDirectories(pathObj); + } catch (IOException e) { + throw new BusinessException(400, "无法创建根目录: " + e.getMessage()); + } + } else if (!Files.isDirectory(pathObj)) { + throw new BusinessException(400, "指定路径不是目录"); + } + + // 创建所有子目录(使用原始路径) + createSubDirectories(basePath); + + // 保存配置到文件 + Properties props = new Properties(); + props.setProperty(BASE_PATH_KEY, normalizedPath); + + Path configFile = getConfigFilePath(); + try { + props.store(Files.newOutputStream(configFile), "MangTool Configuration"); + } catch (IOException e) { + throw new BusinessException(500, "保存配置失败: " + e.getMessage()); + } + } + + /** + * 获取基础路径 + */ + public String getBasePath() { + Path configFile = getConfigFilePath(); + if (!Files.exists(configFile)) { + return null; + } + + Properties props = new Properties(); + try { + props.load(Files.newInputStream(configFile)); + return props.getProperty(BASE_PATH_KEY); + } catch (IOException e) { + throw new BusinessException(500, "读取配置失败: " + e.getMessage()); + } + } + + /** + * 获取完整配置响应(包含所有派生路径) + */ + public ConfigResponse getConfig() { + String basePath = getBasePath(); + if (basePath == null || basePath.isEmpty()) { + return null; + } + + ConfigResponse response = new ConfigResponse(); + response.setBasePath(basePath); + response.setInputDir(PathUtils.joinPath(basePath, INPUT_DIR)); + response.setAggregatedDir(PathUtils.joinPath(basePath, STAGING_AGGREGATED)); + response.setFormatIssuesDir(PathUtils.joinPath(basePath, STAGING_FORMAT_ISSUES)); + response.setDuplicatesDir(PathUtils.joinPath(basePath, STAGING_DUPLICATES)); + response.setZhOutputDir(PathUtils.joinPath(basePath, STAGING_T2S_OUTPUT)); + response.setOrganizedDir(PathUtils.joinPath(basePath, STAGING_ORGANIZED)); + response.setLibraryFinalDir(PathUtils.joinPath(basePath, LIBRARY_FINAL)); + + return response; + } + + /** + * 创建所有子目录 + */ + private void createSubDirectories(String basePath) { + String[] subDirs = { + INPUT_DIR, + STAGING_AGGREGATED, + STAGING_FORMAT_ISSUES, + STAGING_DUPLICATES, + STAGING_T2S_OUTPUT, + STAGING_ORGANIZED, + LIBRARY_FINAL + }; + + // 使用Java的Path API处理路径,自动适配平台 + Path basePathObj = Paths.get(basePath); + for (String subDir : subDirs) { + Path dirPath = basePathObj.resolve(subDir); + try { + Files.createDirectories(dirPath); + } catch (IOException e) { + throw new BusinessException(500, + String.format("无法创建子目录 %s: %s", subDir, e.getMessage())); + } + } + } + + /** + * 获取Input目录路径 + */ + public String getInputDir() { + return getDerivedPath(INPUT_DIR); + } + + /** + * 获取Staging_Aggregated目录路径 + */ + public String getAggregatedDir() { + return getDerivedPath(STAGING_AGGREGATED); + } + + /** + * 获取Staging_Format_Issues目录路径 + */ + public String getFormatIssuesDir() { + return getDerivedPath(STAGING_FORMAT_ISSUES); + } + + /** + * 获取Staging_Duplicates目录路径 + */ + public String getDuplicatesDir() { + return getDerivedPath(STAGING_DUPLICATES); + } + + /** + * 获取Staging_T2S_Output目录路径 + */ + public String getZhOutputDir() { + return getDerivedPath(STAGING_T2S_OUTPUT); + } + + /** + * 获取Staging_Organized目录路径 + */ + public String getOrganizedDir() { + return getDerivedPath(STAGING_ORGANIZED); + } + + /** + * 获取Library_Final目录路径 + */ + public String getLibraryFinalDir() { + return getDerivedPath(LIBRARY_FINAL); + } + + /** + * 获取派生路径的通用方法 + */ + private String getDerivedPath(String subDir) { + String basePath = getBasePath(); + if (basePath == null || basePath.isEmpty()) { + return null; + } + return PathUtils.joinPath(basePath, subDir); + } +} diff --git a/backend/src/main/java/com/music/service/LibraryMergeService.java b/backend/src/main/java/com/music/service/LibraryMergeService.java new file mode 100644 index 0000000..1b77bd0 --- /dev/null +++ b/backend/src/main/java/com/music/service/LibraryMergeService.java @@ -0,0 +1,350 @@ +package com.music.service; + +import com.music.dto.ProgressMessage; +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 javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 整理入库服务:将整理好的 staging 目录智能合并到 Navidrome 主库中 + */ +@Service +public class LibraryMergeService { + + private static final Logger log = LoggerFactory.getLogger(LibraryMergeService.class); + + private static final Set AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList( + "mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape", "aiff", "aif", "wv", "tta", "opus" + )); + + private static final Set LYRICS_EXTENSIONS = new HashSet<>(Arrays.asList("lrc")); + private static final Set COVER_NAMES = new HashSet<>(Arrays.asList("cover.jpg", "cover.png", "folder.jpg", "folder.png")); + + private final SimpMessagingTemplate messagingTemplate; + private final ProgressStore progressStore; + + public LibraryMergeService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) { + this.messagingTemplate = messagingTemplate; + this.progressStore = progressStore; + } + + @Async + public void merge(String taskId, String srcDir, String dstDir, boolean smartUpgrade, boolean keepBackup) { + Path srcPath = Paths.get(srcDir); + Path dstPath = Paths.get(dstDir); + + try { + // 基本校验 + if (srcDir == null || srcDir.trim().isEmpty()) { + sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录不能为空", true); + return; + } + if (!Files.exists(srcPath) || !Files.isDirectory(srcPath)) { + sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录不存在或不是目录", true); + return; + } + if (dstDir == null || dstDir.trim().isEmpty()) { + sendProgress(taskId, 0, 0, 0, 0, 0, null, "目标目录不能为空", true); + return; + } + if (srcPath.normalize().equals(dstPath.normalize())) { + sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录与目标目录不能相同", true); + return; + } + + if (!Files.exists(dstPath)) { + Files.createDirectories(dstPath); + } + + // 收集源目录中的所有文件(音频、歌词、封面) + List audioFiles = new ArrayList<>(); + Map lyricsMap = new HashMap<>(); // 音频文件 -> 歌词文件 + Map coverMap = new HashMap<>(); // 专辑目录 -> 封面文件 + + Files.walkFileTree(srcPath, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + String fileName = file.getFileName().toString().toLowerCase(); + String ext = getExtension(fileName); + + if (isAudioFile(file)) { + audioFiles.add(file); + // 查找同目录下的歌词文件 + Path lyrics = findLyricsFile(file); + if (lyrics != null) { + lyricsMap.put(file, lyrics); + } + } else if (isCoverFile(fileName)) { + // 封面文件,关联到父目录 + Path albumDir = file.getParent(); + if (albumDir != null) { + coverMap.put(albumDir, file); + } + } + return FileVisitResult.CONTINUE; + } + }); + + int total = audioFiles.size(); + if (total == 0) { + sendProgress(taskId, 0, 0, 0, 0, 0, null, "未在源目录中找到音频文件", true); + return; + } + + AtomicInteger processed = new AtomicInteger(0); + AtomicInteger albumsMerged = new AtomicInteger(0); + AtomicInteger tracksMerged = new AtomicInteger(0); + AtomicInteger upgraded = new AtomicInteger(0); + AtomicInteger skipped = new AtomicInteger(0); + + Set processedAlbums = new HashSet<>(); + + sendProgress(taskId, total, 0, 0, 0, 0, null, "开始合并...", false); + + for (Path audioFile : audioFiles) { + try { + // 计算相对路径(相对于源目录) + Path relativePath = srcPath.relativize(audioFile); + Path targetPath = dstPath.resolve(relativePath); + + // 获取专辑目录(用于统计和封面处理) + Path albumDir = targetPath.getParent(); + String albumKey = albumDir != null ? albumDir.toString() : ""; + + boolean isNewAlbum = !processedAlbums.contains(albumKey); + if (isNewAlbum) { + processedAlbums.add(albumKey); + albumsMerged.incrementAndGet(); + } + + // 处理音频文件 + boolean wasUpgraded = false; + if (Files.exists(targetPath)) { + // 目标文件已存在,需要判断是否升级 + if (smartUpgrade) { + long srcSize = Files.size(audioFile); + long dstSize = Files.size(targetPath); + if (srcSize > dstSize * 1.1) { // 新文件明显更大(10%阈值) + // 备份旧文件(如果需要) + if (keepBackup) { + Path backupPath = targetPath.resolveSibling( + targetPath.getFileName().toString() + ".backup"); + Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING); + } + Files.copy(audioFile, targetPath, StandardCopyOption.REPLACE_EXISTING); + wasUpgraded = true; + upgraded.incrementAndGet(); + } else { + skipped.incrementAndGet(); + int p = processed.incrementAndGet(); + if (p % 20 == 0 || p == total) { + sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(), + upgraded.get(), audioFile.getFileName().toString(), + String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false); + } + continue; // 跳过,不升级 + } + } else { + // 不启用智能升级,直接跳过已存在的文件 + skipped.incrementAndGet(); + int p = processed.incrementAndGet(); + if (p % 20 == 0 || p == total) { + sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(), + upgraded.get(), audioFile.getFileName().toString(), + String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false); + } + continue; + } + } else { + // 新文件,直接复制 + Files.createDirectories(targetPath.getParent()); + Files.copy(audioFile, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + + tracksMerged.incrementAndGet(); + + // 同步关联的歌词文件 + Path lyricsFile = lyricsMap.get(audioFile); + if (lyricsFile != null && Files.exists(lyricsFile)) { + Path lyricsTarget = targetPath.resolveSibling(lyricsFile.getFileName().toString()); + if (!Files.exists(lyricsTarget) || wasUpgraded) { + Files.copy(lyricsFile, lyricsTarget, StandardCopyOption.REPLACE_EXISTING); + } + } + + // 处理封面(每个专辑目录只处理一次) + if (isNewAlbum && albumDir != null) { + Path srcAlbumDir = audioFile.getParent(); + Path coverFile = coverMap.get(srcAlbumDir); + if (coverFile != null && Files.exists(coverFile)) { + Path coverTarget = albumDir.resolve("cover.jpg"); + if (!Files.exists(coverTarget)) { + // 目标目录没有封面,直接复制 + Files.copy(coverFile, coverTarget, StandardCopyOption.REPLACE_EXISTING); + } else { + // 比较封面,保留更好的版本 + if (isBetterCover(coverFile, coverTarget)) { + if (keepBackup) { + Path backupPath = coverTarget.resolveSibling("cover.jpg.backup"); + Files.copy(coverTarget, backupPath, StandardCopyOption.REPLACE_EXISTING); + } + Files.copy(coverFile, coverTarget, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + + } catch (Exception e) { + log.warn("合并文件失败: {} - {}", audioFile, e.getMessage()); + skipped.incrementAndGet(); + } + + int p = processed.incrementAndGet(); + if (p % 20 == 0 || p == total) { + sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(), + upgraded.get(), audioFile.getFileName().toString(), + String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false); + } + } + + sendProgress(taskId, total, processed.get(), albumsMerged.get(), tracksMerged.get(), + upgraded.get(), null, + String.format("合并完成!专辑: %d, 曲目: %d, 升级: %d, 跳过: %d", + albumsMerged.get(), tracksMerged.get(), upgraded.get(), skipped.get()), + true); + + } catch (Exception e) { + log.error("合并任务执行失败", e); + sendProgress(taskId, 0, 0, 0, 0, 0, null, "任务执行失败: " + e.getMessage(), true); + } + } + + /** + * 判断是否为音频文件 + */ + private boolean isAudioFile(Path file) { + String ext = getExtension(file.getFileName().toString()); + return ext != null && AUDIO_EXTENSIONS.contains(ext.toLowerCase()); + } + + /** + * 判断是否为歌词文件 + */ + private boolean isLyricsFile(String fileName) { + String ext = getExtension(fileName); + return ext != null && LYRICS_EXTENSIONS.contains(ext.toLowerCase()); + } + + /** + * 判断是否为封面文件 + */ + private boolean isCoverFile(String fileName) { + return COVER_NAMES.contains(fileName.toLowerCase()); + } + + /** + * 查找音频文件对应的歌词文件 + */ + private Path findLyricsFile(Path audioFile) { + Path parent = audioFile.getParent(); + if (parent == null) { + return null; + } + + String baseName = getBaseName(audioFile.getFileName().toString()); + for (String ext : LYRICS_EXTENSIONS) { + Path lyricsPath = parent.resolve(baseName + "." + ext); + if (Files.exists(lyricsPath)) { + return lyricsPath; + } + } + return null; + } + + /** + * 比较两个封面文件,判断新封面是否更好 + * 策略:优先比较分辨率,其次比较文件大小 + */ + private boolean isBetterCover(Path newCover, Path existingCover) { + try { + BufferedImage newImg = ImageIO.read(newCover.toFile()); + BufferedImage existingImg = ImageIO.read(existingCover.toFile()); + + if (newImg == null || existingImg == null) { + // 无法读取图片,比较文件大小 + return Files.size(newCover) > Files.size(existingCover); + } + + int newPixels = newImg.getWidth() * newImg.getHeight(); + int existingPixels = existingImg.getWidth() * existingImg.getHeight(); + + if (newPixels > existingPixels * 1.1) { + // 新封面分辨率明显更高(10%阈值) + return true; + } else if (newPixels < existingPixels * 0.9) { + // 新封面分辨率明显更低 + return false; + } else { + // 分辨率相近,比较文件大小 + return Files.size(newCover) > Files.size(existingCover); + } + } catch (IOException e) { + log.debug("比较封面失败: {} vs {}", newCover, existingCover, e); + // 比较失败,使用文件大小作为后备策略 + try { + return Files.size(newCover) > Files.size(existingCover); + } catch (IOException ex) { + return false; + } + } + } + + /** + * 获取文件扩展名 + */ + private String getExtension(String fileName) { + int i = fileName.lastIndexOf('.'); + return (i > 0 && i < fileName.length() - 1) ? fileName.substring(i + 1) : ""; + } + + /** + * 获取文件名(不含扩展名) + */ + private String getBaseName(String fileName) { + int i = fileName.lastIndexOf('.'); + return i > 0 ? fileName.substring(0, i) : fileName; + } + + /** + * 发送进度消息 + */ + private void sendProgress(String taskId, int total, int processed, int albumsMerged, + int tracksMerged, int upgraded, String currentFile, String message, boolean completed) { + try { + ProgressMessage pm = new ProgressMessage(); + pm.setTaskId(taskId); + pm.setType("merge"); + pm.setTotal(total); + pm.setProcessed(processed); + pm.setSuccess(albumsMerged); // 使用 success 字段存储专辑数 + pm.setFailed(tracksMerged); // 使用 failed 字段存储曲目数 + pm.setCurrentFile(currentFile); + pm.setMessage(message); + pm.setCompleted(completed); + progressStore.put(pm); + messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm); + } catch (Exception e) { + log.error("发送进度失败", e); + } + } +} diff --git a/backend/src/main/java/com/music/service/OrganizeService.java b/backend/src/main/java/com/music/service/OrganizeService.java new file mode 100644 index 0000000..b9a6ffc --- /dev/null +++ b/backend/src/main/java/com/music/service/OrganizeService.java @@ -0,0 +1,473 @@ +package com.music.service; + +import com.music.dto.ProgressMessage; +import net.sourceforge.pinyin4j.PinyinHelper; +import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType; +import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat; +import net.sourceforge.pinyin4j.format.HanyuPinyinToneType; +import net.sourceforge.pinyin4j.format.exception.BadHanyuPinyinOutputFormatCombination; +import org.jaudiotagger.audio.AudioFile; +import org.jaudiotagger.audio.AudioFileIO; +import org.jaudiotagger.audio.flac.metadatablock.MetadataBlockDataPicture; +import org.jaudiotagger.tag.Tag; +import org.jaudiotagger.tag.FieldKey; +import org.jaudiotagger.tag.flac.FlacTag; +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 javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; + +/** + * 音乐整理服务:按 Navidrome 规范重命名、A-Z 分组、封面/歌词提取、整理报告。 + */ +@Service +public class OrganizeService { + + private static final Logger log = LoggerFactory.getLogger(OrganizeService.class); + + private static final Set AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList( + "mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape", "aiff", "aif", "wv", "tta", "opus" + )); + + private static final String VARIOUS_ARTISTS = "Various Artists"; + + /** 视为合辑(Various Artists)的 Album Artist / Artist 等价词,不区分大小写 */ + private static final Set VARIOUS_ARTISTS_ALIASES = new HashSet<>(Arrays.asList( + "Various Artists", "Various", "VA", "合辑", "群星", "群星荟萃", "杂锦", "合輯", "群星薈萃" + )); + + private static final Pattern INVALID_FILENAME = Pattern.compile("[\\\\/:*?\"<>|]"); + private static final Pattern ARTIST_SEP = Pattern.compile("[;;]"); + + private final SimpMessagingTemplate messagingTemplate; + private final ProgressStore progressStore; + private final HanyuPinyinOutputFormat pinyinFormat; + + public OrganizeService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) { + this.messagingTemplate = messagingTemplate; + this.progressStore = progressStore; + this.pinyinFormat = new HanyuPinyinOutputFormat(); + this.pinyinFormat.setCaseType(HanyuPinyinCaseType.UPPERCASE); + this.pinyinFormat.setToneType(HanyuPinyinToneType.WITHOUT_TONE); + } + + @Async + public void organize(String taskId, String srcDir, String dstDir, String mode, + boolean extractCover, boolean extractLyrics, boolean generateReport) { + Path srcPath = Paths.get(srcDir); + Path dstPath = Paths.get(dstDir); + boolean strict = "strict".equalsIgnoreCase(mode); + + try { + if (srcDir == null || srcDir.trim().isEmpty()) { + sendProgress(taskId, 0, 0, 0, 0, null, "源目录不能为空", true); + return; + } + if (!Files.exists(srcPath) || !Files.isDirectory(srcPath)) { + sendProgress(taskId, 0, 0, 0, 0, null, "源目录不存在或不是目录", true); + return; + } + if (dstDir == null || dstDir.trim().isEmpty()) { + sendProgress(taskId, 0, 0, 0, 0, null, "目标目录不能为空", true); + return; + } + if (srcPath.normalize().equals(dstPath.normalize())) { + sendProgress(taskId, 0, 0, 0, 0, null, "源目录与目标目录不能相同", true); + return; + } + + if (!Files.exists(dstPath)) { + Files.createDirectories(dstPath); + } + + List audioFiles = new ArrayList<>(); + Files.walkFileTree(srcPath, 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, null, "未在源目录中找到音频文件", true); + return; + } + + Path manualRoot = dstPath.resolve("_Manual_Fix_Required_"); + Path reportsDir = dstPath.resolve("_Reports"); + Files.createDirectories(manualRoot); + if (generateReport) Files.createDirectories(reportsDir); + + AtomicInteger processed = new AtomicInteger(0); + AtomicInteger organized = new AtomicInteger(0); + AtomicInteger manualFix = new AtomicInteger(0); + + Set albumsWithoutCover = new HashSet<>(); + Set albumsWithCover = new HashSet<>(); + List filesWithoutLyrics = new ArrayList<>(); + Set albumKeys = new HashSet<>(); + + sendProgress(taskId, total, 0, 0, 0, null, "开始整理...", false); + + for (Path file : audioFiles) { + String fileName = file.getFileName().toString(); + try { + AudioFile af = AudioFileIO.read(file.toFile()); + Tag tag = af.getTag(); + if (tag == null) { + moveToManualFix(file, manualRoot, "Missing_Tags", fileName, manualFix); + int p = processed.incrementAndGet(); + if (p % 20 == 0 || p == total) { + sendProgress(taskId, total, p, organized.get(), manualFix.get(), fileName, + String.format("已处理 %d/%d", p, total), false); + } + continue; + } + + String title = trim(tag.getFirst(FieldKey.TITLE)); + String artist = trim(tag.getFirst(FieldKey.ARTIST)); + String album = trim(tag.getFirst(FieldKey.ALBUM)); + String albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST)); + String date = trim(tag.getFirst(FieldKey.YEAR)); + + if (strict && (title.isEmpty() || artist.isEmpty() || album.isEmpty())) { + moveToManualFix(file, manualRoot, "Missing_Incomplete", fileName, manualFix); + int p = processed.incrementAndGet(); + if (p % 20 == 0 || p == total) { + sendProgress(taskId, total, p, organized.get(), manualFix.get(), fileName, + String.format("已处理 %d/%d", p, total), false); + } + continue; + } + if (title.isEmpty()) { + moveToManualFix(file, manualRoot, "Missing_Title", fileName, manualFix); + int p = processed.incrementAndGet(); + if (p % 20 == 0 || p == total) { + sendProgress(taskId, total, p, organized.get(), manualFix.get(), fileName, + String.format("已处理 %d/%d", p, total), false); + } + continue; + } + + String effectiveArtist = firstNonEmpty(albumArtist, artist); + boolean isVarious = isVariousArtist(effectiveArtist); + if (isVarious) effectiveArtist = VARIOUS_ARTISTS; + if (artist.isEmpty() && !isVarious) artist = effectiveArtist; + if (album.isEmpty()) album = "Unknown Album"; + + // 多艺术家(A;B;C)时仅用第一个做 A-Z 与目录名,避免过长路径 + String artistForPath = isVarious ? VARIOUS_ARTISTS : firstArtist(effectiveArtist); + String index = isVarious ? "" : indexLetter(artistForPath); + String year = yearFromDate(date); + String track = trackFromTag(tag); + String ext = ext(fileName); + String safeTitle = sanitize(title); + String safeArtist = sanitize(artistForPath); + String safeAlbum = sanitize(album); + + Path destDir; + String destFileName; + if (isVarious) { + destDir = dstPath.resolve(VARIOUS_ARTISTS).resolve(safeAlbum + (year.isEmpty() ? "" : "(" + year + ")")); + destFileName = track + " - " + safeTitle + "." + ext; + } else { + Path indexPath = index.isEmpty() ? dstPath : dstPath.resolve(index); + destDir = indexPath.resolve(safeArtist).resolve(safeAlbum + (year.isEmpty() ? "" : "(" + year + ")")); + destFileName = track + " - " + safeTitle + "." + ext; + } + + Files.createDirectories(destDir); + Path destFile = resolveTargetFile(destDir, destFileName); + Files.move(file, destFile, StandardCopyOption.REPLACE_EXISTING); + organized.incrementAndGet(); + + String albumKey = (isVarious ? VARIOUS_ARTISTS : index + "|" + safeArtist) + "|" + safeAlbum + "|" + year; + albumKeys.add(albumKey); + + if (extractCover) { + if (!albumsWithCover.contains(albumKey)) { + byte[] cover = extractCoverData(af, tag); + if (cover != null && cover.length > 0) { + Path coverPath = destDir.resolve("cover.jpg"); + writeCoverImage(cover, coverPath); + albumsWithCover.add(albumKey); + albumsWithoutCover.remove(albumKey); + } else { + albumsWithoutCover.add(albumKey); + } + } + } + + if (extractLyrics) { + String lyrics = extractLyrics(tag, af); + if (lyrics != null && !lyrics.trim().isEmpty()) { + String base = baseName(destFileName); + Path lrcPath = destDir.resolve(base + ".lrc"); + Files.write(lrcPath, lyrics.getBytes(StandardCharsets.UTF_8)); + } else { + filesWithoutLyrics.add(destDir.relativize(destFile).toString()); + } + } + + } catch (Exception e) { + log.warn("整理失败: {} - {}", file, e.getMessage()); + moveToManualFix(file, manualRoot, "Error", fileName, manualFix); + } + + int p = processed.incrementAndGet(); + if (p % 20 == 0 || p == total) { + sendProgress(taskId, total, p, organized.get(), manualFix.get(), fileName, + String.format("已处理 %d/%d", p, total), false); + } + } + + if (generateReport) { + Path reportPath = reportsDir.resolve("report_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".txt"); + writeReport(reportPath, total, organized.get(), manualFix.get(), albumKeys.size(), + albumsWithoutCover, filesWithoutLyrics, extractCover, extractLyrics); + } + + sendProgress(taskId, total, processed.get(), organized.get(), manualFix.get(), null, + String.format("整理完成!成功: %d, 需人工修复: %d", organized.get(), manualFix.get()), true); + + } catch (Exception e) { + log.error("整理任务执行失败", e); + sendProgress(taskId, 0, 0, 0, 0, null, "任务执行失败: " + e.getMessage(), true); + } + } + + private void moveToManualFix(Path file, Path manualRoot, String reason, String fileName, + AtomicInteger manualFix) { + try { + Path sub = manualRoot.resolve(reason); + Files.createDirectories(sub); + Path target = resolveTargetFile(sub, fileName); + Files.move(file, target, StandardCopyOption.REPLACE_EXISTING); + manualFix.incrementAndGet(); + } catch (IOException e) { + log.warn("移动至人工修复目录失败: {} - {}", file, e.getMessage()); + } + } + + private String indexLetter(String name) { + if (name == null || name.isEmpty()) return "#"; + String s = name.trim(); + char first = s.charAt(0); + if (Character.isLetter(first)) { + if (first >= 'a' && first <= 'z') return String.valueOf((char) (first - 32)); + if (first >= 'A' && first <= 'Z') return String.valueOf(first); + } + if (Character.isDigit(first) || !Character.isLetterOrDigit(first)) return "#"; + if (first >= 0x4E00 && first <= 0x9FA5) { + try { + String[] py = PinyinHelper.toHanyuPinyinStringArray(first, pinyinFormat); + if (py != null && py[0] != null && !py[0].isEmpty()) { + char c = py[0].charAt(0); + return String.valueOf(Character.toUpperCase(c)); + } + } catch (BadHanyuPinyinOutputFormatCombination ignored) { } + } + return "#"; + } + + private String yearFromDate(String date) { + if (date == null || date.isEmpty()) return ""; + for (int i = 0; i < date.length(); i++) { + if (Character.isDigit(date.charAt(i))) { + int end = Math.min(i + 4, date.length()); + String y = date.substring(i, end); + if (y.length() == 4) return y; + return ""; + } + } + return ""; + } + + private String trackFromTag(Tag tag) { + String t = trim(tag.getFirst(FieldKey.TRACK)); + if (t.isEmpty()) return "01"; + int n; + try { + n = Integer.parseInt(t.replaceAll("\\D", "")); + } catch (NumberFormatException e) { + return "01"; + } + if (n < 1) return "01"; + return n < 10 ? "0" + n : (n < 100 ? String.valueOf(n) : String.format("%03d", n)); + } + + private byte[] extractCoverData(AudioFile af, Tag tag) { + try { + if (tag instanceof FlacTag) { + FlacTag ft = (FlacTag) tag; + List imgs = ft.getImages(); + if (imgs != null && !imgs.isEmpty()) { + return imgs.get(0).getImageData(); + } + return null; + } + Object a = tag.getFirstArtwork(); + if (a != null) { + java.lang.reflect.Method m = a.getClass().getMethod("getBinaryData"); + Object raw = m.invoke(a); + if (raw instanceof byte[]) { + byte[] data = (byte[]) raw; + if (data.length > 0) return data; + } + } + } catch (Exception e) { + log.debug("提取封面失败: {}", af.getFile().getName(), e); + } + return null; + } + + private void writeCoverImage(byte[] data, Path path) { + try (ByteArrayInputStream bis = new ByteArrayInputStream(data)) { + BufferedImage img = ImageIO.read(bis); + if (img != null) ImageIO.write(img, "jpg", path.toFile()); + } catch (IOException e) { + log.warn("写入封面失败: {}", path, e); + } + } + + @SuppressWarnings("deprecation") + private String extractLyrics(Tag tag, AudioFile af) { + try { + for (String key : new String[] { "LYRICS", "UNSYNCED LYRICS" }) { + if (tag.hasField(key)) { + String v = tag.getFirst(key); + if (v != null && !v.trim().isEmpty()) return v; + } + } + } catch (Exception e) { + log.debug("读取歌词失败: {}", af.getFile().getName(), e); + } + return null; + } + + private void writeReport(Path path, int scanned, int organized, int manualFix, int albumCount, + Set noCover, List noLyrics, boolean extractCover, boolean extractLyrics) { + StringBuilder sb = new StringBuilder(); + sb.append("音乐整理报告\n"); + sb.append("生成时间: ").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append("\n"); + sb.append("扫描文件数: ").append(scanned).append("\n"); + sb.append("整理成功: ").append(organized).append("\n"); + sb.append("需人工修复: ").append(manualFix).append("\n"); + sb.append("总专辑数: ").append(albumCount).append("\n"); + if (extractCover && !noCover.isEmpty()) { + sb.append("\n缺失封面的专辑:\n"); + for (String a : new TreeSet<>(noCover)) sb.append(" - ").append(a).append("\n"); + sb.append("无封面专辑数: ").append(noCover.size()).append(" (").append(albumCount > 0 ? String.format("%.1f", 100.0 * noCover.size() / albumCount) : "0").append("%)\n"); + } + if (extractLyrics && !noLyrics.isEmpty()) { + sb.append("\n缺失歌词的文件:\n"); + for (String f : noLyrics) sb.append(" - ").append(f).append("\n"); + sb.append("无歌词文件数: ").append(noLyrics.size()).append(" (").append(organized > 0 ? String.format("%.1f", 100.0 * noLyrics.size() / organized) : "0").append("%)\n"); + } + try { + Files.write(path, sb.toString().getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + log.warn("写入整理报告失败: {}", path, e); + } + } + + private String sanitize(String s) { + if (s == null) return ""; + return INVALID_FILENAME.matcher(s.trim()).replaceAll("_").replaceAll("\\s+", " ").trim(); + } + + private String trim(String s) { + return s == null ? "" : s.trim(); + } + + private String firstNonEmpty(String... ss) { + for (String s : ss) if (s != null && !s.trim().isEmpty()) return s.trim(); + return ""; + } + + /** 是否为合辑(Various Artists):与等价词集合匹配,不区分大小写 */ + private boolean isVariousArtist(String s) { + if (s == null || s.trim().isEmpty()) return false; + String n = s.trim(); + for (String alias : VARIOUS_ARTISTS_ALIASES) { + if (alias.equalsIgnoreCase(n)) return true; + } + return false; + } + + /** 多艺术家(A;B;C 或 A;B;C)时取第一个,用于 A-Z 索引与目录名 */ + private String firstArtist(String s) { + if (s == null || s.trim().isEmpty()) return ""; + String[] parts = ARTIST_SEP.split(s.trim()); + for (String p : parts) { + String t = p.trim(); + if (!t.isEmpty()) return t; + } + return s.trim(); + } + + private boolean isAudioFile(Path f) { + String ext = ext(f.getFileName().toString()); + return ext != null && AUDIO_EXTENSIONS.contains(ext.toLowerCase()); + } + + private String ext(String name) { + int i = name.lastIndexOf('.'); + return (i > 0 && i < name.length() - 1) ? name.substring(i + 1) : ""; + } + + private String baseName(String name) { + int i = name.lastIndexOf('.'); + return i > 0 ? name.substring(0, i) : name; + } + + private Path resolveTargetFile(Path dir, String fileName) throws IOException { + Path p = dir.resolve(fileName); + if (!Files.exists(p)) return p; + String base = baseName(fileName); + String ext = ext(fileName); + String suf = ext.isEmpty() ? "" : "." + ext; + int n = 1; + while (Files.exists(p)) { + p = dir.resolve(base + " (" + n + ")" + suf); + n++; + } + return p; + } + + private void sendProgress(String taskId, int total, int processed, int success, int failed, + String currentFile, String message, boolean completed) { + try { + ProgressMessage pm = new ProgressMessage(); + pm.setTaskId(taskId); + pm.setType("organize"); + pm.setTotal(total); + pm.setProcessed(processed); + pm.setSuccess(success); + pm.setFailed(failed); + pm.setCurrentFile(currentFile); + pm.setMessage(message); + pm.setCompleted(completed); + progressStore.put(pm); + messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm); + } catch (Exception e) { + log.error("发送进度失败", e); + } + } +} diff --git a/backend/src/main/java/com/music/service/ZhConvertService.java b/backend/src/main/java/com/music/service/ZhConvertService.java index 4ed4330..43e7edc 100644 --- a/backend/src/main/java/com/music/service/ZhConvertService.java +++ b/backend/src/main/java/com/music/service/ZhConvertService.java @@ -12,6 +12,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import java.io.IOException; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; @@ -115,11 +116,19 @@ public class ZhConvertService { for (Path srcFile : audioFiles) { String fileName = srcFile.getFileName().toString(); try { - Path targetFile = resolveTargetFileForExecute(srcFile, outputPath, execute); - - int addedEntries = handleSingleFile(targetFile, thresholdRatio, execute); - if (addedEntries > 0) { - traditionalEntries.addAndGet(addedEntries); + // 先检查文件是否包含繁体(预览模式检测) + int previewEntries = checkFileHasTraditional(srcFile, thresholdRatio); + + // 只有包含繁体的文件才需要处理 + if (previewEntries > 0) { + // 执行模式下,需要移动到输出目录(如果配置了输出目录) + Path targetFile = resolveTargetFileForExecute(srcFile, scanPath, outputPath, execute); + + // 处理文件(转换繁体为简体) + int addedEntries = handleSingleFile(targetFile, thresholdRatio, execute); + if (addedEntries > 0) { + traditionalEntries.addAndGet(addedEntries); + } } int currentProcessed = processed.incrementAndGet(); @@ -162,6 +171,50 @@ public class ZhConvertService { } } + /** + * 检查文件是否包含繁体(仅检测,不修改) + * + * @return 检测到的繁体标签条目数量(字段级) + */ + private int checkFileHasTraditional(Path file, double thresholdRatio) throws Exception { + AudioFile audioFile = AudioFileIO.read(file.toFile()); + Tag tag = audioFile.getTag(); + if (tag == null) { + return 0; + } + + int entries = 0; + entries += checkFieldHasTraditional(tag, FieldKey.TITLE, thresholdRatio); + entries += checkFieldHasTraditional(tag, FieldKey.ARTIST, thresholdRatio); + entries += checkFieldHasTraditional(tag, FieldKey.ALBUM, thresholdRatio); + entries += checkFieldHasTraditional(tag, FieldKey.ALBUM_ARTIST, thresholdRatio); + + return entries; + } + + /** + * 检查单个标签字段是否包含繁体(仅检测,不修改) + * + * @return 如果该字段触发阈值,则返回 1,否则返回 0 + */ + private int checkFieldHasTraditional(Tag tag, FieldKey fieldKey, double thresholdRatio) { + String original = tag.getFirst(fieldKey); + if (original == null || original.trim().isEmpty()) { + return 0; + } + + ZhStats stats = traditionalFilterService.analyze(original); + if (stats.getTraditionalCount() <= 0) { + return 0; + } + + if (stats.getRatio() < thresholdRatio) { + return 0; + } + + return 1; + } + /** * 处理单个音频文件 * @@ -241,18 +294,36 @@ public class ZhConvertService { * - 预览模式:始终返回原文件路径 * - 执行模式: * - 未配置输出目录:在原文件上就地修改 - * - 配置了输出目录:先复制到输出目录,再在副本上修改 + * - 配置了输出目录:移动到输出目录(保持相对路径结构),再在移动后的文件上修改 + * + * 注意:此方法只在确认文件包含繁体后才调用,确保只有包含繁体的文件才会被移动到输出目录 */ private Path resolveTargetFileForExecute(Path srcFile, + Path scanPath, Path outputPath, boolean execute) throws Exception { if (!execute || outputPath == null) { return srcFile; } - Path targetFile = outputPath.resolve(srcFile.getFileName()); - if (!Files.exists(targetFile)) { - Files.copy(srcFile, targetFile, StandardCopyOption.COPY_ATTRIBUTES); + // 计算源文件相对于扫描目录的相对路径,保持目录结构 + Path relativePath = scanPath.relativize(srcFile); + Path targetFile = outputPath.resolve(relativePath); + + // 确保目标文件的父目录存在 + Path targetParent = targetFile.getParent(); + if (targetParent != null && !Files.exists(targetParent)) { + Files.createDirectories(targetParent); + } + + // 移动源文件到目标位置(因为已经确认文件包含繁体,需要过滤出来处理) + // 如果目标文件已存在,则覆盖(可能是重复处理的情况) + try { + // 尝试原子移动(在同一文件系统上更快更安全) + Files.move(srcFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + // 如果原子移动失败(例如跨分区),回退到普通移动 + Files.move(srcFile, targetFile, StandardCopyOption.REPLACE_EXISTING); } return targetFile; } diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..5d0a228 --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,30 @@ +import request from './request'; + +export interface ConfigResponse { + basePath: string; + inputDir: string; + aggregatedDir: string; + formatIssuesDir: string; + duplicatesDir: string; + zhOutputDir: string; + organizedDir: string; + libraryFinalDir: string; +} + +export interface ConfigRequest { + basePath: string; +} + +/** + * 保存基础路径配置 + */ +export function saveBasePath(data: ConfigRequest): Promise { + return request.post('/api/config/base-path', data); +} + +/** + * 获取完整配置(包含所有派生路径) + */ +export function getConfig(): Promise { + return request.get('/api/config/base-path'); +} diff --git a/frontend/src/api/merge.ts b/frontend/src/api/merge.ts new file mode 100644 index 0000000..433bed5 --- /dev/null +++ b/frontend/src/api/merge.ts @@ -0,0 +1,19 @@ +import request from './request'; + +export interface MergeRequest { + srcDir: string; + dstDir: string; + smartUpgrade: boolean; + keepBackup: boolean; +} + +export interface MergeResponse { + taskId: string; +} + +/** + * 启动整理入库任务 + */ +export function startMerge(params: MergeRequest): Promise { + return request.post('/api/merge/start', params); +} diff --git a/frontend/src/api/organize.ts b/frontend/src/api/organize.ts new file mode 100644 index 0000000..e80a7d9 --- /dev/null +++ b/frontend/src/api/organize.ts @@ -0,0 +1,21 @@ +import request from './request'; + +export interface OrganizeRequest { + srcDir: string; + dstDir: string; + mode: 'strict' | 'lenient'; + extractCover: boolean; + extractLyrics: boolean; + generateReport: boolean; +} + +export interface OrganizeResponse { + taskId: string; +} + +/** + * 启动音乐整理任务 + */ +export function startOrganize(params: OrganizeRequest): Promise { + return request.post('/api/organize/start', params); +} diff --git a/frontend/src/components/AggregateTab.vue b/frontend/src/components/AggregateTab.vue index d0e018a..e623444 100644 --- a/frontend/src/components/AggregateTab.vue +++ b/frontend/src/components/AggregateTab.vue @@ -176,7 +176,7 @@ @@ -501,6 +754,295 @@ function reset() { margin-top: 16px; } +/* 去重策略说明样式 */ +.strategy-help-collapse, +.mode-help-collapse { + border: none; + background: transparent; +} + +.strategy-help-collapse :deep(.el-collapse-item__header), +.mode-help-collapse :deep(.el-collapse-item__header) { + padding: 0; + border: none; + background: transparent; + height: auto; + line-height: 1.5; +} + +.strategy-help-collapse :deep(.el-collapse-item__wrap), +.mode-help-collapse :deep(.el-collapse-item__wrap) { + border: none; + background: transparent; +} + +.strategy-help-collapse :deep(.el-collapse-item__content), +.mode-help-collapse :deep(.el-collapse-item__content) { + padding: 16px 0 0 0; +} + +.help-title { + display: flex; + align-items: center; + gap: 6px; + color: var(--el-color-primary); + font-size: 13px; + cursor: pointer; + transition: color 0.2s; +} + +.help-title:hover { + color: var(--el-color-primary-light-3); +} + +.help-icon { + font-size: 16px; +} + +.strategy-help-content, +.mode-help-content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.strategy-description, +.mode-description { + padding: 16px; + background: var(--el-bg-color-page); + border-radius: 8px; + border: 1px solid var(--el-border-color-lighter); + transition: all 0.3s; +} + +.strategy-description.active, +.mode-description.active { + background: var(--el-color-primary-light-9); + border-color: var(--el-color-primary-light-5); +} + +.strategy-header, +.mode-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.strategy-icon, +.mode-icon { + font-size: 20px; + color: var(--el-color-primary); +} + +.md5-icon { + color: var(--el-color-info); +} + +.metadata-icon { + color: var(--el-color-primary); +} + +.copy-icon { + color: var(--el-color-info); +} + +.move-icon { + color: var(--el-color-warning); +} + +.strategy-title, +.mode-title { + flex: 1; + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--el-text-color-primary); +} + +.strategy-body, +.mode-body { + margin-left: 30px; +} + +.strategy-summary, +.mode-summary { + margin: 0 0 12px 0; + font-size: 13px; + color: var(--el-text-color-regular); + line-height: 1.6; +} + +.strategy-features, +.mode-features { + margin: 0; + padding: 0; + list-style: none; +} + +.strategy-features li, +.mode-features li { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 8px; + font-size: 13px; + line-height: 1.6; + color: var(--el-text-color-regular); +} + +.feature-icon { + font-size: 16px; + margin-top: 2px; + flex-shrink: 0; + color: var(--el-color-success); +} + +.feature-icon.warning-icon { + color: var(--el-color-warning); +} + +.strategy-features li span, +.mode-features li span { + flex: 1; +} + +.strategy-example, +.mode-example { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--el-border-color-lighter); +} + +.example-label { + margin: 0 0 10px 0; + font-size: 12px; + font-weight: 600; + color: var(--el-text-color-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.example-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.example-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + flex-wrap: wrap; +} + +.example-path { + color: var(--el-text-color-secondary); + font-weight: 500; + min-width: 70px; +} + +.example-separator { + color: var(--el-text-color-secondary); + margin: 0 4px; +} + +.example-score { + color: var(--el-color-success); + font-weight: 500; + margin-left: auto; +} + +.example-status { + color: var(--el-color-warning); + font-weight: 500; + margin-left: 8px; +} + +.example-item code { + padding: 4px 8px; + background: var(--el-bg-color); + border: 1px solid var(--el-border-color); + border-radius: 4px; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 11px; + color: var(--el-color-primary); + word-break: break-all; +} + +.example-result { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + padding: 8px 12px; + background: var(--el-color-info-light-9); + border-radius: 4px; + font-size: 12px; + color: var(--el-color-info-dark-2); +} + +.example-result .el-icon { + font-size: 14px; + color: var(--el-color-info); +} + +.strategy-tip { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + background: var(--el-color-warning-light-9); + border: 1px solid var(--el-color-warning-light-5); + border-radius: 8px; + margin-top: 8px; +} + +.tip-icon { + font-size: 20px; + color: var(--el-color-warning); + flex-shrink: 0; + margin-top: 2px; +} + +.tip-content { + flex: 1; + font-size: 13px; + line-height: 1.6; + color: var(--el-text-color-regular); +} + +.tip-content strong { + color: var(--el-color-warning-dark-2); +} + +@media (max-width: 768px) { + .strategy-body, + .mode-body { + margin-left: 0; + } + + .strategy-header, + .mode-header { + flex-wrap: wrap; + } + + .example-item { + flex-direction: column; + align-items: flex-start; + } + + .example-path { + min-width: auto; + } + + .example-score { + margin-left: 0; + margin-top: 4px; + } +} + @media (max-width: 768px) { .stats-grid { grid-template-columns: repeat(2, 1fr); diff --git a/frontend/src/components/MergeTab.vue b/frontend/src/components/MergeTab.vue index f212bf3..f98d998 100644 --- a/frontend/src/components/MergeTab.vue +++ b/frontend/src/components/MergeTab.vue @@ -1,55 +1,337 @@ diff --git a/frontend/src/components/RenameTab.vue b/frontend/src/components/RenameTab.vue index d1b6aa1..62ba563 100644 --- a/frontend/src/components/RenameTab.vue +++ b/frontend/src/components/RenameTab.vue @@ -1,105 +1,838 @@ - diff --git a/frontend/src/components/SettingsTab.vue b/frontend/src/components/SettingsTab.vue index cdc524a..2087e7d 100644 --- a/frontend/src/components/SettingsTab.vue +++ b/frontend/src/components/SettingsTab.vue @@ -1,53 +1,91 @@ diff --git a/frontend/src/components/TraditionalFilterTab.vue b/frontend/src/components/TraditionalFilterTab.vue index 9e82ff8..d9aafbf 100644 --- a/frontend/src/components/TraditionalFilterTab.vue +++ b/frontend/src/components/TraditionalFilterTab.vue @@ -49,6 +49,80 @@ + + + + + +
+
+
+ +

阈值工作原理

+
+
+

系统会计算每个标签字段中繁体字符的占比,只有超过阈值的字段才会被处理

+
    +
  • + + 计算公式:繁体占比 = 繁体字符数 ÷ 中文字符总数 × 100% +
  • +
  • + + 检测范围:仅统计中文字符(CJK统一表意文字),忽略英文、数字、符号等 +
  • +
  • + + 触发条件:只有当繁体占比 ≥ 设定阈值时,该字段才会被转换 +
  • +
  • + + 支持字段:标题(Title)、艺术家(Artist)、专辑(Album)、专辑艺人(AlbumArtist) +
  • +
+
+

示例:

+
+
+ 标签内容: + 周杰倫 - 七里香 +
+
+ 中文字符: + 5 个(周、杰、倫、七、香) +
+
+ 繁体字符: + 1 个(倫) +
+
+ 繁体占比: + 1 ÷ 5 × 100% = 20% +
+
+ + 如果阈值设为 10%,则会被转换(20% ≥ 10%);如果阈值设为 30%,则不会被转换(20% < 30%) +
+
+
+
+ +
+ 推荐设置:默认 10% 适合大多数场景。如果希望更严格地过滤(只处理明显繁体的内容),可以设置为 30-50%;如果希望更宽松(处理所有包含繁体的内容),可以设置为 1-5%。 +
+
+
+
+
+
+
+
+ @@ -60,6 +134,149 @@ + + + + + +
+ +
+
+ +

预览模式(仅检测)

+ 当前选择 +
+
+

仅扫描并列出检测到繁体的文件和字段,不对原文件做任何修改

+
    +
  • + + 安全检测:只读取文件标签,不修改任何内容 +
  • +
  • + + 评估影响:适合先评估影响范围,确认需要转换的文件数量 +
  • +
  • + + 输出目录:预览模式下输出目录设置会被忽略 +
  • +
  • + + 统计信息:显示总文件数、已扫描数、繁体标签条目数 +
  • +
+
+

使用场景:

+
+
+ + 首次使用,想了解音乐库中有多少文件包含繁体标签 +
+
+ + 调整阈值后,想确认新的阈值会处理多少文件 +
+
+ + 执行转换前,想先预览转换结果 +
+
+
+
+
+ + +
+
+ +

执行模式(执行转换)

+ 当前选择 +
+
+

对检测到的繁体标签进行转换,可选择原地修改或输出到新目录

+
    +
  • + + 转换操作:将繁体字符转换为对应的简体字符 +
  • +
  • + + 输出目录为空:在原文件上直接修改标签(原地修改) +
  • +
  • + + 输出目录不为空:将文件移动到输出目录(保持相对路径结构),然后在移动后的文件上修改标签 +
  • +
  • + + 文件移动:只有包含繁体的文件才会被移动到输出目录,其他文件保持不变 +
  • +
+
+

输出目录设置:

+
+
+
+ + 场景一:输出目录为空(原地修改) +
+
+
+ 扫描目录: + D:\Music\song.mp3 +
+
+ 输出目录: + (留空) +
+
+ + 结果:直接在 D:\Music\song.mp3 上修改标签,文件位置不变 +
+
+
+
+
+ + 场景二:输出目录不为空(复制到新目录) +
+
+
+ 扫描目录: + D:\Music\Album\song.mp3 +
+
+ 输出目录: + D:\Music_Simplified\Album\song.mp3 +
+
+ + 结果:文件移动到输出目录(保持 Album 子目录结构),然后在移动后的文件上修改标签 +
+
+
+
+
+
+ +
+ 使用建议:建议先用预览模式确认需要转换的文件,然后再使用执行模式。如果输出目录不为空,系统会将包含繁体的文件移动到输出目录,这样可以保留原始文件作为备份。 +
+
+
+
+
+
+
+
+