提交代码
This commit is contained in:
350
backend/src/main/java/com/music/service/LibraryMergeService.java
Normal file
350
backend/src/main/java/com/music/service/LibraryMergeService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user