Template
1522 lines
67 KiB
Java
1522 lines
67 KiB
Java
package com.music.service;
|
||
|
||
import com.music.common.FileTransferUtils;
|
||
import com.music.dto.ProgressMessage;
|
||
import com.music.service.AudioValidationService.ValidationResult;
|
||
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.beans.factory.annotation.Autowired;
|
||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||
import com.music.config.AsyncConfig;
|
||
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;
|
||
/** FFmpeg 合并输出保留的诊断字节上限(超出继续读取以排空管道) */
|
||
private static final int FFMPEG_DIAGNOSTIC_CAP = 4096;
|
||
/** 强制终止后回收子进程 / 合并 drainer 的最长等待秒数 */
|
||
private static final int FFMPEG_CLEANUP_WAIT_SECONDS = 5;
|
||
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;
|
||
private final AudioValidationService audioValidationService;
|
||
|
||
/**
|
||
* Spring DI 构造器。
|
||
* <p>包级 4 参数构造器保留给测试,此时 {@code audioValidationService} 为 null,
|
||
* 跳过音频完整性验证步骤。</p>
|
||
*/
|
||
@Autowired
|
||
public IngestService(SimpMessagingTemplate messagingTemplate,
|
||
ProgressStore progressStore,
|
||
TraditionalFilterService traditionalFilterService,
|
||
ConfigService configService,
|
||
AudioValidationService audioValidationService) {
|
||
this.messagingTemplate = messagingTemplate;
|
||
this.progressStore = progressStore;
|
||
this.traditionalFilterService = traditionalFilterService;
|
||
this.configService = configService;
|
||
this.audioValidationService = audioValidationService;
|
||
}
|
||
|
||
/**
|
||
* 包级构造器(测试用,跳过音频完整性验证)。
|
||
*/
|
||
IngestService(SimpMessagingTemplate messagingTemplate,
|
||
ProgressStore progressStore,
|
||
TraditionalFilterService traditionalFilterService,
|
||
ConfigService configService) {
|
||
this(messagingTemplate, progressStore, traditionalFilterService, configService, null);
|
||
}
|
||
|
||
/**
|
||
* 异步执行一键导入任务。
|
||
* <p><b>并发锁</b>:{@code runningLock} 由控制器创建,本方法在 finally 块中释放,
|
||
* 保证无论正常完成、异常、预检失败都能解锁。
|
||
*
|
||
* @param taskId 唯一任务标识
|
||
* @param runningLock 由控制器传入的 AtomicBoolean,任务结束时置为 false
|
||
*/
|
||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||
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;
|
||
}
|
||
|
||
// 预检查 FFprobe 和 FFmpeg(每个新文件都需要完整解码验证)
|
||
if (!audioFiles.isEmpty()) {
|
||
String ffprobeError = checkFfprobeAvailable();
|
||
if (ffprobeError != null) {
|
||
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||
null, ffprobeError, true);
|
||
return;
|
||
}
|
||
String ffmpegError = checkFfmpegAvailable();
|
||
if (ffmpegError != null) {
|
||
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||
null, ffmpegError, true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 历史 Library 清理由 scripts/cleanup-library.sh 显式执行一次。
|
||
// 正常入库不执行破坏性全量扫描,避免每批导入反复删除历史数据。
|
||
int cleanupRemoved = 0;
|
||
|
||
// 扫描 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 missingCover = 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, missingCover);
|
||
} 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(),
|
||
missingCover.get(), cleanupRemoved);
|
||
|
||
// 完成
|
||
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, 其他: %d, 清理: %d",
|
||
ingested.get(), duplicates.get(), missingMeta.get(), missingCover.get(),
|
||
unreadable.get(), convFailed.get(), otherRejected.get(), cleanupRemoved),
|
||
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,
|
||
AtomicInteger missingCover) throws Exception {
|
||
|
||
String fileName = srcFile.getFileName().toString();
|
||
String baseName = getBaseName(fileName);
|
||
|
||
// 1. 读取元数据:优先 jaudiotagger;受支持的 M4A/MP4 无法解析时回退 FFprobe 结构化输出
|
||
AudioFile audioFile = null;
|
||
Tag tag = null;
|
||
boolean usedFallback = false;
|
||
|
||
String title;
|
||
String artist;
|
||
String album;
|
||
String albumArtist;
|
||
String yearStr;
|
||
String trackRaw;
|
||
String discRaw;
|
||
|
||
boolean jaudioOk = false;
|
||
String jaudioError = null;
|
||
try {
|
||
audioFile = AudioFileIO.read(srcFile.toFile());
|
||
tag = audioFile.getTag();
|
||
jaudioOk = (tag != null);
|
||
if (!jaudioOk) {
|
||
jaudioError = "标签为空";
|
||
}
|
||
} catch (Exception e) {
|
||
jaudioError = e.getMessage();
|
||
}
|
||
|
||
if (jaudioOk) {
|
||
title = trim(tag.getFirst(FieldKey.TITLE));
|
||
artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||
album = trim(tag.getFirst(FieldKey.ALBUM));
|
||
albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST));
|
||
yearStr = safeGetFirst(tag, FieldKey.YEAR);
|
||
trackRaw = safeGetFirst(tag, FieldKey.TRACK);
|
||
discRaw = safeGetFirst(tag, FieldKey.DISC_NO);
|
||
} else {
|
||
// jaudiotagger 无法解析:仅对受支持的 M4A/MP4 且启用了 FFprobe 校验服务时回退
|
||
if (audioValidationService == null || !isFallbackEligible(srcFile)) {
|
||
unreadable.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||
log.warn("无法读取文件元数据: {} - {}", fileName, jaudioError);
|
||
return "rejected:unreadable";
|
||
}
|
||
AudioValidationService.ProbeMetadata meta = audioValidationService.readMetadata(srcFile);
|
||
if (!meta.isAvailable()) {
|
||
unreadable.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||
log.warn("FFprobe 后备元数据读取失败: {} - {}", fileName, meta.getDiagnostic());
|
||
return "rejected:unreadable";
|
||
}
|
||
usedFallback = true;
|
||
title = meta.getFirst("title");
|
||
artist = meta.getFirst("artist");
|
||
album = meta.getFirst("album");
|
||
albumArtist = meta.getFirst("album_artist", "albumartist");
|
||
yearStr = meta.getFirst("date", "year");
|
||
trackRaw = meta.getFirst("track");
|
||
discRaw = meta.getFirst("disc", "discnumber");
|
||
log.info("jaudiotagger 无法解析,改用 FFprobe 后备读取元数据: {}", fileName);
|
||
}
|
||
|
||
// 2. 校验 Title/Artist/Album 非空(任一为空 → MissingMetadata)
|
||
if (title.isEmpty() || artist.isEmpty() || album.isEmpty()) {
|
||
missingMeta.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "MissingMetadata", fileName);
|
||
return "rejected:missing-metadata";
|
||
}
|
||
|
||
// 3. 繁简转换文本标签并写回源文件(仅 jaudiotagger 路径;
|
||
// FFprobe 后备路径稍后用 FFmpeg 无损 remux 写入简体标签)
|
||
// 若 commit 失败但已读到完整元数据,则切换到 FFmpeg remux 恢复路径(对受支持容器),
|
||
// 避免把合法音频误判为 Other。
|
||
boolean commitFailedNeedsRemux = false;
|
||
if (jaudioOk) {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if (tagsModified) {
|
||
try {
|
||
audioFile.commit();
|
||
} catch (Exception e) {
|
||
// 已读到完整元数据;对可 remux 的容器改走 FFmpeg 无损恢复路径。
|
||
if (audioValidationService != null && isFallbackEligible(srcFile)) {
|
||
commitFailedNeedsRemux = true;
|
||
tag = null; // 不再依赖 jaudiotagger 标签对象,改用 remux 后 FFprobe 重读
|
||
log.info("标签 commit 失败,改用 FFmpeg remux 恢复: {} - {}", fileName, e.getMessage());
|
||
} else {
|
||
otherRejected.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||
log.warn("标签写入失败且无法恢复: {} - {}", fileName, e.getMessage());
|
||
return "rejected:tag-write-failed";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
int trackNum = parseInt(trackRaw, 0);
|
||
int discNum = parseInt(discRaw, 0);
|
||
|
||
// 4. 计算 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";
|
||
}
|
||
|
||
// 5. 格式转换 / 后备 remux(如需要)
|
||
Path effectiveFile = srcFile;
|
||
boolean needsConversion = isLosslessFormat(srcFile);
|
||
String remuxExt = null; // 后备 remux 时的输出容器扩展名(决定入库文件扩展名)
|
||
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";
|
||
}
|
||
} else if (usedFallback || commitFailedNeedsRemux) {
|
||
// jaudiotagger 无法解析或无法写回此容器的标签:用 FFmpeg 无损 remux(-c copy)
|
||
// 生成保留音频质量与原有元数据的 Navidrome 兼容输出,并写入简体 Title/Artist/Album。
|
||
// 依据探测到的音频编码选择兼容容器,避免把 AAC 等误封进不兼容容器。
|
||
String simpTitle = traditionalFilterService.toSimplified(title);
|
||
String simpArtist = traditionalFilterService.toSimplified(artist);
|
||
String simpAlbum = traditionalFilterService.toSimplified(album);
|
||
String simpAlbumArtist = albumArtist.isEmpty()
|
||
? "" : traditionalFilterService.toSimplified(albumArtist);
|
||
remuxExt = remuxContainerExt(srcFile);
|
||
try {
|
||
Path remuxed = remuxWithSimplifiedTags(srcFile, simpTitle, simpArtist,
|
||
simpAlbum, simpAlbumArtist, remuxExt);
|
||
effectiveFile = remuxed;
|
||
usedFallback = true; // 后续按 FFprobe 重读元数据 + 从源提取封面处理
|
||
} catch (Exception e) {
|
||
convFailed.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "ConversionFailed", fileName);
|
||
log.warn("后备 remux 失败: {} - {}", fileName, e.getMessage());
|
||
return "rejected:conversion-failed";
|
||
}
|
||
}
|
||
|
||
boolean derivedFile = !effectiveFile.equals(srcFile);
|
||
|
||
// 6. 音频完整性验证:确保有效文件可被 FFprobe 识别且 FFmpeg 可完整解码
|
||
if (audioValidationService != null) {
|
||
ValidationResult vr = audioValidationService.validate(effectiveFile);
|
||
if (!vr.isValid()) {
|
||
if (derivedFile) {
|
||
deleteIfExists(effectiveFile);
|
||
}
|
||
unreadable.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||
log.warn("音频完整性验证失败: {} - {}", fileName, vr.getDiagnostic());
|
||
return "rejected:unreadable";
|
||
}
|
||
}
|
||
|
||
// 7. 重新检查有效文件的 Title/Artist/Album(确保转换/remux/标签持久化未丢失必需元数据)
|
||
if (audioValidationService != null) {
|
||
if (usedFallback) {
|
||
// 后备路径:jaudiotagger 仍无法解析该容器,改用 FFprobe 再次读取校验
|
||
AudioValidationService.ProbeMetadata reMeta =
|
||
audioValidationService.readMetadata(effectiveFile);
|
||
String vTitle = reMeta.isAvailable() ? reMeta.getFirst("title") : "";
|
||
String vArtist = reMeta.isAvailable() ? reMeta.getFirst("artist") : "";
|
||
String vAlbum = reMeta.isAvailable() ? reMeta.getFirst("album") : "";
|
||
if (vTitle.isEmpty() || vArtist.isEmpty() || vAlbum.isEmpty()) {
|
||
if (derivedFile) {
|
||
deleteIfExists(effectiveFile);
|
||
}
|
||
unreadable.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||
log.warn("后备 remux 后元数据缺失: {}(Title='{}' Artist='{}' Album='{}')",
|
||
fileName, vTitle, vArtist, vAlbum);
|
||
return "rejected:unreadable";
|
||
}
|
||
title = vTitle;
|
||
artist = vArtist;
|
||
album = vAlbum;
|
||
albumArtist = reMeta.getFirst("album_artist", "albumartist");
|
||
yearStr = reMeta.getFirst("date", "year");
|
||
trackRaw = reMeta.getFirst("track");
|
||
discRaw = reMeta.getFirst("disc", "discnumber");
|
||
trackNum = parseInt(trackRaw, 0);
|
||
discNum = parseInt(discRaw, 0);
|
||
} else {
|
||
try {
|
||
AudioFile validatedAudio = AudioFileIO.read(effectiveFile.toFile());
|
||
Tag validatedTag = validatedAudio.getTag();
|
||
String vTitle = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.TITLE) : "");
|
||
String vArtist = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.ARTIST) : "");
|
||
String vAlbum = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.ALBUM) : "");
|
||
if (vTitle.isEmpty() || vArtist.isEmpty() || vAlbum.isEmpty()) {
|
||
if (derivedFile) {
|
||
deleteIfExists(effectiveFile);
|
||
}
|
||
unreadable.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||
log.warn("完整性验证后元数据缺失: {}(Title='{}' Artist='{}' Album='{}')",
|
||
fileName, vTitle, vArtist, vAlbum);
|
||
return "rejected:unreadable";
|
||
}
|
||
// 使用有效文件的标签更新后续处理引用
|
||
tag = validatedTag;
|
||
title = vTitle;
|
||
artist = vArtist;
|
||
album = vAlbum;
|
||
albumArtist = trim(validatedTag.getFirst(FieldKey.ALBUM_ARTIST));
|
||
yearStr = safeGetFirst(validatedTag, FieldKey.YEAR);
|
||
trackRaw = safeGetFirst(validatedTag, FieldKey.TRACK);
|
||
discRaw = safeGetFirst(validatedTag, FieldKey.DISC_NO);
|
||
trackNum = parseInt(trackRaw, 0);
|
||
discNum = parseInt(discRaw, 0);
|
||
} catch (Exception e) {
|
||
if (derivedFile) {
|
||
deleteIfExists(effectiveFile);
|
||
}
|
||
unreadable.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||
log.warn("完整性验证后重新读取元数据失败: {} - {}", fileName, e.getMessage());
|
||
return "rejected:unreadable";
|
||
}
|
||
}
|
||
}
|
||
|
||
// 确定最终格式与文件名(后备 remux 使用编码兼容的容器扩展名)
|
||
String ext;
|
||
if (needsConversion) {
|
||
ext = "flac";
|
||
} else if (remuxExt != null) {
|
||
ext = remuxExt;
|
||
} else {
|
||
ext = 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(traditionalFilterService.toSimplified(title));
|
||
String destFileName = trackStr + " - " + safeTitle + "." + ext;
|
||
Path targetFile = resolveUniqueFile(targetDir, destFileName);
|
||
|
||
// 封面是入库硬性不变量:album 目录已有 cover.jpg/png,或能从源媒体提取内嵌封面,
|
||
// 二者皆无则拒绝为 MissingCover。必须在源文件删除前从源提取
|
||
// (后备 remux 使用 -map 0:a:0 会丢弃 attached picture,故须从原始 srcFile 提取)。
|
||
boolean albumAlreadyCovered = hasExistingCover(targetDir);
|
||
Path writtenCover = null;
|
||
if (!albumAlreadyCovered) {
|
||
writtenCover = acquireCover(srcFile, tag, targetDir);
|
||
if (writtenCover == null) {
|
||
// 无既有封面且无法提取内嵌封面 → MissingCover;清理派生临时文件,隔离源文件
|
||
if (derivedFile) {
|
||
deleteIfExists(effectiveFile);
|
||
}
|
||
missingCover.incrementAndGet();
|
||
moveToRejected(srcFile, rejectedPath, "MissingCover", fileName);
|
||
log.info("缺少封面(无既有 cover 且无内嵌封面): {}", fileName);
|
||
return "rejected:missing-cover";
|
||
}
|
||
}
|
||
|
||
// 移动/复制到目标位置
|
||
try {
|
||
FileTransferUtils.moveWithFallback(effectiveFile, targetFile);
|
||
} catch (IOException e) {
|
||
otherRejected.incrementAndGet();
|
||
// 如果经过转换/remux,删除派生文件并隔离源文件
|
||
if (derivedFile) {
|
||
deleteIfExists(effectiveFile);
|
||
}
|
||
// 本曲目刚写入的封面(原本无既有封面)也需清理,避免遗留孤立 cover
|
||
if (writtenCover != null) {
|
||
deleteIfExists(writtenCover);
|
||
}
|
||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||
log.warn("移动文件到 Library 失败: {} - {}", fileName, e.getMessage());
|
||
return "rejected:move-failed";
|
||
}
|
||
|
||
// 如果生成了派生文件(转换或后备 remux)且成功,删除原文件
|
||
if (derivedFile) {
|
||
deleteIfExists(srcFile);
|
||
}
|
||
|
||
// 更新身份标识集合
|
||
libraryIdentities.add(identity);
|
||
batchIdentities.add(identity);
|
||
ingested.incrementAndGet();
|
||
|
||
// 处理关联 LRC 文件(可选)
|
||
handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
|
||
|
||
// 提取嵌入式歌词(可选,仅 jaudiotagger 路径有 tag 对象)
|
||
if (tag != null) {
|
||
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
|
||
}
|
||
|
||
return "ingested";
|
||
}
|
||
|
||
// ========== 封面处理 ==========
|
||
|
||
/**
|
||
* album 目录是否已存在有效封面文件(cover.jpg/png,大小写不敏感)。
|
||
*/
|
||
private boolean hasExistingCover(Path albumDir) {
|
||
return findExistingCover(albumDir) != null;
|
||
}
|
||
|
||
/**
|
||
* 返回 album 目录中已存在的封面文件(cover.jpg/jpeg/png,大小写不敏感),无则 null。
|
||
*/
|
||
private Path findExistingCover(Path albumDir) {
|
||
if (albumDir == null || !Files.isDirectory(albumDir)) return null;
|
||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(albumDir)) {
|
||
for (Path p : ds) {
|
||
if (!Files.isRegularFile(p)) continue;
|
||
String n = p.getFileName().toString().toLowerCase();
|
||
if (n.equals("cover.jpg") || n.equals("cover.jpeg") || n.equals("cover.png")) {
|
||
try {
|
||
if (Files.size(p) > 0) return p;
|
||
} catch (IOException ignored) {
|
||
}
|
||
}
|
||
}
|
||
} catch (IOException e) {
|
||
log.debug("扫描既有封面失败: {} - {}", albumDir, e.getMessage());
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 从源媒体提取内嵌封面写入 album 目录,返回写入的封面路径;无内嵌封面或提取失败返回 null。
|
||
* <p>优先使用 jaudiotagger 已解析的 artwork 字节(保留 JPEG/PNG 原样);不可用或为
|
||
* 不支持的图像格式时,改用 FFmpeg 从源的 attached-picture 流提取/规范化为 cover.jpg。
|
||
* 绝不联网或从文件名推断。</p>
|
||
*
|
||
* @param srcFile 原始源媒体(须在删除前调用,remux 输出不含封面)
|
||
* @param tag jaudiotagger 标签对象(可为 null)
|
||
* @param albumDir 目标 album 目录
|
||
*/
|
||
private Path acquireCover(Path srcFile, Tag tag, Path albumDir) {
|
||
// 1. jaudiotagger 已解析的内嵌封面字节(JPEG/PNG 直接保留)
|
||
if (tag != null) {
|
||
try {
|
||
List<Artwork> artworks = tag.getArtworkList();
|
||
if (artworks != null && !artworks.isEmpty()) {
|
||
Artwork artwork = artworks.get(0);
|
||
byte[] data = artwork.getBinaryData();
|
||
if (data != null && data.length > 0) {
|
||
String mime = artwork.getMimeType();
|
||
String coverExt = null;
|
||
if (mime != null) {
|
||
String m = mime.toLowerCase();
|
||
if (m.contains("png")) coverExt = "png";
|
||
else if (m.contains("jpeg") || m.contains("jpg")) coverExt = "jpg";
|
||
}
|
||
if (coverExt != null) {
|
||
Path coverFile = resolveUniqueFile(albumDir, "cover." + coverExt);
|
||
Files.write(coverFile, data);
|
||
log.info("已从标签提取封面到: {}", coverFile);
|
||
return coverFile;
|
||
}
|
||
// 不支持的图像格式:交由 FFmpeg 规范化(下面走 ffprobe/ffmpeg 路径)
|
||
}
|
||
}
|
||
} catch (Exception e) {
|
||
log.debug("jaudiotagger 封面提取失败,尝试 FFmpeg: {} - {}", srcFile, e.getMessage());
|
||
}
|
||
}
|
||
|
||
// 2. FFmpeg:探测源的 attached-picture 流并提取(保留 JPEG/PNG,其他规范化为 jpg)
|
||
if (audioValidationService != null) {
|
||
AudioValidationService.EmbeddedArtwork art =
|
||
audioValidationService.probeAttachedPicture(srcFile);
|
||
if (art.isPresent()) {
|
||
String coverExt = audioValidationService.coverExtensionFor(art);
|
||
try {
|
||
Path coverFile = resolveUniqueFile(albumDir, "cover." + coverExt);
|
||
if (audioValidationService.extractCoverTo(srcFile, art, coverFile)) {
|
||
log.info("已从源媒体提取内嵌封面到: {}", coverFile);
|
||
return coverFile;
|
||
}
|
||
deleteIfExists(coverFile);
|
||
} catch (IOException e) {
|
||
log.debug("FFmpeg 封面提取失败: {} - {}", srcFile, e.getMessage());
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ========== 关联文件处理 ==========
|
||
|
||
/**
|
||
* 将同名 .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());
|
||
}
|
||
}
|
||
|
||
// ========== 结构化报告 ==========
|
||
|
||
/**
|
||
* 写入 JSON 格式的结构化报告
|
||
*/
|
||
private void writeReport(String taskId, LinkedHashMap<String, String> fileOutcomes,
|
||
Path rejectedPath, int ingestedCount, int duplicateCount,
|
||
int missingMetaCount, int unreadableCount,
|
||
int convFailedCount, int otherRejectedCount,
|
||
int missingCoverCount, int cleanupRemovedCount) {
|
||
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(" \"missingCover\": ").append(missingCoverCount).append(",\n");
|
||
json.append(" \"libraryCleanupRemoved\": ").append(cleanupRemovedCount).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");
|
||
|
||
List<String> cmd = new ArrayList<>(Arrays.asList(
|
||
getFfmpegCommand(),
|
||
"-y",
|
||
"-i", input.toAbsolutePath().toString(),
|
||
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
|
||
output.toAbsolutePath().toString()
|
||
));
|
||
runFfmpegDrained(cmd, FFMPEG_CONVERT_TIMEOUT_SECONDS, output, "转码");
|
||
return output;
|
||
}
|
||
|
||
private void deleteIfExists(Path path) {
|
||
if (path == null) return;
|
||
try {
|
||
Files.deleteIfExists(path);
|
||
} catch (IOException e) {
|
||
log.warn("清理临时文件失败: {} - {}", path, e.getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判断文件是否适用 FFprobe 元数据后备/恢复路径。
|
||
* <p>凡是受支持的音频类型(已通过 {@link #isAudioFile} 扫描)且启用了校验服务,
|
||
* 均允许在 jaudiotagger 无法解析/写回时改用 FFprobe 元数据分类并 remux 恢复,
|
||
* 使支持的可读文件由 FFprobe 元数据决定归类,而非一律 Unreadable。</p>
|
||
*/
|
||
private boolean isFallbackEligible(Path file) {
|
||
return audioValidationService != null && isAudioFile(file);
|
||
}
|
||
|
||
/** 音频编码 → 后备 remux 输出容器扩展名(选择编码兼容的容器,避免不兼容封装) */
|
||
private static final Map<String, String> CODEC_TO_CONTAINER_EXT;
|
||
static {
|
||
Map<String, String> m = new HashMap<>();
|
||
m.put("aac", "m4a");
|
||
m.put("alac", "m4a");
|
||
m.put("mp3", "mp3");
|
||
m.put("flac", "flac");
|
||
m.put("vorbis", "ogg");
|
||
m.put("opus", "opus");
|
||
m.put("wmav1", "wma");
|
||
m.put("wmav2", "wma");
|
||
m.put("wmapro", "wma");
|
||
CODEC_TO_CONTAINER_EXT = m;
|
||
}
|
||
|
||
/**
|
||
* 依据探测到的音频编码选择后备 remux 的输出容器扩展名。
|
||
* 未知编码回退到 {@code m4a}(AAC 系最常见),保证仍产出可用的音乐资产。
|
||
*/
|
||
private String remuxContainerExt(Path srcFile) {
|
||
String codec = audioValidationService != null
|
||
? audioValidationService.probeAudioCodec(srcFile) : "";
|
||
String ext = CODEC_TO_CONTAINER_EXT.get(codec);
|
||
return ext != null ? ext : "m4a";
|
||
}
|
||
|
||
/**
|
||
* 使用 FFmpeg 无损 remux({@code -c copy})生成保留音频质量与原有元数据的
|
||
* Navidrome 兼容 M4A 输出,并写入简体 Title/Artist/Album(及可选 Album Artist)。
|
||
* <p>用于 jaudiotagger 无法安全写回该容器标签、且不能修改原文件的场景,
|
||
* 避免因写回失败而误拒绝合法音频。不伪造缺失的必要元数据。</p>
|
||
* <p>输出为独立临时文件;调用方负责在失败或验证不通过时清理。</p>
|
||
*
|
||
* @return 生成的 M4A 输出路径
|
||
*/
|
||
List<String> buildRemuxCommand(Path input, Path output, String title, String artist,
|
||
String album, String albumArtist) {
|
||
List<String> cmd = new ArrayList<>();
|
||
cmd.add(getFfmpegCommand());
|
||
cmd.add("-y");
|
||
cmd.add("-i");
|
||
cmd.add(input.toAbsolutePath().toString());
|
||
// 仅映射预期的第一个音频流,避免把普通视频/字幕/数据等流带入生成的输出,
|
||
// 确保输出是干净的 Navidrome 兼容音乐资产。
|
||
cmd.add("-map");
|
||
cmd.add("0:a:0");
|
||
cmd.add("-c");
|
||
cmd.add("copy");
|
||
// 保留原有 format 级元数据(不含未映射的流),随后覆盖简体标签
|
||
cmd.add("-map_metadata");
|
||
cmd.add("0");
|
||
// MP3 输出使用 ID3v2.3 以获得最广泛的兼容性
|
||
String outExt = getExtension(output.getFileName().toString());
|
||
if ("mp3".equals(outExt)) {
|
||
cmd.add("-id3v2_version");
|
||
cmd.add("3");
|
||
}
|
||
// 覆盖简体标签(不伪造缺失字段:调用方已确保三项必需字段非空)
|
||
cmd.add("-metadata");
|
||
cmd.add("title=" + title);
|
||
cmd.add("-metadata");
|
||
cmd.add("artist=" + artist);
|
||
cmd.add("-metadata");
|
||
cmd.add("album=" + album);
|
||
if (albumArtist != null && !albumArtist.isEmpty()) {
|
||
cmd.add("-metadata");
|
||
cmd.add("album_artist=" + albumArtist);
|
||
}
|
||
cmd.add(output.toAbsolutePath().toString());
|
||
return cmd;
|
||
}
|
||
|
||
private Path remuxWithSimplifiedTags(Path input, String title, String artist,
|
||
String album, String albumArtist, String containerExt)
|
||
throws IOException, InterruptedException {
|
||
String baseName = getBaseName(input.getFileName().toString());
|
||
Path output = resolveUniqueFile(input.getParent(), baseName + ".fallback." + containerExt);
|
||
|
||
List<String> cmd = buildRemuxCommand(input, output, title, artist, album, albumArtist);
|
||
runFfmpegDrained(cmd, FFMPEG_CONVERT_TIMEOUT_SECONDS, output, "remux");
|
||
return output;
|
||
}
|
||
|
||
/**
|
||
* 运行一个 FFmpeg 子进程,并发排空其合并后的标准输出,避免管道写满导致死锁;
|
||
* 超时或中断时强制终止并有界回收,失败时清理输出文件。
|
||
* <p>合并 stderr→stdout 后由守护线程持续读取(仅保留前 {@value #FFMPEG_DIAGNOSTIC_CAP}
|
||
* 字节作诊断),超出部分丢弃以排空管道。非零退出或超时抛出 {@link RuntimeException}。</p>
|
||
*
|
||
* @param command 完整命令行
|
||
* @param timeoutSeconds 超时秒数
|
||
* @param outputToClean 失败/超时时需要删除的输出文件(可为 null)
|
||
* @param label 日志/异常用的操作标签(如 "remux" / "转码")
|
||
*/
|
||
private void runFfmpegDrained(List<String> command, int timeoutSeconds,
|
||
Path outputToClean, String label)
|
||
throws IOException, InterruptedException {
|
||
ProcessBuilder pb = new ProcessBuilder(command);
|
||
pb.redirectErrorStream(true);
|
||
Process p = pb.start();
|
||
|
||
final StringBuilder diag = new StringBuilder();
|
||
Thread drainer = new Thread(() -> {
|
||
try (InputStream is = p.getInputStream()) {
|
||
byte[] buf = new byte[4096];
|
||
int n;
|
||
while ((n = is.read(buf)) != -1) {
|
||
synchronized (diag) {
|
||
int remaining = FFMPEG_DIAGNOSTIC_CAP - diag.length();
|
||
if (remaining > 0) {
|
||
diag.append(new String(buf, 0, Math.min(n, remaining),
|
||
StandardCharsets.UTF_8));
|
||
}
|
||
// 已达上限后继续读取以排空管道,丢弃多余数据
|
||
}
|
||
}
|
||
} catch (IOException ignored) {
|
||
// 进程终止后流关闭属正常情况
|
||
}
|
||
}, "ffmpeg-" + label + "-drainer");
|
||
drainer.setDaemon(true);
|
||
drainer.start();
|
||
|
||
boolean interrupted = false;
|
||
boolean finished;
|
||
try {
|
||
finished = p.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||
} catch (InterruptedException e) {
|
||
interrupted = true;
|
||
finished = false;
|
||
}
|
||
|
||
if (!finished) {
|
||
p.destroyForcibly();
|
||
try {
|
||
p.waitFor(FFMPEG_CLEANUP_WAIT_SECONDS, TimeUnit.SECONDS);
|
||
} catch (InterruptedException e) {
|
||
interrupted = true;
|
||
}
|
||
}
|
||
|
||
// 先 join drainer 确保输出读尽,再判定结果
|
||
try {
|
||
drainer.join(TimeUnit.SECONDS.toMillis(FFMPEG_CLEANUP_WAIT_SECONDS));
|
||
} catch (InterruptedException e) {
|
||
interrupted = true;
|
||
}
|
||
|
||
if (interrupted) {
|
||
deleteIfExists(outputToClean);
|
||
Thread.currentThread().interrupt();
|
||
throw new InterruptedException("ffmpeg " + label + " 被中断");
|
||
}
|
||
|
||
if (!finished) {
|
||
deleteIfExists(outputToClean);
|
||
throw new RuntimeException("ffmpeg " + label + " 超时(" + timeoutSeconds + "s)");
|
||
}
|
||
|
||
int exit = p.exitValue();
|
||
if (exit != 0) {
|
||
String tail;
|
||
synchronized (diag) {
|
||
tail = diag.toString().trim();
|
||
}
|
||
deleteIfExists(outputToClean);
|
||
throw new RuntimeException("ffmpeg " + label + " 退出码: " + exit
|
||
+ (tail.isEmpty() ? "" : " - " + tail));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 既有 Library 封面清理:扫描各 album 目录,删除既无 album 封面文件(cover.jpg/png)、
|
||
* 且自身也不含内嵌 attached-picture 封面的音频文件。
|
||
* <p>安全边界:仅删除 Library 内的音频文件;不删除封面、非音频侧车、任何 Rejected 文件;
|
||
* album 目录中一旦存在有效 cover.jpg/png,则该目录内音频全部保留。
|
||
* 操作确定性且幂等(相同输入多次运行结果一致)。</p>
|
||
*
|
||
* @return 被删除的音频文件数量
|
||
*/
|
||
int cleanupLibraryWithoutCover(Path libraryPath) {
|
||
if (libraryPath == null || !Files.isDirectory(libraryPath)) return 0;
|
||
|
||
// 收集所有含音频的目录(album 目录以其直接父目录音频为单位判定封面)
|
||
List<Path> audioFiles = new ArrayList<>();
|
||
try {
|
||
Files.walkFileTree(libraryPath, new SimpleFileVisitor<Path>() {
|
||
@Override
|
||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||
if (isAudioFile(file)) {
|
||
audioFiles.add(file);
|
||
}
|
||
return FileVisitResult.CONTINUE;
|
||
}
|
||
});
|
||
} catch (IOException e) {
|
||
log.warn("封面清理扫描失败: {}", e.getMessage());
|
||
return 0;
|
||
}
|
||
|
||
// 确定性:按路径排序;按 album 目录缓存“是否已有封面文件”避免重复扫描
|
||
audioFiles.sort(Comparator.comparing(Path::toString));
|
||
Map<Path, Boolean> dirHasCover = new HashMap<>();
|
||
|
||
int removed = 0;
|
||
for (Path audio : audioFiles) {
|
||
Path albumDir = audio.getParent();
|
||
if (albumDir == null) continue;
|
||
Boolean covered = dirHasCover.get(albumDir);
|
||
if (covered == null) {
|
||
covered = hasExistingCover(albumDir);
|
||
dirHasCover.put(albumDir, covered);
|
||
}
|
||
if (covered) {
|
||
continue; // 目录已有封面 → 保留其内所有音频
|
||
}
|
||
// 目录无封面文件:若该音频含内嵌封面,则提取为 album 目录的 cover.jpg/png,
|
||
// 使 Navidrome 可发现封面;随后该目录标记为 covered,其余音频一并保留。
|
||
Path extracted = extractCoverFromAudio(audio, albumDir);
|
||
if (extracted != null) {
|
||
dirHasCover.put(albumDir, Boolean.TRUE);
|
||
log.info("封面清理:从内嵌封面为 album 目录生成 {}", extracted.getFileName());
|
||
continue;
|
||
}
|
||
// 既无目录封面、也无内嵌封面 → 删除该音频文件(仅音频,不动侧车/封面)
|
||
try {
|
||
if (Files.deleteIfExists(audio)) {
|
||
removed++;
|
||
log.info("封面清理:移除无封面音频 {}", audio.getFileName());
|
||
}
|
||
} catch (IOException e) {
|
||
log.warn("封面清理删除失败: {} - {}", audio.getFileName(), e.getMessage());
|
||
}
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
/**
|
||
* 若音频含内嵌 attached-picture 封面,则提取到 album 目录并返回写入的 cover 路径;
|
||
* 无内嵌封面或提取失败返回 null。jaudiotagger 已解析的字节优先(保留 JPEG/PNG),
|
||
* 否则用 FFmpeg 从源提取/规范化。绝不联网或从文件名推断。
|
||
*/
|
||
private Path extractCoverFromAudio(Path audio, Path albumDir) {
|
||
Tag tag = null;
|
||
try {
|
||
AudioFile af = AudioFileIO.read(audio.toFile());
|
||
tag = af.getTag();
|
||
} catch (Exception ignored) {
|
||
// jaudiotagger 无法解析 → acquireCover 内部会走 FFmpeg 路径
|
||
}
|
||
return acquireCover(audio, tag, albumDir);
|
||
}
|
||
|
||
/**
|
||
* 扫描 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(safeGetFirst(tag, FieldKey.TRACK), 0);
|
||
int discNum = parseInt(safeGetFirst(tag, 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();
|
||
}
|
||
|
||
/**
|
||
* 安全读取标签字段,对于不支持该字段的标签实现(如 WavTag 不支持 TRACK/DISC_NO/YEAR)
|
||
* 返回空字符串而非抛出 {@link UnsupportedOperationException}。
|
||
*/
|
||
private String safeGetFirst(Tag tag, FieldKey key) {
|
||
try {
|
||
return trim(tag.getFirst(key));
|
||
} catch (UnsupportedOperationException e) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
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 String checkFfprobeAvailable() {
|
||
String ffprobeCmd = audioValidationService != null
|
||
? audioValidationService.getFfprobeCommand() : "ffprobe";
|
||
try {
|
||
ProcessBuilder pb = new ProcessBuilder(ffprobeCmd, "-version");
|
||
pb.redirectErrorStream(true);
|
||
Process p = pb.start();
|
||
boolean finished = p.waitFor(FFMPEG_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||
if (!finished) {
|
||
p.destroyForcibly();
|
||
return "ffprobe 预检查超时,请检查环境配置";
|
||
}
|
||
if (p.exitValue() != 0) {
|
||
return "ffprobe 不可用,请确认已正确安装并加入 PATH";
|
||
}
|
||
return null;
|
||
} catch (IOException e) {
|
||
return "ffprobe 不可用,请确认已正确安装并加入 PATH";
|
||
} catch (InterruptedException e) {
|
||
Thread.currentThread().interrupt();
|
||
return "ffprobe 预检查被中断";
|
||
}
|
||
}
|
||
|
||
// ========== 进度消息 ==========
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|