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;
@Autowired(required = false)
private LyricsService lyricsService;
@org.springframework.beans.factory.annotation.Autowired(required = false)
private CoverArtService coverArtService;
private volatile CoverRun coverRun;
static final class CoverRun {
final java.util.concurrent.atomic.AtomicInteger found = new java.util.concurrent.atomic.AtomicInteger();
final java.util.concurrent.atomic.AtomicInteger missing = new java.util.concurrent.atomic.AtomicInteger();
final java.util.concurrent.atomic.AtomicInteger failed = new java.util.concurrent.atomic.AtomicInteger();
volatile String lastSource;
volatile String lastStatus;
void reset() { lastSource = null; lastStatus = null; }
void record(String source, String status) {
lastSource = source;
lastStatus = status;
if ("found".equals(status)) found.incrementAndGet();
else if ("failed".equals(status)) failed.incrementAndGet();
else missing.incrementAndGet();
}
}
/** 任务生命周期持久化与取消(可选注入;测试构造器不设置时为 null,行为不变)。 */
@Autowired(required = false)
private IngestTaskStore taskStore;
/**
* 当前 ingest 运行的歌词统计收集器;仅由 {@link #ingest} 在循环前设置、结束后清空。
* 直接调用 {@link #processSingleFile}(测试)时为 null,此时不记录(不影响返回值/行为)。
* ingest 由 running 锁保证同一时刻仅一个任务,故单实例字段安全。
*/
private volatile LyricRun lyricRun;
/** 歌词来源分类(用于报告观测)。 */
static final String LYRIC_SOURCE_SIDECAR = "sidecar";
static final String LYRIC_SOURCE_EMBEDDED = "embedded";
static final String LYRIC_SOURCE_REMOTE = "remote";
static final String LYRIC_SOURCE_NONE = "none";
/** 歌词状态分类:found/missing/failed。 */
static final String LYRIC_STATUS_FOUND = "found";
static final String LYRIC_STATUS_MISSING = "missing";
static final String LYRIC_STATUS_FAILED = "failed";
/**
* 单次 ingest 运行的歌词聚合。歌词失败绝不影响音频入库,此处仅作观测统计。
* 循环单线程执行,{@code lastSource/lastStatus} 在每个文件处理前后读取一次,无并发问题。
*/
static final class LyricRun {
final AtomicInteger found = new AtomicInteger();
final AtomicInteger missing = new AtomicInteger();
final AtomicInteger failed = new AtomicInteger();
volatile String lastSource;
volatile String lastStatus;
void reset() { lastSource = null; lastStatus = null; }
void record(String source, String status) {
lastSource = source;
lastStatus = status;
if (LYRIC_STATUS_FOUND.equals(status)) found.incrementAndGet();
else if (LYRIC_STATUS_FAILED.equals(status)) failed.incrementAndGet();
else missing.incrementAndGet();
}
}
/**
* Spring DI 构造器。
* 包级 4 参数构造器保留给测试,此时 {@code audioValidationService} 为 null,
* 跳过音频完整性验证步骤。
*/
@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);
}
/**
* 异步执行一键导入任务。
* 并发锁:{@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 audioFiles = new ArrayList<>();
Files.walkFileTree(inputPath, new SimpleFileVisitor() {
@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 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);
LyricRun run = new LyricRun();
this.lyricRun = run;
CoverRun cr = new CoverRun();
this.coverRun = cr;
// 登记任务生命周期(持久化,供重启恢复与取消)
if (taskStore != null) {
taskStore.begin(taskId, total);
}
// 批内重复检测集合
Set batchIdentities = new HashSet<>();
// 文件结果映射(用于报告):outcome + 歌词来源/状态
LinkedHashMap fileOutcomes = new LinkedHashMap<>();
boolean cancelled = false;
for (Path srcFile : audioFiles) {
// 取消检查:仅停止后续文件处理,已完成(已移动)的文件保持不动
if (taskStore != null && taskStore.isCancelRequested(taskId)) {
cancelled = true;
break;
}
String fileName = srcFile.getFileName().toString();
String relativeKey = inputPath.relativize(srcFile).toString();
String outcome = "unknown";
run.reset();
if (coverRun != null) coverRun.reset();
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, new FileReport(outcome, run.lastSource, run.lastStatus, cr.lastSource, cr.lastStatus));
if (taskStore != null) {
taskStore.recordProcessed(taskId, relativeKey, outcome, run.lastSource, run.lastStatus,
cr.lastSource, cr.lastStatus,
new IngestTaskStore.Counters(
ingested.get(), duplicates.get(), missingMeta.get(),
unreadable.get(), convFailed.get(), otherRejected.get(),
missingCover.get(),
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get()));
}
int p = processed.incrementAndGet();
sendProgress(taskId, total, p, ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.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,
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get());
if (cancelled) {
if (taskStore != null) {
taskStore.markCancelled(taskId, "任务已取消");
}
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get(), null,
String.format("任务已取消:已处理 %d/%d,已入库 %d(已完成文件保持不动)",
processed.get(), total, ingested.get()),
true);
return;
}
// 完成
if (taskStore != null) {
taskStore.complete(taskId, "导入完成");
}
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get(), null,
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 缺封面: %d, 不可读: %d, 转码失败: %d, 其他: %d, 清理: %d, 歌词(有/无/失败): %d/%d/%d, 封面(有/无/失败): %d/%d/%d",
ingested.get(), duplicates.get(), missingMeta.get(), missingCover.get(),
unreadable.get(), convFailed.get(), otherRejected.get(), cleanupRemoved,
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get()),
true);
} catch (Exception e) {
log.error("导入任务执行失败", e);
// 持久化任务标记为 failed(若已 begin),避免重启后残留 running 状态
if (taskStore != null) {
taskStore.markFailed(taskId, "任务内部错误: " + e.getMessage());
}
// 发送 terminal 进度消息,确保前端停止轮询并显示错误
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
null, "任务内部错误: " + e.getMessage(), true);
} finally {
this.lyricRun = null;
coverRun = null;
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 libraryIdentities,
Set 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;
String coverSource = "none";
String coverStatus = "missing";
if (!albumAlreadyCovered) {
writtenCover = acquireCover(srcFile, tag, targetDir);
if (writtenCover != null) {
coverSource = "embedded";
coverStatus = "found";
} else if (coverArtService != null) {
CoverArtService.RemoteOutcome ro = coverArtService.resolveRemote(targetDir, effectiveAlbum, effectiveArtist, year);
if (ro == CoverArtService.RemoteOutcome.FETCHED) {
Path existing = findExistingCover(targetDir);
try {
if (existing != null && Files.size(existing) > 0) {
writtenCover = existing;
coverSource = "remote";
coverStatus = "found";
} else {
writtenCover = null;
coverStatus = "failed";
}
} catch (IOException e) {
writtenCover = null;
coverStatus = "failed";
}
} else if (ro == CoverArtService.RemoteOutcome.FAILED) {
coverStatus = "failed";
}
}
if (writtenCover == null) {
if (coverRun != null) coverRun.record(coverSource, coverStatus);
if (derivedFile) {
deleteIfExists(effectiveFile);
}
missingCover.incrementAndGet();
moveToRejected(srcFile, rejectedPath, "MissingCover", fileName);
log.info("缺少封面(无既有 cover 且无内嵌封面): {}", fileName);
return "rejected:missing-cover";
}
} else {
coverSource = "existing";
coverStatus = "found";
}
if (coverRun != null) coverRun.record(coverSource, coverStatus);
// 移动/复制到目标位置
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 文件(可选)
boolean sidecarLrc = handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
// 提取嵌入式歌词(可选,仅 jaudiotagger 路径有 tag 对象)
boolean embeddedLrc = false;
if (tag != null) {
embeddedLrc = extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
}
// 远程歌词兜底(可选)。任何失败均非致命,仅记录为 lyricsFailed,绝不改变入库结果。
String lyricSource;
String lyricStatus;
if (sidecarLrc) {
lyricSource = LYRIC_SOURCE_SIDECAR;
lyricStatus = LYRIC_STATUS_FOUND;
} else if (embeddedLrc) {
lyricSource = LYRIC_SOURCE_EMBEDDED;
lyricStatus = LYRIC_STATUS_FOUND;
} else if (lyricsService != null) {
LyricsService.RemoteOutcome ro =
lyricsService.resolveRemote(targetDir.resolve(destFileName), title, effectiveArtist);
switch (ro) {
case FETCHED:
lyricSource = LYRIC_SOURCE_REMOTE;
lyricStatus = LYRIC_STATUS_FOUND;
break;
case EXISTS:
lyricSource = LYRIC_SOURCE_SIDECAR;
lyricStatus = LYRIC_STATUS_FOUND;
break;
case FAILED:
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_FAILED;
break;
default: // MISSING / DISABLED
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_MISSING;
break;
}
} else {
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_MISSING;
}
if (lyricRun != null) {
lyricRun.record(lyricSource, lyricStatus);
}
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 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。
* 优先使用 jaudiotagger 已解析的 artwork 字节(保留 JPEG/PNG 原样);不可用或为
* 不支持的图像格式时,改用 FFmpeg 从源的 attached-picture 流提取/规范化为 cover.jpg。
* 绝不联网或从文件名推断。
*
* @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 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 文件随音频一起移入目标目录。
* @return true 表示确实移动了一个侧车 .lrc
*/
private boolean 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 false;
}
try {
String lrcDestName = getBaseName(destFileName) + ".lrc";
Path lrcTarget = resolveUniqueFile(targetDir, lrcDestName);
FileTransferUtils.moveWithFallback(lrcSource, lrcTarget);
log.info("已移动关联 LRC 文件: {} -> {}", lrcSource.getFileName(), lrcTarget);
return true;
} catch (IOException e) {
log.warn("移动 LRC 文件失败: {} - {}", lrcSource, e.getMessage());
return false;
}
}
/**
* 从标签中提取嵌入式歌词(可选),写入 LRC 文件。
* @return true 表示确实写出了一个内嵌歌词 .lrc
*/
private boolean 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 false;
// 构建简易 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);
return true;
} catch (Exception e) {
log.debug("提取嵌入式歌词失败(可选,忽略): {}", e.getMessage());
return false;
}
}
// ========== 结构化报告 ==========
/**
* 写入 JSON 格式的结构化报告
*/
/** 单文件报告条目:处理结果 + 歌词来源/状态(歌词字段可为 null,仅入库文件有值)。 */
static final class FileReport {
final String outcome;
final String lyricSource;
final String lyricStatus;
final String coverSource;
final String coverStatus;
FileReport(String outcome, String lyricSource, String lyricStatus, String coverSource, String coverStatus) {
this.outcome = outcome;
this.lyricSource = lyricSource;
this.lyricStatus = lyricStatus;
this.coverSource = coverSource;
this.coverStatus = coverStatus;
}
}
private void writeReport(String taskId, LinkedHashMap fileOutcomes,
Path rejectedPath, int ingestedCount, int duplicateCount,
int missingMetaCount, int unreadableCount,
int convFailedCount, int otherRejectedCount,
int missingCoverCount, int cleanupRemovedCount,
int lyricsFoundCount, int lyricsMissingCount, int lyricsFailedCount,
int coversFoundCount, int coversMissingCount, int coversFailedCount) {
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(" \"lyricsFound\": ").append(lyricsFoundCount).append(",\n");
json.append(" \"lyricsMissing\": ").append(lyricsMissingCount).append(",\n");
json.append(" \"lyricsFailed\": ").append(lyricsFailedCount).append(",\n");
json.append(" \"coversFound\": ").append(coversFoundCount).append(",\n");
json.append(" \"coversMissing\": ").append(coversMissingCount).append(",\n");
json.append(" \"coversFailed\": ").append(coversFailedCount).append("\n");
json.append(" },\n");
json.append(" \"files\": [\n");
int idx = 0;
int size = fileOutcomes.size();
for (Map.Entry entry : fileOutcomes.entrySet()) {
FileReport fr = entry.getValue();
json.append(" {\"file\": \"").append(escapeJson(entry.getKey()));
json.append("\", \"outcome\": \"").append(escapeJson(fr.outcome)).append("\"");
// 歌词字段仅对入库文件有值;追加在已有字段之后,保持向后兼容
if (fr.lyricStatus != null) {
json.append(", \"lyricStatus\": \"").append(escapeJson(fr.lyricStatus)).append("\"");
json.append(", \"lyricSource\": \"").append(escapeJson(fr.lyricSource)).append("\"");
}
if (fr.coverStatus != null) {
json.append(", \"coverStatus\": \"").append(escapeJson(fr.coverStatus)).append("\"");
json.append(", \"coverSource\": \"").append(escapeJson(fr.coverSource)).append("\"");
}
json.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 libraryIdentities,
Set 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 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 元数据后备/恢复路径。
* 凡是受支持的音频类型(已通过 {@link #isAudioFile} 扫描)且启用了校验服务,
* 均允许在 jaudiotagger 无法解析/写回时改用 FFprobe 元数据分类并 remux 恢复,
* 使支持的可读文件由 FFprobe 元数据决定归类,而非一律 Unreadable。
*/
private boolean isFallbackEligible(Path file) {
return audioValidationService != null && isAudioFile(file);
}
/** 音频编码 → 后备 remux 输出容器扩展名(选择编码兼容的容器,避免不兼容封装) */
private static final Map CODEC_TO_CONTAINER_EXT;
static {
Map 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)。
* 用于 jaudiotagger 无法安全写回该容器标签、且不能修改原文件的场景,
* 避免因写回失败而误拒绝合法音频。不伪造缺失的必要元数据。
* 输出为独立临时文件;调用方负责在失败或验证不通过时清理。
*
* @return 生成的 M4A 输出路径
*/
List buildRemuxCommand(Path input, Path output, String title, String artist,
String album, String albumArtist) {
List 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 cmd = buildRemuxCommand(input, output, title, artist, album, albumArtist);
runFfmpegDrained(cmd, FFMPEG_CONVERT_TIMEOUT_SECONDS, output, "remux");
return output;
}
/**
* 运行一个 FFmpeg 子进程,并发排空其合并后的标准输出,避免管道写满导致死锁;
* 超时或中断时强制终止并有界回收,失败时清理输出文件。
* 合并 stderr→stdout 后由守护线程持续读取(仅保留前 {@value #FFMPEG_DIAGNOSTIC_CAP}
* 字节作诊断),超出部分丢弃以排空管道。非零退出或超时抛出 {@link RuntimeException}。
*
* @param command 完整命令行
* @param timeoutSeconds 超时秒数
* @param outputToClean 失败/超时时需要删除的输出文件(可为 null)
* @param label 日志/异常用的操作标签(如 "remux" / "转码")
*/
private void runFfmpegDrained(List 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 封面的音频文件。
* 安全边界:仅删除 Library 内的音频文件;不删除封面、非音频侧车、任何 Rejected 文件;
* album 目录中一旦存在有效 cover.jpg/png,则该目录内音频全部保留。
* 操作确定性且幂等(相同输入多次运行结果一致)。
*
* @return 被删除的音频文件数量
*/
int cleanupLibraryWithoutCover(Path libraryPath) {
if (libraryPath == null || !Files.isDirectory(libraryPath)) return 0;
// 收集所有含音频的目录(album 目录以其直接父目录音频为单位判定封面)
List audioFiles = new ArrayList<>();
try {
Files.walkFileTree(libraryPath, new SimpleFileVisitor() {
@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 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 scanLibraryIdentities(Path libraryPath) {
Set identities = new HashSet<>();
if (!Files.exists(libraryPath)) return identities;
try {
Files.walkFileTree(libraryPath, new SimpleFileVisitor() {
@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) {
sendProgress(taskId, total, processed, ingestedFiles, duplicateFiles,
missingMetadataFiles, unreadableFiles, conversionFailedFiles, otherRejectedFiles,
null, null, null, null, null, null, currentFile, message, completed);
}
private void sendProgress(String taskId, int total, int processed,
int ingestedFiles, int duplicateFiles,
int missingMetadataFiles, int unreadableFiles,
int conversionFailedFiles, int otherRejectedFiles,
Integer lyricsFound, Integer lyricsMissing, Integer lyricsFailed,
Integer coversFound, Integer coversMissing, Integer coversFailed,
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.setLyricsFound(lyricsFound);
pm.setLyricsMissing(lyricsMissing);
pm.setLyricsFailed(lyricsFailed);
pm.setCoversFound(coversFound);
pm.setCoversMissing(coversMissing);
pm.setCoversFailed(coversFailed);
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;
}
}
}