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.
385 lines
16 KiB
Java
385 lines
16 KiB
Java
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);
|
||
}
|
||
}
|
||
}
|