Template
Add one-click Navidrome ingestion workflow
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.IngestRequest;
|
||||
import com.music.dto.IngestStatusResponse;
|
||||
import com.music.dto.ProgressMessage;
|
||||
import com.music.exception.BusinessException;
|
||||
import com.music.service.ConfigService;
|
||||
import com.music.service.IngestService;
|
||||
import com.music.service.ProgressStore;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 一键导入控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/ingest")
|
||||
@Validated
|
||||
public class IngestController {
|
||||
|
||||
private final IngestService ingestService;
|
||||
private final ProgressStore progressStore;
|
||||
private final ConfigService configService;
|
||||
|
||||
/** 防止并发执行的生命周期锁,由 IngestService 在 finally 中释放 */
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
/** 当前/最近一次任务的 taskId,用于 GET /api/ingest/status 恢复 */
|
||||
private volatile String currentTaskId;
|
||||
|
||||
public IngestController(IngestService ingestService,
|
||||
ProgressStore progressStore,
|
||||
ConfigService configService) {
|
||||
this.ingestService = ingestService;
|
||||
this.progressStore = progressStore;
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一键导入任务。
|
||||
* 并发锁在 IngestService 的 finally 块中自动释放。
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public Result<StartResponse> start(@RequestBody(required = false) IngestRequest request) {
|
||||
if (configService.getBasePath() == null) {
|
||||
throw new BusinessException(400, "请先配置工作根目录");
|
||||
}
|
||||
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
throw new BusinessException(409, "导入任务已在运行中,请等待完成");
|
||||
}
|
||||
|
||||
String taskId = UUID.randomUUID().toString();
|
||||
currentTaskId = taskId;
|
||||
ingestService.ingest(taskId, running);
|
||||
return Result.success(new StartResponse(taskId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前/最近一次导入任务的状态(页面刷新恢复用)。
|
||||
* 优先返回正在运行的任务,否则返回最近完成的任务。
|
||||
*/
|
||||
@GetMapping("/status")
|
||||
public Result<IngestStatusResponse> statusCurrent() {
|
||||
String tid = currentTaskId;
|
||||
if (tid == null) {
|
||||
return Result.success(new IngestStatusResponse(null, false, false,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, "暂无任务记录"));
|
||||
}
|
||||
return statusById(tid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定 taskId 的任务状态
|
||||
*/
|
||||
@GetMapping("/status/{taskId}")
|
||||
public Result<IngestStatusResponse> statusById(@PathVariable("taskId") String taskId) {
|
||||
ProgressMessage pm = progressStore.get(taskId);
|
||||
if (pm == null) {
|
||||
return Result.success(new IngestStatusResponse(taskId, running.get(), false,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, "未找到任务或已过期"));
|
||||
}
|
||||
|
||||
IngestStatusResponse resp = new IngestStatusResponse(
|
||||
taskId, running.get(), pm.isCompleted(),
|
||||
pm.getTotal(), pm.getProcessed(),
|
||||
optInt(pm.getIngestedFiles()),
|
||||
optInt(pm.getDuplicateFiles()),
|
||||
optInt(pm.getMissingMetadataFiles()),
|
||||
optInt(pm.getUnreadableFiles()),
|
||||
optInt(pm.getConversionFailedFiles()),
|
||||
optInt(pm.getOtherRejectedFiles()),
|
||||
pm.getMessage()
|
||||
);
|
||||
|
||||
if (pm.isCompleted()) {
|
||||
running.set(false);
|
||||
}
|
||||
|
||||
return Result.success(resp);
|
||||
}
|
||||
|
||||
private int optInt(Integer val) {
|
||||
return val == null ? 0 : val;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import lombok.NoArgsConstructor;
|
||||
public class ConfigResponse {
|
||||
private String basePath;
|
||||
private String inputDir;
|
||||
private String libraryDir;
|
||||
private String rejectedDir;
|
||||
// 保留原有字段以保证向后兼容
|
||||
private String aggregatedDir;
|
||||
private String formatIssuesDir;
|
||||
private String duplicatesDir;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 一键导入请求(使用已保存的配置路径,无需额外参数)
|
||||
*/
|
||||
@Data
|
||||
public class IngestRequest {
|
||||
// 无参请求,所有路径从已保存配置读取
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 导入任务结果汇总
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IngestStatusResponse {
|
||||
private String taskId;
|
||||
private boolean running;
|
||||
private boolean completed;
|
||||
private int total;
|
||||
private int processed;
|
||||
private int ingestedFiles;
|
||||
private int duplicateFiles;
|
||||
private int missingMetadataFiles;
|
||||
private int unreadableFiles;
|
||||
private int conversionFailedFiles;
|
||||
private int otherRejectedFiles;
|
||||
private String message;
|
||||
}
|
||||
@@ -29,4 +29,12 @@ public class ProgressMessage {
|
||||
private Integer tracksMerged; // merge: 已合并曲目数
|
||||
private Integer upgradedFiles; // merge: 升级替换文件数
|
||||
private Integer skippedFiles; // merge: 跳过文件数
|
||||
|
||||
// ingest 任务专属字段
|
||||
private Integer ingestedFiles; // ingest: 成功入库文件数
|
||||
private Integer duplicateFiles; // ingest: 跳过重复文件数
|
||||
private Integer missingMetadataFiles; // ingest: 缺少元数据文件数
|
||||
private Integer unreadableFiles; // ingest: 无法读取文件数
|
||||
private Integer conversionFailedFiles; // ingest: 转码失败文件数
|
||||
private Integer otherRejectedFiles; // ingest: 其他原因拒绝文件数
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ public class ConfigService {
|
||||
|
||||
// 子目录名称常量
|
||||
private static final String INPUT_DIR = "Input";
|
||||
private static final String LIBRARY_DIR = "Library";
|
||||
private static final String REJECTED_DIR = "Rejected";
|
||||
// 保留原有子目录以保持向后兼容
|
||||
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";
|
||||
@@ -114,6 +117,9 @@ public class ConfigService {
|
||||
ConfigResponse response = new ConfigResponse();
|
||||
response.setBasePath(basePath);
|
||||
response.setInputDir(PathUtils.joinPath(basePath, INPUT_DIR));
|
||||
response.setLibraryDir(PathUtils.joinPath(basePath, LIBRARY_DIR));
|
||||
response.setRejectedDir(PathUtils.joinPath(basePath, REJECTED_DIR));
|
||||
// 保留原有字段
|
||||
response.setAggregatedDir(PathUtils.joinPath(basePath, STAGING_AGGREGATED));
|
||||
response.setFormatIssuesDir(PathUtils.joinPath(basePath, STAGING_FORMAT_ISSUES));
|
||||
response.setDuplicatesDir(PathUtils.joinPath(basePath, STAGING_DUPLICATES));
|
||||
@@ -130,6 +136,8 @@ public class ConfigService {
|
||||
private void createSubDirectories(String basePath) {
|
||||
String[] subDirs = {
|
||||
INPUT_DIR,
|
||||
LIBRARY_DIR,
|
||||
REJECTED_DIR,
|
||||
STAGING_AGGREGATED,
|
||||
STAGING_FORMAT_ISSUES,
|
||||
STAGING_DUPLICATES,
|
||||
@@ -158,6 +166,20 @@ public class ConfigService {
|
||||
return getDerivedPath(INPUT_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Library目录路径
|
||||
*/
|
||||
public String getLibraryDir() {
|
||||
return getDerivedPath(LIBRARY_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Rejected目录路径
|
||||
*/
|
||||
public String getRejectedDir() {
|
||||
return getDerivedPath(REJECTED_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Staging_Aggregated目录路径
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,959 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.common.FileTransferUtils;
|
||||
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.jaudiotagger.tag.images.Artwork;
|
||||
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.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 一键导入服务 —— 将 Input 目录中的音频文件扫描、校验、繁简转换、格式转换、
|
||||
* 去重后直接纳入 Navidrome 兼容的 Library 目录布局。
|
||||
*
|
||||
* <p>进度字段语义(type = "ingest"):
|
||||
* <ul>
|
||||
* <li>total:扫描到的音频文件总数</li>
|
||||
* <li>processed:已处理文件数</li>
|
||||
* <li>success / ingestedFiles:成功入库文件数</li>
|
||||
* <li>duplicateFiles:跳过重复文件数</li>
|
||||
* <li>missingMetadataFiles:缺少 Title/Artist/Album 的文件数</li>
|
||||
* <li>unreadableFiles:无法读取元数据的文件数</li>
|
||||
* <li>conversionFailedFiles:格式转换失败文件数</li>
|
||||
* <li>failed / otherRejectedFiles:其他原因拒绝文件数</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class IngestService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IngestService.class);
|
||||
|
||||
/** 需要转换为 FLAC 的无损格式 */
|
||||
private static final Set<String> LOSSLESS_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"wav", "ape", "aiff", "aif", "wv", "tta"
|
||||
));
|
||||
|
||||
/** 可直接保留的格式 */
|
||||
private static final Set<String> COMPATIBLE_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"flac", "mp3", "m4a", "aac", "ogg", "opus", "wma"
|
||||
));
|
||||
|
||||
/** 所有支持的音频格式 */
|
||||
private static final Set<String> ALL_AUDIO_EXTENSIONS = new HashSet<>();
|
||||
|
||||
static {
|
||||
ALL_AUDIO_EXTENSIONS.addAll(LOSSLESS_EXTENSIONS);
|
||||
ALL_AUDIO_EXTENSIONS.addAll(COMPATIBLE_EXTENSIONS);
|
||||
}
|
||||
|
||||
/** 需要繁简转换的标签字段 */
|
||||
private static final FieldKey[] TEXT_FIELDS = {
|
||||
FieldKey.TITLE, FieldKey.ARTIST, FieldKey.ALBUM, FieldKey.ALBUM_ARTIST
|
||||
};
|
||||
|
||||
private static final int FFMPEG_COMPRESSION_LEVEL = 5;
|
||||
private static final int FFMPEG_CHECK_TIMEOUT_SECONDS = 10;
|
||||
private static final int FFMPEG_CONVERT_TIMEOUT_SECONDS = 600;
|
||||
private static final String FFMPEG_BIN_PROPERTY = "mangtool.ffmpeg.bin";
|
||||
private static final String REPORT_SUBDIR = "Reports";
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final ProgressStore progressStore;
|
||||
private final TraditionalFilterService traditionalFilterService;
|
||||
private final ConfigService configService;
|
||||
|
||||
public IngestService(SimpMessagingTemplate messagingTemplate,
|
||||
ProgressStore progressStore,
|
||||
TraditionalFilterService traditionalFilterService,
|
||||
ConfigService configService) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.progressStore = progressStore;
|
||||
this.traditionalFilterService = traditionalFilterService;
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步执行一键导入任务。
|
||||
* <p><b>并发锁</b>:{@code runningLock} 由控制器创建,本方法在 finally 块中释放,
|
||||
* 保证无论正常完成、异常、预检失败都能解锁。
|
||||
*
|
||||
* @param taskId 唯一任务标识
|
||||
* @param runningLock 由控制器传入的 AtomicBoolean,任务结束时置为 false
|
||||
*/
|
||||
@Async
|
||||
public void ingest(String taskId, AtomicBoolean runningLock) {
|
||||
try {
|
||||
String inputDir = configService.getInputDir();
|
||||
String libraryDir = configService.getLibraryDir();
|
||||
String rejectedDir = configService.getRejectedDir();
|
||||
|
||||
if (inputDir == null || libraryDir == null || rejectedDir == null) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
null, "请先配置工作根目录", true);
|
||||
return;
|
||||
}
|
||||
|
||||
Path inputPath = Paths.get(inputDir);
|
||||
Path libraryPath = Paths.get(libraryDir);
|
||||
Path rejectedPath = Paths.get(rejectedDir);
|
||||
|
||||
// 路径合法性校验:三者必须互不相同且不互相包含
|
||||
String pathError = validatePaths(inputPath, libraryPath, rejectedPath);
|
||||
if (pathError != null) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
null, pathError, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
Files.createDirectories(inputPath);
|
||||
Files.createDirectories(libraryPath);
|
||||
Files.createDirectories(rejectedPath);
|
||||
|
||||
// 扫描 Input 目录中的音频文件
|
||||
List<Path> audioFiles = new ArrayList<>();
|
||||
Files.walkFileTree(inputPath, 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, 0, 0, 0, 0,
|
||||
null, "Input 目录中未发现音频文件", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 预检查 FFmpeg(如果需要转换)
|
||||
boolean mayNeedFfmpeg = audioFiles.stream().anyMatch(f -> isLosslessFormat(f));
|
||||
if (mayNeedFfmpeg) {
|
||||
String ffmpegError = checkFfmpegAvailable();
|
||||
if (ffmpegError != null) {
|
||||
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||||
null, ffmpegError, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描 Library 中已有的文件作为重复检测参考(t2s 归一化)
|
||||
Set<IdentityKey> libraryIdentities = scanLibraryIdentities(libraryPath);
|
||||
log.info("Library 中已有 {} 个曲目用于重复检测", libraryIdentities.size());
|
||||
|
||||
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||||
null, "扫描完成,开始导入...", false);
|
||||
|
||||
// 处理每个文件
|
||||
AtomicInteger ingested = new AtomicInteger(0);
|
||||
AtomicInteger duplicates = new AtomicInteger(0);
|
||||
AtomicInteger missingMeta = new AtomicInteger(0);
|
||||
AtomicInteger unreadable = new AtomicInteger(0);
|
||||
AtomicInteger convFailed = new AtomicInteger(0);
|
||||
AtomicInteger otherRejected = new AtomicInteger(0);
|
||||
AtomicInteger processed = new AtomicInteger(0);
|
||||
|
||||
// 批内重复检测集合
|
||||
Set<IdentityKey> batchIdentities = new HashSet<>();
|
||||
|
||||
// 文件结果映射(用于报告)
|
||||
LinkedHashMap<String, String> fileOutcomes = new LinkedHashMap<>();
|
||||
|
||||
for (Path srcFile : audioFiles) {
|
||||
String fileName = srcFile.getFileName().toString();
|
||||
// 使用相对于 Input 的路径作为唯一跟踪键(防止不同子目录中的同名文件互相覆盖)
|
||||
String relativeKey = inputPath.relativize(srcFile).toString();
|
||||
String outcome = "unknown";
|
||||
try {
|
||||
outcome = processSingleFile(srcFile, libraryPath, rejectedPath,
|
||||
libraryIdentities, batchIdentities,
|
||||
ingested, duplicates, missingMeta, unreadable,
|
||||
convFailed, otherRejected);
|
||||
} catch (Exception e) {
|
||||
otherRejected.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
outcome = "rejected:exception";
|
||||
log.warn("处理文件异常: {} - {}", relativeKey, e.getMessage());
|
||||
}
|
||||
fileOutcomes.put(relativeKey, outcome);
|
||||
|
||||
int p = processed.incrementAndGet();
|
||||
sendProgress(taskId, total, p, ingested.get(), duplicates.get(),
|
||||
missingMeta.get(), unreadable.get(), convFailed.get(),
|
||||
otherRejected.get(), relativeKey,
|
||||
String.format("已处理 (%d/%d): %s", p, total, relativeKey),
|
||||
false);
|
||||
}
|
||||
|
||||
// 写入结构化报告
|
||||
writeReport(taskId, fileOutcomes, rejectedPath, ingested.get(), duplicates.get(),
|
||||
missingMeta.get(), unreadable.get(), convFailed.get(), otherRejected.get());
|
||||
|
||||
// 完成
|
||||
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
|
||||
missingMeta.get(), unreadable.get(), convFailed.get(),
|
||||
otherRejected.get(), null,
|
||||
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 不可读: %d, 转码失败: %d, 其他: %d",
|
||||
ingested.get(), duplicates.get(), missingMeta.get(),
|
||||
unreadable.get(), convFailed.get(), otherRejected.get()),
|
||||
true);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("导入任务执行失败", e);
|
||||
// 发送 terminal 进度消息,确保前端停止轮询并显示错误
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
null, "任务内部错误: " + e.getMessage(), true);
|
||||
} finally {
|
||||
if (runningLock != null) {
|
||||
runningLock.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 路径校验 ==========
|
||||
|
||||
/**
|
||||
* 校验 Input / Library / Rejected 路径:
|
||||
* 三者必须互不相同且不互相嵌套(不能互为子目录)。
|
||||
*
|
||||
* @return 错误描述(null 表示通过)
|
||||
*/
|
||||
static String validatePaths(Path input, Path library, Path rejected) {
|
||||
Path absInput = input.toAbsolutePath().normalize();
|
||||
Path absLib = library.toAbsolutePath().normalize();
|
||||
Path absRej = rejected.toAbsolutePath().normalize();
|
||||
|
||||
if (absInput.equals(absLib)) return "Input 和 Library 不能是同一目录";
|
||||
if (absInput.equals(absRej)) return "Input 和 Rejected 不能是同一目录";
|
||||
if (absLib.equals(absRej)) return "Library 和 Rejected 不能是同一目录";
|
||||
|
||||
// 检查嵌套关系
|
||||
if (absLib.startsWith(absInput)) return "Library 不能位于 Input 目录内";
|
||||
if (absRej.startsWith(absInput)) return "Rejected 不能位于 Input 目录内";
|
||||
if (absInput.startsWith(absLib)) return "Input 不能位于 Library 目录内";
|
||||
if (absInput.startsWith(absRej)) return "Input 不能位于 Rejected 目录内";
|
||||
if (absLib.startsWith(absRej)) return "Library 不能位于 Rejected 目录内";
|
||||
if (absRej.startsWith(absLib)) return "Rejected 不能位于 Library 目录内";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== 核心处理 ==========
|
||||
|
||||
/**
|
||||
* 处理单个文件:读取元数据 → 校验 → 繁简转换 → 持久化标签 → MD5 →
|
||||
* 格式转换 → 去重 → 入库。同时处理关联 LRC 文件、嵌入式歌词和封面。
|
||||
*
|
||||
* @return 文件结果描述(用于报告)
|
||||
*/
|
||||
private String processSingleFile(Path srcFile, Path libraryPath, Path rejectedPath,
|
||||
Set<IdentityKey> libraryIdentities,
|
||||
Set<IdentityKey> batchIdentities,
|
||||
AtomicInteger ingested, AtomicInteger duplicates,
|
||||
AtomicInteger missingMeta, AtomicInteger unreadable,
|
||||
AtomicInteger convFailed, AtomicInteger otherRejected) throws Exception {
|
||||
|
||||
String fileName = srcFile.getFileName().toString();
|
||||
String baseName = getBaseName(fileName);
|
||||
|
||||
// 1. 读取元数据
|
||||
AudioFile audioFile;
|
||||
Tag tag;
|
||||
try {
|
||||
audioFile = AudioFileIO.read(srcFile.toFile());
|
||||
tag = audioFile.getTag();
|
||||
if (tag == null) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("无法读取文件元数据: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
|
||||
// 2. 读取关键字段
|
||||
String title = trim(tag.getFirst(FieldKey.TITLE));
|
||||
String artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||
String album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||
|
||||
// 3. 校验 Title/Artist/Album 非空
|
||||
if (title.isEmpty() || artist.isEmpty() || album.isEmpty()) {
|
||||
missingMeta.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "MissingMetadata", fileName);
|
||||
return "rejected:missing-metadata";
|
||||
}
|
||||
|
||||
// 4. 繁简转换文本标签
|
||||
boolean tagsModified = false;
|
||||
for (FieldKey key : TEXT_FIELDS) {
|
||||
String value = trim(tag.getFirst(key));
|
||||
if (!value.isEmpty()) {
|
||||
String converted = traditionalFilterService.toSimplified(value);
|
||||
if (!converted.equals(value)) {
|
||||
tag.setField(key, converted);
|
||||
tagsModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对 albumArtist 也进行繁简转换
|
||||
String albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||
if (!albumArtist.isEmpty()) {
|
||||
String converted = traditionalFilterService.toSimplified(albumArtist);
|
||||
if (!converted.equals(albumArtist)) {
|
||||
tag.setField(FieldKey.ALBUM_ARTIST, converted);
|
||||
tagsModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 将简化后的标签写回源文件
|
||||
if (tagsModified) {
|
||||
try {
|
||||
audioFile.commit();
|
||||
} catch (Exception e) {
|
||||
otherRejected.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
log.warn("标签写入失败且无法恢复: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:tag-write-failed";
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 提取其他可选字段
|
||||
String yearStr = trim(tag.getFirst(FieldKey.YEAR));
|
||||
String trackRaw = trim(tag.getFirst(FieldKey.TRACK));
|
||||
String discRaw = trim(tag.getFirst(FieldKey.DISC_NO));
|
||||
|
||||
int trackNum = parseInt(trackRaw, 0);
|
||||
int discNum = parseInt(discRaw, 0);
|
||||
|
||||
// 7. 计算 MD5 用于去重兜底
|
||||
String fileMd5 = computeMd5(srcFile);
|
||||
|
||||
// 8. 构建归一化身份标识
|
||||
IdentityKey identity = new IdentityKey(
|
||||
traditionalFilterService.toSimplified(artist.trim().toLowerCase()),
|
||||
traditionalFilterService.toSimplified(album.trim().toLowerCase()),
|
||||
discNum, trackNum,
|
||||
traditionalFilterService.toSimplified(title.trim().toLowerCase()),
|
||||
fileMd5
|
||||
);
|
||||
|
||||
// 9. 去重检测
|
||||
String duplicateReason = checkDuplicate(identity, libraryIdentities, batchIdentities);
|
||||
if (duplicateReason != null) {
|
||||
duplicates.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Duplicate", fileName);
|
||||
return "rejected:duplicate";
|
||||
}
|
||||
|
||||
// 10. 格式转换(如需要)
|
||||
Path effectiveFile = srcFile;
|
||||
boolean needsConversion = isLosslessFormat(srcFile);
|
||||
if (needsConversion) {
|
||||
try {
|
||||
Path flacFile = convertToFlac(srcFile, srcFile.getParent());
|
||||
effectiveFile = flacFile;
|
||||
} catch (Exception e) {
|
||||
convFailed.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "ConversionFailed", fileName);
|
||||
log.warn("转码失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:conversion-failed";
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 确定最终格式与文件名
|
||||
String ext = needsConversion ? "flac" : getExtension(fileName);
|
||||
if (ext == null) ext = "flac";
|
||||
|
||||
// 12. 构建目标路径
|
||||
String effectiveArtist = !albumArtist.isEmpty()
|
||||
? traditionalFilterService.toSimplified(albumArtist.trim())
|
||||
: traditionalFilterService.toSimplified(artist.trim());
|
||||
String effectiveAlbum = traditionalFilterService.toSimplified(album.trim());
|
||||
String year = extractYear(yearStr);
|
||||
String trackStr = trackNum > 0 ? String.format("%02d", trackNum) : "01";
|
||||
|
||||
String albumDirName = year.isEmpty() ? effectiveAlbum : effectiveAlbum + " (" + year + ")";
|
||||
String artistDir = sanitizePathComponent(effectiveArtist);
|
||||
String albumDir = sanitizePathComponent(albumDirName);
|
||||
Path targetDir = libraryPath.resolve(artistDir).resolve(albumDir);
|
||||
Files.createDirectories(targetDir);
|
||||
|
||||
String safeTitle = sanitizePathComponent(title);
|
||||
String destFileName = trackStr + " - " + safeTitle + "." + ext;
|
||||
Path targetFile = resolveUniqueFile(targetDir, destFileName);
|
||||
|
||||
// 13. 移动/复制到目标位置
|
||||
try {
|
||||
FileTransferUtils.moveWithFallback(effectiveFile, targetFile);
|
||||
} catch (IOException e) {
|
||||
otherRejected.incrementAndGet();
|
||||
// 如果经过转换,删除临时 FLAC 并隔离源文件
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
deleteIfExists(effectiveFile);
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
} else {
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
}
|
||||
log.warn("移动文件到 Library 失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:move-failed";
|
||||
}
|
||||
|
||||
// 14. 如果进行了格式转换且成功,删除原文件
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
deleteIfExists(srcFile);
|
||||
}
|
||||
|
||||
// 15. 更新身份标识集合
|
||||
libraryIdentities.add(identity);
|
||||
batchIdentities.add(identity);
|
||||
ingested.incrementAndGet();
|
||||
|
||||
// 16. 处理关联 LRC 文件(可选)
|
||||
handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
|
||||
|
||||
// 17. 提取嵌入式歌词(可选)写入 LRC
|
||||
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
|
||||
|
||||
// 18. 提取封面(可选)
|
||||
extractCover(tag, targetDir);
|
||||
|
||||
return "ingested";
|
||||
}
|
||||
|
||||
// ========== 关联文件处理 ==========
|
||||
|
||||
/**
|
||||
* 将同名 .lrc 文件随音频一起移入目标目录
|
||||
*/
|
||||
private void handleAssociatedLrc(Path srcFile, Path targetDir, String baseName, String destFileName) {
|
||||
Path lrcSource = srcFile.resolveSibling(baseName + ".lrc");
|
||||
if (!Files.exists(lrcSource)) {
|
||||
// 也检查小写扩展名
|
||||
lrcSource = srcFile.resolveSibling(baseName + ".LRC");
|
||||
if (!Files.exists(lrcSource)) return;
|
||||
}
|
||||
|
||||
try {
|
||||
String lrcDestName = getBaseName(destFileName) + ".lrc";
|
||||
Path lrcTarget = resolveUniqueFile(targetDir, lrcDestName);
|
||||
FileTransferUtils.moveWithFallback(lrcSource, lrcTarget);
|
||||
log.info("已移动关联 LRC 文件: {} -> {}", lrcSource.getFileName(), lrcTarget);
|
||||
} catch (IOException e) {
|
||||
log.warn("移动 LRC 文件失败: {} - {}", lrcSource, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标签中提取嵌入式歌词(可选),写入 LRC 文件
|
||||
*/
|
||||
private void extractEmbeddedLyrics(Tag tag, Path targetDir, String trackStr,
|
||||
String safeTitle, String originalTitle, String artist) {
|
||||
try {
|
||||
String lyrics = tag.getFirst(FieldKey.LYRICS);
|
||||
if (lyrics == null || lyrics.trim().isEmpty()) return;
|
||||
|
||||
// 构建简易 LRC 内容(无时间戳,仅作为歌词文本)
|
||||
StringBuilder lrcContent = new StringBuilder();
|
||||
lrcContent.append("[ti:").append(originalTitle).append("]\n");
|
||||
lrcContent.append("[ar:").append(artist).append("]\n");
|
||||
lrcContent.append("\n");
|
||||
lrcContent.append(lyrics.trim()).append("\n");
|
||||
|
||||
String lrcFileName = trackStr + " - " + safeTitle + ".lrc";
|
||||
Path lrcFile = resolveUniqueFile(targetDir, lrcFileName);
|
||||
Files.write(lrcFile, lrcContent.toString().getBytes(StandardCharsets.UTF_8));
|
||||
log.info("已提取嵌入式歌词到: {}", lrcFile);
|
||||
} catch (Exception e) {
|
||||
log.debug("提取嵌入式歌词失败(可选,忽略): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标签中提取封面(可选),写入 cover.jpg
|
||||
*/
|
||||
private void extractCover(Tag tag, Path targetDir) {
|
||||
try {
|
||||
List<Artwork> artworks = tag.getArtworkList();
|
||||
if (artworks == null || artworks.isEmpty()) return;
|
||||
|
||||
Artwork artwork = artworks.get(0);
|
||||
byte[] data = artwork.getBinaryData();
|
||||
if (data == null || data.length == 0) return;
|
||||
|
||||
String mime = artwork.getMimeType();
|
||||
String coverExt = "jpg";
|
||||
if (mime != null) {
|
||||
if (mime.contains("png")) coverExt = "png";
|
||||
else if (mime.contains("gif")) coverExt = "gif";
|
||||
}
|
||||
|
||||
Path coverFile = resolveUniqueFile(targetDir, "cover." + coverExt);
|
||||
Files.write(coverFile, data);
|
||||
log.info("已提取封面到: {}", coverFile);
|
||||
} catch (Exception e) {
|
||||
log.debug("提取封面失败(可选,忽略): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 结构化报告 ==========
|
||||
|
||||
/**
|
||||
* 写入 JSON 格式的结构化报告
|
||||
*/
|
||||
private void writeReport(String taskId, LinkedHashMap<String, String> fileOutcomes,
|
||||
Path rejectedPath, int ingestedCount, int duplicateCount,
|
||||
int missingMetaCount, int unreadableCount,
|
||||
int convFailedCount, int otherRejectedCount) {
|
||||
try {
|
||||
Path reportsDir = rejectedPath.resolve(REPORT_SUBDIR);
|
||||
Files.createDirectories(reportsDir);
|
||||
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
|
||||
String reportFileName = "ingest-report-" + timestamp + ".json";
|
||||
Path reportFile = resolveUniqueFile(reportsDir, reportFileName);
|
||||
|
||||
StringBuilder json = new StringBuilder();
|
||||
json.append("{\n");
|
||||
json.append(" \"taskId\": \"").append(escapeJson(taskId)).append("\",\n");
|
||||
json.append(" \"timestamp\": \"").append(timestamp).append("\",\n");
|
||||
json.append(" \"summary\": {\n");
|
||||
json.append(" \"total\": ").append(fileOutcomes.size()).append(",\n");
|
||||
json.append(" \"ingested\": ").append(ingestedCount).append(",\n");
|
||||
json.append(" \"duplicates\": ").append(duplicateCount).append(",\n");
|
||||
json.append(" \"missingMetadata\": ").append(missingMetaCount).append(",\n");
|
||||
json.append(" \"unreadable\": ").append(unreadableCount).append(",\n");
|
||||
json.append(" \"conversionFailed\": ").append(convFailedCount).append(",\n");
|
||||
json.append(" \"otherRejected\": ").append(otherRejectedCount).append("\n");
|
||||
json.append(" },\n");
|
||||
json.append(" \"files\": [\n");
|
||||
|
||||
int idx = 0;
|
||||
int size = fileOutcomes.size();
|
||||
for (Map.Entry<String, String> entry : fileOutcomes.entrySet()) {
|
||||
json.append(" {\"file\": \"").append(escapeJson(entry.getKey()));
|
||||
json.append("\", \"outcome\": \"").append(escapeJson(entry.getValue())).append("\"}");
|
||||
if (++idx < size) json.append(",");
|
||||
json.append("\n");
|
||||
}
|
||||
|
||||
json.append(" ]\n");
|
||||
json.append("}\n");
|
||||
|
||||
Files.write(reportFile, json.toString().getBytes(StandardCharsets.UTF_8));
|
||||
log.info("已完成结构化报告: {}", reportFile);
|
||||
} catch (Exception e) {
|
||||
log.warn("写入报告失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String escapeJson(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
|
||||
// ========== 去重检测 ==========
|
||||
|
||||
/**
|
||||
* 检查文件是否与已有集合中的任何条目重复。
|
||||
*
|
||||
* @return 重复原因(null 表示不重复)
|
||||
*/
|
||||
String checkDuplicate(IdentityKey identity,
|
||||
Set<IdentityKey> libraryIdentities,
|
||||
Set<IdentityKey> batchIdentities) {
|
||||
// 先检查精确的 metadata 匹配(Artist|Album|Disc|Track|Title)
|
||||
if (libraryIdentities.contains(identity) || batchIdentities.contains(identity)) {
|
||||
return "metadata";
|
||||
}
|
||||
|
||||
// MD5 兜底:检查是否有相同 MD5 的文件(即使元数据不同)
|
||||
if (identity.md5 != null && !identity.md5.isEmpty()) {
|
||||
for (IdentityKey existing : libraryIdentities) {
|
||||
if (identity.md5.equals(existing.md5)) {
|
||||
return "md5";
|
||||
}
|
||||
}
|
||||
for (IdentityKey existing : batchIdentities) {
|
||||
if (identity.md5.equals(existing.md5)) {
|
||||
return "md5";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== 工具方法 ==========
|
||||
|
||||
/**
|
||||
* 计算文件的 MD5 哈希
|
||||
*/
|
||||
static String computeMd5(Path file) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
try (InputStream is = Files.newInputStream(file);
|
||||
DigestInputStream dis = new DigestInputStream(is, md)) {
|
||||
byte[] buf = new byte[8192];
|
||||
while (dis.read(buf) != -1) {
|
||||
// 读取即可,DigestInputStream 自动更新摘要
|
||||
}
|
||||
}
|
||||
byte[] digest = md.digest();
|
||||
StringBuilder sb = new StringBuilder(32);
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b & 0xff));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException | IOException e) {
|
||||
log.warn("计算 MD5 失败: {} - {}", file, e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将无损格式文件转换为 FLAC。
|
||||
* 转换失败时清理残留输出文件。
|
||||
*/
|
||||
private Path convertToFlac(Path input, Path outputDir) throws IOException, InterruptedException {
|
||||
String baseName = getBaseName(input.getFileName().toString());
|
||||
Path output = outputDir.resolve(baseName + ".flac");
|
||||
output = resolveUniqueFile(outputDir, baseName + ".flac");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
getFfmpegCommand(),
|
||||
"-y",
|
||||
"-i", input.toAbsolutePath().toString(),
|
||||
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
|
||||
output.toAbsolutePath().toString()
|
||||
);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
boolean finished = p.waitFor(FFMPEG_CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
p.destroyForcibly();
|
||||
deleteIfExists(output);
|
||||
throw new RuntimeException("ffmpeg 转码超时(" + FFMPEG_CONVERT_TIMEOUT_SECONDS + "s)");
|
||||
}
|
||||
int exit = p.exitValue();
|
||||
if (exit != 0) {
|
||||
deleteIfExists(output);
|
||||
throw new RuntimeException("ffmpeg 退出码: " + exit);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private void deleteIfExists(Path path) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("清理临时文件失败: {} - {}", path, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 Library 目录中已有的音频文件,构建身份标识集合
|
||||
* (所有文本字段经过 t2s + lowercase 归一化,与 incoming 文件一致)
|
||||
*/
|
||||
private Set<IdentityKey> scanLibraryIdentities(Path libraryPath) {
|
||||
Set<IdentityKey> identities = new HashSet<>();
|
||||
if (!Files.exists(libraryPath)) return identities;
|
||||
|
||||
try {
|
||||
Files.walkFileTree(libraryPath, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
if (!isAudioFile(file)) return FileVisitResult.CONTINUE;
|
||||
try {
|
||||
AudioFile af = AudioFileIO.read(file.toFile());
|
||||
Tag tag = af.getTag();
|
||||
if (tag == null) return FileVisitResult.CONTINUE;
|
||||
|
||||
String title = trim(tag.getFirst(FieldKey.TITLE));
|
||||
String artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||
String album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||
if (title.isEmpty() || artist.isEmpty() || album.isEmpty()) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
int trackNum = parseInt(trim(tag.getFirst(FieldKey.TRACK)), 0);
|
||||
int discNum = parseInt(trim(tag.getFirst(FieldKey.DISC_NO)), 0);
|
||||
|
||||
String md5 = computeMd5(file);
|
||||
identities.add(new IdentityKey(
|
||||
traditionalFilterService.toSimplified(artist.toLowerCase()),
|
||||
traditionalFilterService.toSimplified(album.toLowerCase()),
|
||||
discNum, trackNum,
|
||||
traditionalFilterService.toSimplified(title.toLowerCase()),
|
||||
md5
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.debug("读取 Library 文件元数据失败: {} - {}", file, e.getMessage());
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("扫描 Library 目录失败", e);
|
||||
}
|
||||
return identities;
|
||||
}
|
||||
|
||||
// ========== 小工具 ==========
|
||||
|
||||
private boolean isAudioFile(Path file) {
|
||||
String ext = getExtension(file.getFileName().toString());
|
||||
return ext != null && ALL_AUDIO_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
private boolean isLosslessFormat(Path file) {
|
||||
String ext = getExtension(file.getFileName().toString());
|
||||
return ext != null && LOSSLESS_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
private String getExtension(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) return null;
|
||||
int i = fileName.lastIndexOf('.');
|
||||
if (i <= 0 || 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 String trim(String s) {
|
||||
return s == null ? "" : s.trim();
|
||||
}
|
||||
|
||||
private int parseInt(String s, int defaultValue) {
|
||||
if (s == null || s.trim().isEmpty()) return defaultValue;
|
||||
try {
|
||||
StringBuilder digits = new StringBuilder();
|
||||
for (char c : s.trim().toCharArray()) {
|
||||
if (Character.isDigit(c)) {
|
||||
digits.append(c);
|
||||
} else if (digits.length() > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (digits.length() == 0) return defaultValue;
|
||||
return Integer.parseInt(digits.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractYear(String dateStr) {
|
||||
if (dateStr == null || dateStr.isEmpty()) return "";
|
||||
for (int i = 0; i < dateStr.length(); i++) {
|
||||
if (Character.isDigit(dateStr.charAt(i))) {
|
||||
int end = Math.min(i + 4, dateStr.length());
|
||||
String y = dateStr.substring(i, end);
|
||||
if (y.length() == 4) return y;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void moveToRejected(Path file, Path rejectedRoot, String reason, String fileName) {
|
||||
try {
|
||||
Path reasonDir = rejectedRoot.resolve(reason);
|
||||
Files.createDirectories(reasonDir);
|
||||
Path target = resolveUniqueFile(reasonDir, fileName);
|
||||
FileTransferUtils.moveWithFallback(file, target);
|
||||
// 将关联的 .lrc 侧车文件一同移入
|
||||
moveSidecarLrc(file, reasonDir);
|
||||
} catch (IOException e) {
|
||||
log.warn("移动文件到 Rejected 失败: {} - {}", file, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将音频文件同名的 .lrc 侧车文件移入目标目录(碰撞安全)
|
||||
*/
|
||||
private void moveSidecarLrc(Path audioFile, Path targetDir) {
|
||||
String baseName = getBaseName(audioFile.getFileName().toString());
|
||||
for (String ext : new String[]{".lrc", ".LRC"}) {
|
||||
Path lrcSource = audioFile.resolveSibling(baseName + ext);
|
||||
if (Files.exists(lrcSource)) {
|
||||
try {
|
||||
Path lrcTarget = resolveUniqueFile(targetDir, baseName + ".lrc");
|
||||
FileTransferUtils.moveWithFallback(lrcSource, lrcTarget);
|
||||
log.info("已移动关联 LRC 侧车文件: {} -> {}", lrcSource.getFileName(), lrcTarget);
|
||||
} catch (IOException e) {
|
||||
log.warn("移动关联 LRC 侧车文件失败: {} - {}", lrcSource, e.getMessage());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveUniqueFile(Path dir, String fileName) throws IOException {
|
||||
Path target = dir.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)) {
|
||||
target = dir.resolve(base + " (" + n + ")" + ext);
|
||||
n++;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private String sanitizePathComponent(String s) {
|
||||
if (s == null || s.isEmpty()) return "_";
|
||||
String cleaned = s.replaceAll("[\\\\/:*?\"<>|]", "_")
|
||||
.replaceAll("\\s+", " ").trim();
|
||||
return cleaned.isEmpty() ? "_" : cleaned;
|
||||
}
|
||||
|
||||
private String getFfmpegCommand() {
|
||||
String configured = System.getProperty(FFMPEG_BIN_PROPERTY);
|
||||
if (configured == null || configured.trim().isEmpty()) {
|
||||
return "ffmpeg";
|
||||
}
|
||||
return configured.trim();
|
||||
}
|
||||
|
||||
private String checkFfmpegAvailable() {
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(getFfmpegCommand(), "-version");
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
boolean finished = p.waitFor(FFMPEG_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
p.destroyForcibly();
|
||||
return "ffmpeg 预检查超时,请检查环境配置";
|
||||
}
|
||||
if (p.exitValue() != 0) {
|
||||
return "ffmpeg 不可用,请确认已正确安装并加入 PATH";
|
||||
}
|
||||
return null;
|
||||
} catch (IOException e) {
|
||||
return "ffmpeg 不可用,请确认已正确安装并加入 PATH";
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return "ffmpeg 预检查被中断";
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 进度消息 ==========
|
||||
|
||||
private void sendProgress(String taskId, int total, int processed,
|
||||
int ingestedFiles, int duplicateFiles,
|
||||
int missingMetadataFiles, int unreadableFiles,
|
||||
int conversionFailedFiles, int otherRejectedFiles,
|
||||
String currentFile, String message, boolean completed) {
|
||||
try {
|
||||
ProgressMessage pm = new ProgressMessage();
|
||||
pm.setTaskId(taskId);
|
||||
pm.setType("ingest");
|
||||
pm.setTotal(total);
|
||||
pm.setProcessed(processed);
|
||||
pm.setSuccess(ingestedFiles);
|
||||
pm.setFailed(otherRejectedFiles);
|
||||
pm.setIngestedFiles(ingestedFiles);
|
||||
pm.setDuplicateFiles(duplicateFiles);
|
||||
pm.setMissingMetadataFiles(missingMetadataFiles);
|
||||
pm.setUnreadableFiles(unreadableFiles);
|
||||
pm.setConversionFailedFiles(conversionFailedFiles);
|
||||
pm.setOtherRejectedFiles(otherRejectedFiles);
|
||||
pm.setCurrentFile(currentFile);
|
||||
pm.setMessage(message);
|
||||
pm.setCompleted(completed);
|
||||
progressStore.put(pm);
|
||||
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
|
||||
log.debug("发送 ingest 进度: taskId={}, total={}, processed={}, ingested={}",
|
||||
taskId, total, processed, ingestedFiles);
|
||||
} catch (Exception e) {
|
||||
log.error("发送进度消息失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 内部类 ==========
|
||||
|
||||
/**
|
||||
* 文件身份标识,用于去重比较。
|
||||
* 包括元数据身份(Artist|Album|Disc|Track|Title)和 MD5 哈希兜底。
|
||||
*/
|
||||
static class IdentityKey {
|
||||
private final String artist;
|
||||
private final String album;
|
||||
private final int disc;
|
||||
private final int track;
|
||||
private final String title;
|
||||
private final String md5;
|
||||
|
||||
IdentityKey(String artist, String album, int disc, int track, String title) {
|
||||
this(artist, album, disc, track, title, "");
|
||||
}
|
||||
|
||||
IdentityKey(String artist, String album, int disc, int track, String title, String md5) {
|
||||
this.artist = artist;
|
||||
this.album = album;
|
||||
this.disc = disc;
|
||||
this.track = track;
|
||||
this.title = title;
|
||||
this.md5 = md5 == null ? "" : md5;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof IdentityKey)) return false;
|
||||
IdentityKey that = (IdentityKey) o;
|
||||
return disc == that.disc && track == that.track &&
|
||||
Objects.equals(artist, that.artist) &&
|
||||
Objects.equals(album, that.album) &&
|
||||
Objects.equals(title, that.title);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(artist, album, disc, track, title);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return artist + "|" + album + "|" + disc + "|" + track + "|" + title;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user