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 目录布局。 * *

进度字段语义(type = "ingest"): *

*/ @Service public class IngestService { private static final Logger log = LoggerFactory.getLogger(IngestService.class); /** 需要转换为 FLAC 的无损格式 */ private static final Set LOSSLESS_EXTENSIONS = new HashSet<>(Arrays.asList( "wav", "ape", "aiff", "aif", "wv", "tta" )); /** 可直接保留的格式 */ private static final Set COMPATIBLE_EXTENSIONS = new HashSet<>(Arrays.asList( "flac", "mp3", "m4a", "aac", "ogg", "opus", "wma" )); /** 所有支持的音频格式 */ private static final Set 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 构造器。 *

包级 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); // 批内重复检测集合 Set batchIdentities = new HashSet<>(); // 文件结果映射(用于报告) LinkedHashMap 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 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; 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 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 文件随音频一起移入目标目录 */ 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 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 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 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) { 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; } } }