Add one-click Navidrome ingestion workflow

This commit is contained in:
2026-07-19 02:23:41 +08:00
parent 81977a157e
commit ff59f341cf
19 changed files with 2340 additions and 46 deletions
@@ -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;
}
}
}