提交代码

This commit is contained in:
liu
2026-01-29 18:26:02 +08:00
parent 981b4ecf42
commit 7531b6c466
47 changed files with 7257 additions and 16 deletions

89
backend/pom.xml Normal file
View File

@@ -0,0 +1,89 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.music</groupId>
<artifactId>mangtool-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>MangTool Backend</name>
<description>Music toolbox backend (Spring Boot)</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 音频元数据解析(标签读取) -->
<dependency>
<groupId>net.jthink</groupId>
<artifactId>jaudiotagger</artifactId>
<version>2.2.5</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,15 @@
package com.music;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class MangToolApplication {
public static void main(String[] args) {
SpringApplication.run(MangToolApplication.class, args);
}
}

View File

@@ -0,0 +1,39 @@
package com.music.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
/**
* 0 = 成功,非 0 = 业务错误代码
*/
private int code;
/**
* 提示信息
*/
private String message;
/**
* 业务数据
*/
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(0, "success", data);
}
public static <T> Result<T> success(String message, T data) {
return new Result<>(0, message, data);
}
public static <T> Result<T> failure(int code, String message) {
return new Result<>(code, message, null);
}
}

View File

@@ -0,0 +1,25 @@
package com.music.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}

View File

@@ -0,0 +1,26 @@
package com.music.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
}

View File

@@ -0,0 +1,67 @@
package com.music.controller;
import com.music.common.Result;
import com.music.dto.AggregateRequest;
import com.music.exception.BusinessException;
import com.music.service.AggregatorService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.UUID;
@RestController
@RequestMapping("/api/aggregate")
@Validated
public class AggregateController {
private final AggregatorService aggregatorService;
public AggregateController(AggregatorService aggregatorService) {
this.aggregatorService = aggregatorService;
}
/**
* 启动音频文件汇聚任务
*/
@PostMapping("/start")
public Result<StartResponse> start(@Valid @RequestBody AggregateRequest request) {
// 验证模式参数
if (!"copy".equalsIgnoreCase(request.getMode()) &&
!"move".equalsIgnoreCase(request.getMode())) {
throw new BusinessException(400, "模式参数错误,必须是 copy 或 move");
}
// 生成任务ID
String taskId = UUID.randomUUID().toString();
// 异步执行任务
aggregatorService.aggregate(
taskId,
request.getSrcDir(),
request.getDstDir(),
request.getMode()
);
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,59 @@
package com.music.controller;
import com.music.common.Result;
import com.music.dto.ConvertRequest;
import com.music.exception.BusinessException;
import com.music.service.ConvertService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.UUID;
@RestController
@RequestMapping("/api/convert")
@Validated
public class ConvertController {
private final ConvertService convertService;
public ConvertController(ConvertService convertService) {
this.convertService = convertService;
}
/**
* 启动音频格式智能处理(转码)任务
*/
@PostMapping("/start")
public Result<StartResponse> start(@Valid @RequestBody ConvertRequest request) {
if (!"copy".equalsIgnoreCase(request.getMode()) && !"move".equalsIgnoreCase(request.getMode())) {
throw new BusinessException(400, "模式参数错误,必须是 copy 或 move");
}
String taskId = UUID.randomUUID().toString();
convertService.convert(
taskId,
request.getSrcDir(),
request.getDstDir(),
request.getMode()
);
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,72 @@
package com.music.controller;
import com.music.common.Result;
import com.music.dto.DedupRequest;
import com.music.exception.BusinessException;
import com.music.service.DedupService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.UUID;
/**
* 音乐去重任务控制器
*/
@RestController
@RequestMapping("/api/dedup")
@Validated
public class DedupController {
private final DedupService dedupService;
public DedupController(DedupService dedupService) {
this.dedupService = dedupService;
}
/**
* 启动音乐去重任务
*/
@PostMapping("/start")
public Result<StartResponse> start(@Valid @RequestBody DedupRequest request) {
// 模式校验
if (!"copy".equalsIgnoreCase(request.getMode()) &&
!"move".equalsIgnoreCase(request.getMode())) {
throw new BusinessException(400, "模式参数错误,必须是 copy 或 move");
}
// 至少启用一种策略
if (!request.isUseMd5() && !request.isUseMetadata()) {
throw new BusinessException(400, "至少需要启用一种去重策略MD5 或元数据匹配)");
}
String taskId = UUID.randomUUID().toString();
dedupService.dedup(
taskId,
request.getLibraryDir(),
request.getTrashDir(),
request.isUseMd5(),
request.isUseMetadata(),
request.getMode()
);
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,15 @@
package com.music.controller;
import com.music.common.Result;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HealthController {
@GetMapping("/api/health")
public Result<String> health() {
return Result.success("OK");
}
}

View File

@@ -0,0 +1,23 @@
package com.music.controller;
import com.music.common.Result;
import com.music.dto.ProgressMessage;
import com.music.service.ProgressStore;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/progress")
public class ProgressController {
private final ProgressStore progressStore;
public ProgressController(ProgressStore progressStore) {
this.progressStore = progressStore;
}
@GetMapping("/{taskId}")
public Result<ProgressMessage> get(@PathVariable("taskId") String taskId) {
return Result.success(progressStore.get(taskId));
}
}

View File

@@ -0,0 +1,72 @@
package com.music.controller;
import com.music.common.Result;
import com.music.dto.ZhConvertRequest;
import com.music.exception.BusinessException;
import com.music.service.ZhConvertService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.UUID;
/**
* 音乐元数据繁体转简体任务控制器
*/
@RestController
@RequestMapping("/api/zhconvert")
@Validated
public class ZhConvertController {
private final ZhConvertService zhConvertService;
public ZhConvertController(ZhConvertService zhConvertService) {
this.zhConvertService = zhConvertService;
}
/**
* 启动繁体检测 / 转换任务
*/
@PostMapping("/start")
public Result<StartResponse> start(@Valid @RequestBody ZhConvertRequest request) {
String mode = request.getMode();
if (!"preview".equalsIgnoreCase(mode) && !"execute".equalsIgnoreCase(mode)) {
throw new BusinessException(400, "处理模式错误,必须是 preview 或 execute");
}
int threshold = request.getThreshold();
if (threshold < 1 || threshold > 100) {
throw new BusinessException(400, "繁体占比阈值必须在 1-100 之间");
}
String taskId = UUID.randomUUID().toString();
double thresholdRatio = threshold / 100.0;
zhConvertService.process(
taskId,
request.getScanDir(),
request.getOutputDir(),
mode,
thresholdRatio
);
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,21 @@
package com.music.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class AggregateRequest {
@NotBlank(message = "源目录不能为空")
private String srcDir;
@NotBlank(message = "目标目录不能为空")
private String dstDir;
/**
* 模式copy复制模式或 move移动模式
*/
@NotBlank(message = "模式不能为空")
private String mode;
}

View File

@@ -0,0 +1,21 @@
package com.music.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class ConvertRequest {
@NotBlank(message = "输入目录不能为空")
private String srcDir;
@NotBlank(message = "输出目录不能为空")
private String dstDir;
/**
* 模式copy复制模式或 move移动模式
*/
@NotBlank(message = "模式不能为空")
private String mode;
}

View File

@@ -0,0 +1,41 @@
package com.music.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 音乐去重任务请求
*/
@Data
public class DedupRequest {
/**
* 音乐库根目录
*/
@NotBlank(message = "音乐库目录不能为空")
private String libraryDir;
/**
* 回收站目录(重复文件移动/复制到此目录)
*/
@NotBlank(message = "回收站目录不能为空")
private String trashDir;
/**
* 是否启用 MD5 哈希去重
*/
private boolean useMd5 = true;
/**
* 是否启用元数据匹配去重
*/
private boolean useMetadata = true;
/**
* 执行模式copy复制或 move移动
*/
@NotBlank(message = "执行模式不能为空")
private String mode;
}

View File

@@ -0,0 +1,18 @@
package com.music.dto;
import lombok.Data;
@Data
public class ProgressMessage {
private String taskId; // 任务 ID
private String type; // 任务类型,如 aggregate/convert/dedup 等
private int total; // 总数
private int processed; // 已处理
private int success; // 成功数
private int failed; // 失败数
private String currentFile; // 当前处理文件
private String message; // 进度消息
private boolean completed; // 是否完成
}

View File

@@ -0,0 +1,44 @@
package com.music.dto;
import lombok.Data;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
/**
* 音乐元数据繁体转简体任务请求
*/
@Data
public class ZhConvertRequest {
/**
* 待扫描标签的根目录
*/
@NotBlank(message = "扫描目录不能为空")
private String scanDir;
/**
* 输出目录(可选)
*
* - 预览模式:忽略该字段
* - 执行模式:
* - 为空:在原文件上就地修改标签
* - 非空:在该目录下生成一份带简体标签的副本
*/
private String outputDir;
/**
* 繁体占比阈值(百分比 1-100
*/
@Min(value = 1, message = "繁体占比阈值不能小于 1%")
@Max(value = 100, message = "繁体占比阈值不能大于 100%")
private int threshold;
/**
* 处理模式preview仅预览 或 execute执行转换
*/
@NotBlank(message = "处理模式不能为空")
private String mode;
}

View File

@@ -0,0 +1,16 @@
package com.music.exception;
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,32 @@
package com.music.exception;
import com.music.common.Result;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException ex) {
return Result.failure(ex.getCode(), ex.getMessage());
}
@ExceptionHandler({
MethodArgumentNotValidException.class,
BindException.class,
HttpMessageNotReadableException.class
})
public Result<Void> handleValidationException(Exception ex) {
return Result.failure(400, "请求参数错误:" + ex.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleOther(Exception ex) {
return Result.failure(500, "服务器内部错误:" + ex.getMessage());
}
}

View File

@@ -0,0 +1,202 @@
package com.music.service;
import com.music.dto.ProgressMessage;
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.*;
import java.util.concurrent.atomic.AtomicInteger;
@Service
public class AggregatorService {
private static final Set<String> AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList(
"mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape"
));
private final SimpMessagingTemplate messagingTemplate;
private final ProgressStore progressStore;
public AggregatorService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) {
this.messagingTemplate = messagingTemplate;
this.progressStore = progressStore;
}
/**
* 异步执行音频文件汇聚任务
*/
@Async
public void aggregate(String taskId, String srcDir, String dstDir, String mode) {
Path sourcePath = Paths.get(srcDir);
Path targetPath = Paths.get(dstDir);
try {
// 验证源目录
if (!Files.exists(sourcePath) || !Files.isDirectory(sourcePath)) {
sendProgress(taskId, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
return;
}
// 创建目标目录
if (!Files.exists(targetPath)) {
Files.createDirectories(targetPath);
}
// 收集所有音频文件
List<Path> audioFiles = new ArrayList<>();
Files.walkFileTree(sourcePath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (isAudioFile(file)) {
audioFiles.add(file);
}
return FileVisitResult.CONTINUE;
}
});
int total = audioFiles.size();
AtomicInteger processed = new AtomicInteger(0);
AtomicInteger success = new AtomicInteger(0);
AtomicInteger failed = new AtomicInteger(0);
// 发送初始进度
sendProgress(taskId, total, 0, 0, 0, null, "开始汇聚任务...", false);
// 处理每个文件
for (Path sourceFile : audioFiles) {
String fileName = sourceFile.getFileName().toString();
try {
Path targetFile = resolveTargetFile(targetPath, fileName);
if ("move".equalsIgnoreCase(mode)) {
// 移动模式
Files.move(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
} else {
// 复制模式
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
}
success.incrementAndGet();
sendProgress(taskId, total, processed.incrementAndGet(),
success.get(), failed.get(), fileName,
String.format("已处理: %s", fileName), false);
} catch (Exception e) {
failed.incrementAndGet();
sendProgress(taskId, total, processed.incrementAndGet(),
success.get(), failed.get(), fileName,
String.format("处理失败: %s - %s", fileName, e.getMessage()), false);
}
}
// 移动模式下清理空目录
if ("move".equalsIgnoreCase(mode)) {
cleanupEmptyDirectories(sourcePath);
}
// 发送完成消息
sendProgress(taskId, total, processed.get(), success.get(), failed.get(),
null, String.format("任务完成!成功: %d, 失败: %d", success.get(), failed.get()), true);
} catch (Exception e) {
sendProgress(taskId, 0, 0, 0, 0, null,
"任务执行失败: " + e.getMessage(), true);
}
}
/**
* 判断是否为音频文件
*/
private boolean isAudioFile(Path file) {
String fileName = file.getFileName().toString().toLowerCase();
int lastDot = fileName.lastIndexOf('.');
if (lastDot == -1) {
return false;
}
String extension = fileName.substring(lastDot + 1);
return AUDIO_EXTENSIONS.contains(extension);
}
/**
* 解析目标文件路径,处理文件名冲突
*/
private Path resolveTargetFile(Path targetDir, String fileName) {
Path targetFile = targetDir.resolve(fileName);
// 如果文件不存在,直接返回
if (!Files.exists(targetFile)) {
return targetFile;
}
// 处理文件名冲突:文件名 (1).ext, 文件名 (2).ext ...
int lastDot = fileName.lastIndexOf('.');
String baseName;
String extension;
if (lastDot == -1) {
baseName = fileName;
extension = "";
} else {
baseName = fileName.substring(0, lastDot);
extension = fileName.substring(lastDot);
}
int counter = 1;
while (Files.exists(targetFile)) {
String newFileName = String.format("%s (%d)%s", baseName, counter, extension);
targetFile = targetDir.resolve(newFileName);
counter++;
}
return targetFile;
}
/**
* 清理空目录(移动模式下使用)
*/
private void cleanupEmptyDirectories(Path rootPath) {
try {
Files.walkFileTree(rootPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
// 不删除根目录本身
if (dir.equals(rootPath)) {
return FileVisitResult.CONTINUE;
}
try {
Files.deleteIfExists(dir);
} catch (DirectoryNotEmptyException e) {
// 目录不为空,忽略
}
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
// 清理失败不影响主流程
}
}
/**
* 发送进度消息
*/
private void sendProgress(String taskId, int total, int processed, int success,
int failed, String currentFile, String message, boolean completed) {
ProgressMessage progress = new ProgressMessage();
progress.setTaskId(taskId);
progress.setType("aggregate");
progress.setTotal(total);
progress.setProcessed(processed);
progress.setSuccess(success);
progress.setFailed(failed);
progress.setCurrentFile(currentFile);
progress.setMessage(message);
progress.setCompleted(completed);
progressStore.put(progress);
messagingTemplate.convertAndSend("/topic/progress/" + taskId, progress);
}
}

View File

@@ -0,0 +1,219 @@
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 java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
@Service
public class ConvertService {
private static final Logger log = LoggerFactory.getLogger(ConvertService.class);
/** 无损格式,需转换为 FLAC */
private static final Set<String> LOSSLESS_EXTENSIONS = new HashSet<>(Arrays.asList(
"wav", "ape", "aiff", "aif", "wv", "tta"
));
/** 有损格式及 FLAC跳过不处理 */
private static final Set<String> SKIP_EXTENSIONS = new HashSet<>(Arrays.asList(
"flac", "mp3", "m4a", "aac", "ogg", "opus", "wma"
));
private static final int FFMPEG_COMPRESSION_LEVEL = 5;
private final SimpMessagingTemplate messagingTemplate;
private final ProgressStore progressStore;
public ConvertService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) {
this.messagingTemplate = messagingTemplate;
this.progressStore = progressStore;
}
@Async
public void convert(String taskId, String srcDir, String dstDir, String mode) {
Path srcPath = Paths.get(srcDir);
Path dstPath = Paths.get(dstDir);
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 ("move".equalsIgnoreCase(mode) && srcPath.equals(dstPath)) {
sendProgress(taskId, 0, 0, 0, 0, null, "移动模式下,输入目录和输出目录不能相同", true);
return;
}
// 创建输出目录(如果不存在)
if (!Files.exists(dstPath)) {
Files.createDirectories(dstPath);
}
// 扫描输入目录,查找需要转换的文件
List<Path> toConvert = new ArrayList<>();
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (shouldConvert(file)) {
toConvert.add(file);
}
return FileVisitResult.CONTINUE;
}
});
int total = toConvert.size();
AtomicInteger processed = new AtomicInteger(0);
AtomicInteger success = new AtomicInteger(0);
AtomicInteger failed = new AtomicInteger(0);
log.info("转码任务开始,扫描目录: {}, 待转码文件数: {}", srcPath, total);
sendProgress(taskId, total, 0, 0, 0, null, "扫描完成,开始转码任务...", false);
// 如果目录存在但没有需要转换的文件,正常完成任务
if (total == 0) {
log.info("目录 {} 中未找到需要转码的无损音频文件", srcPath);
sendProgress(taskId, 0, 0, 0, 0, null,
"目录扫描完成未找到需要转码的无损音频文件WAV/APE/AIFF/WV/TTA。目录中可能只有 FLAC 或有损格式文件,已自动跳过。", true);
return;
}
for (Path srcFile : toConvert) {
String fileName = srcFile.getFileName().toString();
String baseName = getBaseName(fileName);
String outFileName = baseName + ".flac";
Path outFile = resolveTargetFile(dstPath, outFileName);
try {
runFfmpeg(srcFile, outFile);
success.incrementAndGet();
if ("move".equalsIgnoreCase(mode)) {
try {
Files.deleteIfExists(srcFile);
} catch (IOException e) {
log.warn("移动模式删除源文件失败: {}", srcFile, e);
}
}
sendProgress(taskId, total, processed.incrementAndGet(),
success.get(), failed.get(), fileName,
"已处理: " + fileName, false);
} catch (Exception e) {
failed.incrementAndGet();
log.warn("转码失败: {} - {}", fileName, e.getMessage());
sendProgress(taskId, total, processed.incrementAndGet(),
success.get(), failed.get(), fileName,
"转码失败: " + fileName + " - " + e.getMessage(), false);
}
}
sendProgress(taskId, total, processed.get(), success.get(), failed.get(),
null, String.format("任务完成!成功: %d, 失败: %d", success.get(), failed.get()), true);
} catch (Exception e) {
log.error("转码任务执行失败", e);
sendProgress(taskId, 0, 0, 0, 0, null,
"任务执行失败: " + e.getMessage(), true);
}
}
private boolean shouldConvert(Path file) {
String ext = getExtension(file.getFileName().toString());
if (ext == null) return false;
return LOSSLESS_EXTENSIONS.contains(ext);
}
private String getExtension(String fileName) {
int i = fileName.lastIndexOf('.');
if (i == -1 || i == fileName.length() - 1) return null;
return fileName.substring(i + 1).toLowerCase();
}
private String getBaseName(String fileName) {
int i = fileName.lastIndexOf('.');
if (i <= 0) return fileName;
return fileName.substring(0, i);
}
private void runFfmpeg(Path input, Path output) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg",
"-y",
"-i", input.toAbsolutePath().toString(),
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
output.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process p = pb.start();
int exit = p.waitFor();
if (exit != 0) {
throw new RuntimeException("ffmpeg 退出码: " + exit);
}
}
private Path resolveTargetFile(Path targetDir, String fileName) throws IOException {
Path target = targetDir.resolve(fileName);
if (!Files.exists(target)) return target;
int lastDot = fileName.lastIndexOf('.');
String base = lastDot > 0 ? fileName.substring(0, lastDot) : fileName;
String ext = lastDot > 0 ? fileName.substring(lastDot) : "";
int n = 1;
while (Files.exists(target)) {
String next = base + " (" + n + ")" + ext;
target = targetDir.resolve(next);
n++;
}
return target;
}
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("convert");
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);
log.debug("发送进度消息: taskId={}, total={}, processed={}, success={}, failed={}, completed={}",
taskId, total, processed, success, failed, completed);
} catch (Exception e) {
log.error("发送进度消息失败", e);
}
}
}

View File

@@ -0,0 +1,454 @@
package com.music.service;
import com.music.dto.ProgressMessage;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 音乐去重服务(首版:仅实现 MD5 去重)
*
* 说明:
* - 目前实现的是基于 MD5 的二进制级别去重,用于识别完全相同的文件拷贝。
* - 元数据匹配与智能评分策略后续迭代中补充。
*/
@Service
public class DedupService {
private static final Logger log = LoggerFactory.getLogger(DedupService.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 int DURATION_TOLERANCE_SECONDS = 5;
private final SimpMessagingTemplate messagingTemplate;
private final ProgressStore progressStore;
public DedupService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) {
this.messagingTemplate = messagingTemplate;
this.progressStore = progressStore;
}
/**
* 异步执行去重任务
*/
@Async
public void dedup(String taskId,
String libraryDir,
String trashDir,
boolean useMd5,
boolean useMetadata,
String mode) {
Path libraryPath = Paths.get(libraryDir);
Path trashPath = Paths.get(trashDir);
try {
// 基本校验
if (!Files.exists(libraryPath) || !Files.isDirectory(libraryPath)) {
sendProgress(taskId, 0, 0, 0, 0,
"音乐库目录不存在或不是目录", true);
return;
}
if (!Files.exists(trashPath)) {
Files.createDirectories(trashPath);
}
if (!"copy".equalsIgnoreCase(mode) && !"move".equalsIgnoreCase(mode)) {
sendProgress(taskId, 0, 0, 0, 0,
"执行模式错误,必须是 copy 或 move", true);
return;
}
if (!useMd5 && !useMetadata) {
sendProgress(taskId, 0, 0, 0, 0,
"至少需要启用一种去重策略MD5 或元数据匹配)", true);
return;
}
// 收集所有音频文件
List<Path> audioFiles = new ArrayList<>();
Files.walkFileTree(libraryPath, 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,
"未在音乐库中找到音频文件", true);
return;
}
AtomicInteger scanned = new AtomicInteger(0);
AtomicInteger duplicateGroups = new AtomicInteger(0);
AtomicInteger moved = new AtomicInteger(0);
AtomicInteger failed = new AtomicInteger(0);
sendProgress(taskId, total, 0, 0, 0,
"开始扫描音乐库...", false);
Map<String, List<Path>> md5Groups = new HashMap<>();
Map<MetadataKey, List<Path>> metadataGroups = new HashMap<>();
// 第一阶段:扫描并根据配置构建分组
for (Path file : audioFiles) {
try {
if (useMd5) {
String md5 = calculateMd5(file);
md5Groups.computeIfAbsent(md5, k -> new ArrayList<>()).add(file);
}
if (useMetadata) {
Optional<MetadataKey> keyOpt = readMetadataKey(file);
keyOpt.ifPresent(key -> metadataGroups
.computeIfAbsent(key, k -> new ArrayList<>())
.add(file));
}
int currentScanned = scanned.incrementAndGet();
if (currentScanned % 50 == 0) {
sendProgress(taskId, total, currentScanned,
duplicateGroups.get(), moved.get(),
String.format("扫描中(%d/%d", currentScanned, total),
false);
}
} catch (Exception e) {
failed.incrementAndGet();
log.warn("扫描文件失败: {}", file, e);
}
}
// 第二阶段:处理 MD5 去重结果(完全二进制重复)
if (useMd5) {
for (Map.Entry<String, List<Path>> entry : md5Groups.entrySet()) {
List<Path> group = entry.getValue();
if (group.size() <= 1) {
continue;
}
duplicateGroups.incrementAndGet();
Path keep = chooseBestFileByScore(group);
List<Path> duplicates = new ArrayList<>(group);
duplicates.remove(keep);
moved.addAndGet(handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, failed));
}
}
if (useMetadata) {
// 第三阶段:处理元数据匹配去重结果
for (Map.Entry<MetadataKey, List<Path>> entry : metadataGroups.entrySet()) {
List<Path> group = entry.getValue();
if (group.size() <= 1) {
continue;
}
duplicateGroups.incrementAndGet();
Path keep = chooseBestFileByScore(group);
List<Path> duplicates = new ArrayList<>(group);
duplicates.remove(keep);
moved.addAndGet(handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, failed));
}
}
sendProgress(taskId, total, scanned.get(),
duplicateGroups.get(), moved.get(),
String.format("任务完成!扫描文件: %d, 重复组: %d, 移动/复制文件: %d",
scanned.get(), duplicateGroups.get(), moved.get()),
true);
} catch (Exception e) {
log.error("去重任务执行失败", e);
sendProgress(taskId, 0, 0, 0, 0,
"任务执行失败: " + e.getMessage(), true);
}
}
private boolean isAudioFile(Path file) {
String name = file.getFileName().toString().toLowerCase();
int idx = name.lastIndexOf('.');
if (idx <= 0 || idx == name.length() - 1) {
return false;
}
String ext = name.substring(idx + 1);
return AUDIO_EXTENSIONS.contains(ext);
}
private String calculateMd5(Path file) throws IOException, NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
try (InputStream is = Files.newInputStream(file);
DigestInputStream dis = new DigestInputStream(is, md)) {
byte[] buffer = new byte[8192];
// 读取整个文件,结果自动更新到 md 中
while (dis.read(buffer) != -1) {
// no-op
}
}
byte[] digest = md.digest();
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
/**
* 元数据分组键:艺术家 + 标题 + 专辑 + 时长(按 5 秒误差归一)
*/
private static class MetadataKey {
private final String artist;
private final String title;
private final String album;
private final int normalizedDuration;
private MetadataKey(String artist, String title, String album, int normalizedDuration) {
this.artist = artist;
this.title = title;
this.album = album;
this.normalizedDuration = normalizedDuration;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof MetadataKey)) return false;
MetadataKey that = (MetadataKey) o;
return normalizedDuration == that.normalizedDuration &&
Objects.equals(artist, that.artist) &&
Objects.equals(title, that.title) &&
Objects.equals(album, that.album);
}
@Override
public int hashCode() {
return Objects.hash(artist, title, album, normalizedDuration);
}
}
/**
* 从音频文件读取用于匹配的元数据键
*/
private Optional<MetadataKey> readMetadataKey(Path file) {
try {
AudioFile audioFile = AudioFileIO.read(file.toFile());
Tag tag = audioFile.getTag();
if (tag == null) {
return Optional.empty();
}
String artist = normalize(tag.getFirst(FieldKey.ARTIST));
String title = normalize(tag.getFirst(FieldKey.TITLE));
String album = normalize(tag.getFirst(FieldKey.ALBUM));
int lengthSec = audioFile.getAudioHeader().getTrackLength();
if (artist.isEmpty() || title.isEmpty()) {
// 核心标签缺失则跳过元数据分组
return Optional.empty();
}
// 将时长按 5 秒误差容忍度归一化
int normalizedDuration = lengthSec / DURATION_TOLERANCE_SECONDS;
return Optional.of(new MetadataKey(artist, title, album, normalizedDuration));
} catch (Exception e) {
// 标签损坏或不支持的格式时,忽略元数据去重
log.debug("读取元数据失败: {}", file, e);
return Optional.empty();
}
}
private String normalize(String s) {
if (s == null) {
return "";
}
return s.trim().toLowerCase();
}
/**
* 对一组候选文件进行综合评分,选择最佳保留文件
*
* 评分策略:
* - 格式优先FLAC > 其他无损 > 有损
* - 码率优先:高码率得分更高(如果可获取)
* - 文件大小:极小文件减分
* - 文件名噪声惩罚:含样本/preview 等噪声词减分
*/
private Path chooseBestFileByScore(List<Path> candidates) {
if (candidates.size() == 1) {
return candidates.get(0);
}
return candidates.stream()
.max(Comparator.comparingDouble(this::scoreFile))
.orElse(candidates.get(0));
}
private double scoreFile(Path file) {
double score = 0.0;
String name = file.getFileName().toString().toLowerCase();
String ext = "";
int idx = name.lastIndexOf('.');
if (idx > 0 && idx < name.length() - 1) {
ext = name.substring(idx + 1);
}
// 格式权重
if ("flac".equals(ext)) {
score += 100;
} else if (Arrays.asList("wav", "ape", "aiff", "aif", "wv", "tta").contains(ext)) {
score += 80;
} else {
score += 50; // 有损格式
}
// 文件大小KB加权更大的通常音质更好但极大文件不再线性加分
try {
long size = Files.size(file);
double sizeKB = size / 1024.0;
if (sizeKB < 128) {
score -= 30; // 极小文件,疑似样本/损坏
} else {
score += Math.min(sizeKB / 100.0, 40.0);
}
} catch (IOException e) {
// 忽略大小获取失败
}
// 文件名噪声惩罚
if (name.contains("sample") || name.contains("preview") || name.contains("demo")) {
score -= 20;
}
if (name.matches(".*\\b(live|remix|karaoke)\\b.*")) {
// 某些版本可能不是首选,略微扣分(具体偏好可根据需要调整)
score -= 5;
}
// TODO如有需要可从音频头中读取比特率进一步加权
return score;
}
/**
* 将重复文件移动/复制到回收站,并更新统计与进度
*
* @return 实际成功移动/复制的文件数量
*/
private int handleDuplicates(List<Path> duplicates,
Path keep,
Path trashPath,
String mode,
String taskId,
int total,
AtomicInteger scanned,
AtomicInteger duplicateGroups,
AtomicInteger failed) {
int movedCount = 0;
for (Path dup : duplicates) {
try {
Path target = resolveTargetFile(trashPath, dup.getFileName().toString());
if ("move".equalsIgnoreCase(mode)) {
Files.move(dup, target, StandardCopyOption.REPLACE_EXISTING);
} else {
Files.copy(dup, target, StandardCopyOption.REPLACE_EXISTING);
}
movedCount++;
sendProgress(taskId, total, scanned.get(),
duplicateGroups.get(), movedCount,
String.format("重复文件: %s (保留: %s)",
dup.getFileName(), keep.getFileName()),
false);
} catch (Exception e) {
failed.incrementAndGet();
log.warn("处理重复文件失败: {}", dup, e);
}
}
return movedCount;
}
/**
* 解析回收站中的目标文件名,处理重名冲突
*/
private Path resolveTargetFile(Path targetDir, String fileName) throws IOException {
Path target = targetDir.resolve(fileName);
if (!Files.exists(target)) {
return target;
}
int lastDot = fileName.lastIndexOf('.');
String base = lastDot > 0 ? fileName.substring(0, lastDot) : fileName;
String ext = lastDot > 0 ? fileName.substring(lastDot) : "";
int n = 1;
while (Files.exists(target)) {
String next = base + " (" + n + ")" + ext;
target = targetDir.resolve(next);
n++;
}
return target;
}
/**
* 发送进度消息
*
* 字段语义(供前端展示用):
* - total扫描到的音频文件总数
* - processed已扫描文件数
* - success重复组数量
* - failed移动/复制的重复文件数量
*
* 由于进度字段在不同任务中的含义略有差异,前端可根据 type === "dedup" 做专门映射。
*/
private void sendProgress(String taskId,
int total,
int processed,
int success,
int failed,
String message,
boolean completed) {
ProgressMessage pm = new ProgressMessage();
pm.setTaskId(taskId);
pm.setType("dedup");
pm.setTotal(total);
pm.setProcessed(processed);
pm.setSuccess(success);
pm.setFailed(failed);
pm.setCurrentFile(null);
pm.setMessage(message);
pm.setCompleted(completed);
progressStore.put(pm);
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
}
}

View File

@@ -0,0 +1,30 @@
package com.music.service;
import com.music.dto.ProgressMessage;
import org.springframework.stereotype.Service;
import java.util.concurrent.ConcurrentHashMap;
/**
* 进度缓存:用于 WebSocket 消息丢失时的兜底查询
*/
@Service
public class ProgressStore {
private final ConcurrentHashMap<String, ProgressMessage> latest = new ConcurrentHashMap<>();
public void put(ProgressMessage message) {
if (message == null || message.getTaskId() == null) {
return;
}
latest.put(message.getTaskId(), message);
}
public ProgressMessage get(String taskId) {
if (taskId == null) {
return null;
}
return latest.get(taskId);
}
}

View File

@@ -0,0 +1,147 @@
package com.music.service;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* 繁体检测与转换工具服务
*
* <p>说明:
* - 这里实现的是一个轻量级的基于字典的繁简转换和检测
* - 仅覆盖常见的繁体字,后续可接入更完整的第三方词库或服务
*/
@Service
public class TraditionalFilterService {
/**
* 常见繁体字符映射表(可按需扩展)
*/
private static final Map<Character, Character> TRAD_TO_SIMP_MAP = new HashMap<>();
/**
* 常见繁体字符集合,加速检测
*/
private static final Set<Character> TRADITIONAL_SET = new HashSet<>();
static {
// 人名 / 常用字
addMapping('倫', '伦');
addMapping('愛', '爱');
addMapping('聲', '声');
addMapping('轉', '转');
addMapping('幹', '干');
addMapping('後', '后');
addMapping('來', '来');
addMapping('體', '体');
addMapping('風', '风');
addMapping('陽', '阳');
addMapping('廣', '广');
addMapping('門', '门');
addMapping('馬', '马');
addMapping('國', '国');
addMapping('書', '书');
addMapping('樂', '乐');
addMapping('現', '现');
addMapping('時', '时');
addMapping('總', '总');
addMapping('開', '开');
addMapping('關', '关');
addMapping('電', '电');
addMapping('錄', '录');
addMapping('戲', '戏');
addMapping('畫', '画');
addMapping('聲', '声');
// 可以根据需要逐步补充更多常见映射
}
private static void addMapping(char trad, char simp) {
TRAD_TO_SIMP_MAP.put(trad, simp);
TRADITIONAL_SET.add(trad);
}
/**
* 检测字符串中的中文/繁体字数量
*/
public ZhStats analyze(String text) {
if (text == null || text.isEmpty()) {
return new ZhStats(0, 0);
}
int chineseCount = 0;
int traditionalCount = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (isCjk(c)) {
chineseCount++;
if (TRADITIONAL_SET.contains(c)) {
traditionalCount++;
}
}
}
return new ZhStats(chineseCount, traditionalCount);
}
/**
* 将字符串中的常见繁体字转换为简体
*/
public String toSimplified(String text) {
if (text == null || text.isEmpty()) {
return text;
}
StringBuilder sb = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
Character mapped = TRAD_TO_SIMP_MAP.get(c);
sb.append(mapped != null ? mapped : c);
}
return sb.toString();
}
/**
* 判断是否为 CJK 统一表意文字(大致范围)
*/
private boolean isCjk(char c) {
// 基本汉字 + 扩展区的一部分
return (c >= 0x4E00 && c <= 0x9FFF) // CJK Unified Ideographs
|| (c >= 0x3400 && c <= 0x4DBF); // CJK Unified Ideographs Extension A
}
/**
* 繁体检测统计结果
*/
public static class ZhStats {
private final int chineseCount;
private final int traditionalCount;
public ZhStats(int chineseCount, int traditionalCount) {
this.chineseCount = chineseCount;
this.traditionalCount = traditionalCount;
}
public int getChineseCount() {
return chineseCount;
}
public int getTraditionalCount() {
return traditionalCount;
}
/**
* 繁体占比0.0 - 1.0
*/
public double getRatio() {
if (chineseCount <= 0) {
return 0.0;
}
return (double) traditionalCount / (double) chineseCount;
}
}
}

View File

@@ -0,0 +1,286 @@
package com.music.service;
import com.music.dto.ProgressMessage;
import com.music.service.TraditionalFilterService.ZhStats;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 音乐元数据繁体转简体服务
*
* <p>进度字段语义type = "zhconvert"
* - total扫描到的音频文件总数
* - processed已扫描文件数
* - success检测到的繁体标签条目数量字段级Title/Artist/Album/AlbumArtist
* - failed处理失败的文件数量
* - currentFile当前正在处理的文件名
*/
@Service
public class ZhConvertService {
private static final Logger log = LoggerFactory.getLogger(ZhConvertService.class);
private static final Set<String> AUDIO_EXTENSIONS = ConcurrentHashMap.newKeySet();
static {
AUDIO_EXTENSIONS.addAll(Arrays.asList(
"mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape", "aiff", "aif", "wv", "tta", "opus"
));
}
private final SimpMessagingTemplate messagingTemplate;
private final ProgressStore progressStore;
private final TraditionalFilterService traditionalFilterService;
public ZhConvertService(SimpMessagingTemplate messagingTemplate,
ProgressStore progressStore,
TraditionalFilterService traditionalFilterService) {
this.messagingTemplate = messagingTemplate;
this.progressStore = progressStore;
this.traditionalFilterService = traditionalFilterService;
}
/**
* 异步执行繁体检测与转换任务
*
* @param mode preview / execute
* @param thresholdRatio 触发阈值0.0-1.0
*/
@Async
public void process(String taskId,
String scanDir,
String outputDir,
String mode,
double thresholdRatio) {
Path scanPath = Paths.get(scanDir);
try {
if (!Files.exists(scanPath) || !Files.isDirectory(scanPath)) {
sendProgress(taskId, 0, 0, 0, 0, null,
"扫描目录不存在或不是目录", true);
return;
}
Path outputPath = null;
boolean execute = "execute".equalsIgnoreCase(mode);
if (execute && outputDir != null && !outputDir.trim().isEmpty()) {
outputPath = Paths.get(outputDir.trim());
if (!Files.exists(outputPath)) {
Files.createDirectories(outputPath);
}
}
// 收集音频文件
List<Path> audioFiles = new ArrayList<>();
Files.walkFileTree(scanPath, 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;
}
AtomicInteger processed = new AtomicInteger(0);
AtomicInteger traditionalEntries = new AtomicInteger(0);
AtomicInteger failed = new AtomicInteger(0);
sendProgress(taskId, total, 0, 0, 0, null,
execute ? "开始执行繁体→简体转换任务..." : "开始繁体检测预览任务...", false);
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 currentProcessed = processed.incrementAndGet();
sendProgress(taskId,
total,
currentProcessed,
traditionalEntries.get(),
failed.get(),
fileName,
String.format("已扫描 (%d/%d)%s", currentProcessed, total, fileName),
false);
} catch (Exception e) {
failed.incrementAndGet();
int currentProcessed = processed.incrementAndGet();
log.warn("处理文件失败: {}", srcFile, e);
sendProgress(taskId,
total,
currentProcessed,
traditionalEntries.get(),
failed.get(),
fileName,
String.format("处理失败: %s - %s", fileName, e.getMessage()),
false);
}
}
sendProgress(taskId,
total,
processed.get(),
traditionalEntries.get(),
failed.get(),
null,
String.format("任务完成!检测到繁体标签条目: %d失败文件: %d",
traditionalEntries.get(), failed.get()),
true);
} catch (Exception e) {
log.error("繁体转换任务执行失败", e);
sendProgress(taskId, 0, 0, 0, 0, null,
"任务执行失败: " + e.getMessage(), true);
}
}
/**
* 处理单个音频文件
*
* @return 新增的“繁体标签条目”数量(字段级)
*/
private int handleSingleFile(Path file, double thresholdRatio, boolean execute) throws Exception {
AudioFile audioFile = AudioFileIO.read(file.toFile());
Tag tag = audioFile.getTag();
if (tag == null) {
return 0;
}
int entries = 0;
entries += handleSingleField(tag, FieldKey.TITLE, thresholdRatio, execute);
entries += handleSingleField(tag, FieldKey.ARTIST, thresholdRatio, execute);
entries += handleSingleField(tag, FieldKey.ALBUM, thresholdRatio, execute);
entries += handleSingleField(tag, FieldKey.ALBUM_ARTIST, thresholdRatio, execute);
// 如果是执行模式且有字段被修改,则写回文件
if (execute && entries > 0) {
audioFile.commit();
}
return entries;
}
/**
* 处理单个标签字段:检测 + 可选转换
*
* @return 如果该字段触发阈值,则返回 1否则返回 0
*/
private int handleSingleField(Tag tag,
FieldKey fieldKey,
double thresholdRatio,
boolean execute) {
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;
}
if (execute) {
String converted = traditionalFilterService.toSimplified(original);
try {
tag.setField(fieldKey, converted);
} catch (Exception e) {
// 单个字段失败不影响整体流程
log.debug("更新标签字段失败: {} - {}", fieldKey.name(), e.getMessage());
}
}
// 该字段计为 1 个“繁体标签条目”
return 1;
}
private boolean isAudioFile(Path file) {
String name = file.getFileName().toString().toLowerCase();
int idx = name.lastIndexOf('.');
if (idx <= 0 || idx == name.length() - 1) {
return false;
}
String ext = name.substring(idx + 1);
return AUDIO_EXTENSIONS.contains(ext);
}
/**
* 决定实际处理的目标文件:
* - 预览模式:始终返回原文件路径
* - 执行模式:
* - 未配置输出目录:在原文件上就地修改
* - 配置了输出目录:先复制到输出目录,再在副本上修改
*/
private Path resolveTargetFileForExecute(Path srcFile,
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);
}
return targetFile;
}
private void sendProgress(String taskId,
int total,
int processed,
int entries,
int failed,
String currentFile,
String message,
boolean completed) {
try {
ProgressMessage pm = new ProgressMessage();
pm.setTaskId(taskId);
pm.setType("zhconvert");
pm.setTotal(total);
pm.setProcessed(processed);
pm.setSuccess(entries);
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

@@ -0,0 +1,16 @@
server:
port: 8080
spring:
application:
name: mangtool-backend
jackson:
serialization:
write-dates-as-timestamps: false
logging:
level:
root: INFO
com.music: DEBUG