Files
MyTool/backend/src/main/java/com/music/service/LibraryMergeService.java
mangmang 81977a157e Improve music processing robustness and workflow UX
Unify safe file-move behavior and richer progress semantics across backend tasks, while upgrading traditional-to-simplified conversion and refining the frontend multi-step panels for clearer execution feedback.
2026-03-08 04:26:18 +08:00

385 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.music.service;
import com.music.common.FileTransferUtils;
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 static final Set<String> EXCLUDED_ROOT_DIRS = new HashSet<>(Arrays.asList(
"_Manual_Fix_Required_", "_Reports"
));
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, 0, null, "源目录不能为空", true);
return;
}
if (!Files.exists(srcPath) || !Files.isDirectory(srcPath)) {
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
return;
}
if (dstDir == null || dstDir.trim().isEmpty()) {
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "目标目录不能为空", true);
return;
}
if (srcPath.normalize().equals(dstPath.normalize())) {
sendProgress(taskId, 0, 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 preVisitDirectory(Path dir, BasicFileAttributes attrs) {
if (isExcludedSystemDirectory(srcPath, dir)) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@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, 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, 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);
}
FileTransferUtils.moveWithFallback(audioFile, targetPath);
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(), skipped.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(), skipped.get(), audioFile.getFileName().toString(),
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
}
continue;
}
} else {
// 新文件,直接移动
Files.createDirectories(targetPath.getParent());
FileTransferUtils.moveWithFallback(audioFile, targetPath);
}
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) {
FileTransferUtils.moveWithFallback(lyricsFile, lyricsTarget);
}
}
// 处理封面(每个专辑目录只处理一次)
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)) {
// 目标目录没有封面,直接移动
FileTransferUtils.moveWithFallback(coverFile, coverTarget);
} else {
// 比较封面,保留更好的版本
if (isBetterCover(coverFile, coverTarget)) {
if (keepBackup) {
Path backupPath = coverTarget.resolveSibling("cover.jpg.backup");
Files.copy(coverTarget, backupPath, StandardCopyOption.REPLACE_EXISTING);
}
FileTransferUtils.moveWithFallback(coverFile, coverTarget);
}
}
}
}
} 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(), skipped.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(), skipped.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, 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 boolean isExcludedSystemDirectory(Path sourceRoot, Path dir) {
if (dir == null || sourceRoot == null) {
return false;
}
if (dir.equals(sourceRoot)) {
return false;
}
Path relative = sourceRoot.relativize(dir);
if (relative.getNameCount() <= 0) {
return false;
}
String firstSegment = relative.getName(0).toString();
return EXCLUDED_ROOT_DIRS.contains(firstSegment);
}
/**
* 发送进度消息
*/
private void sendProgress(String taskId, int total, int processed, int albumsMerged,
int tracksMerged, int upgraded, int skipped,
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.setAlbumsMerged(albumsMerged);
pm.setTracksMerged(tracksMerged);
pm.setUpgradedFiles(upgraded);
pm.setSkippedFiles(skipped);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);
progressStore.put(pm);
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
} catch (Exception e) {
log.error("发送进度失败", e);
}
}
}