提交代码

This commit is contained in:
liu
2026-01-30 00:04:31 +08:00
parent 7531b6c466
commit 89be3ba0bd
23 changed files with 4934 additions and 179 deletions

View File

@@ -65,6 +65,13 @@
<artifactId>jaudiotagger</artifactId>
<version>2.2.5</version>
</dependency>
<!-- 中文拼音首字母A-Z 分组) -->
<dependency>
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version>
</dependency>
</dependencies>
<build>

View File

@@ -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);
}
}

View File

@@ -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<Void> saveBasePath(@Valid @RequestBody ConfigRequest request) {
configService.saveBasePath(request.getBasePath());
return Result.success("配置保存成功", null);
}
/**
* 获取完整配置(包含所有派生路径)
*/
@GetMapping("/base-path")
public Result<ConfigResponse> getConfig() {
ConfigResponse config = configService.getConfig();
return Result.success(config);
}
}

View File

@@ -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<StartResponse> 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;
}
}
}

View File

@@ -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<OrganizeController.StartResponse> 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;
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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+Albumlenient 宽松(仅需 Title
*/
private String mode = "strict";
/**
* 是否提取封面到专辑目录 cover.jpg
*/
private boolean extractCover = true;
/**
* 是否提取内嵌歌词到 .lrc
*/
private boolean extractLyrics = true;
/**
* 是否生成整理报告到 _Reports/
*/
private boolean generateReport = true;
}

View File

@@ -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);
}
}

View File

@@ -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<String> AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList(
"mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape", "aiff", "aif", "wv", "tta", "opus"
));
private static final Set<String> LYRICS_EXTENSIONS = new HashSet<>(Arrays.asList("lrc"));
private static final Set<String> 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<Path> audioFiles = new ArrayList<>();
Map<Path, Path> lyricsMap = new HashMap<>(); // 音频文件 -> 歌词文件
Map<Path, Path> coverMap = new HashMap<>(); // 专辑目录 -> 封面文件
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
@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<String> 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);
}
}
}

View File

@@ -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<String> 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<String> 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<Path> audioFiles = new ArrayList<>();
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (isAudioFile(file)) audioFiles.add(file);
return FileVisitResult.CONTINUE;
}
});
int total = audioFiles.size();
if (total == 0) {
sendProgress(taskId, 0, 0, 0, 0, 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<String> albumsWithoutCover = new HashSet<>();
Set<String> albumsWithCover = new HashSet<>();
List<String> filesWithoutLyrics = new ArrayList<>();
Set<String> 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<MetadataBlockDataPicture> 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<String> noCover, List<String> 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 或 ABC时取第一个用于 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);
}
}
}

View File

@@ -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;
}