From 484c9c0d242bff463e510d8a099f5898c88f2f87 Mon Sep 17 00:00:00 2001 From: mangmang <362165265@qq.com> Date: Mon, 20 Jul 2026 01:56:13 +0800 Subject: [PATCH] fix: enforce artwork-safe music ingestion --- README.md | 16 + .../music/service/AudioValidationService.java | 199 +++++++ .../java/com/music/service/IngestService.java | 350 +++++++++--- .../service/AudioValidationServiceTest.java | 100 ++++ .../music/service/IngestServiceE2ETest.java | 532 +++++++++++++++++- scripts/cleanup-library.sh | 104 ++++ 6 files changed, 1223 insertions(+), 78 deletions(-) create mode 100755 scripts/cleanup-library.sh diff --git a/README.md b/README.md index 478e159..c9227a0 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,22 @@ cd frontend && npm ci && npm run dev cd docker && docker compose up -d --build ``` +## 一次性清理历史曲库 + +正常导入不会扫描或删除 `Library` 中的历史文件。升级后如需清理旧数据,先运行默认的预览模式: + +```bash +./scripts/cleanup-library.sh --library /path/to/Library --dry-run +``` + +确认输出后再显式执行: + +```bash +./scripts/cleanup-library.sh --library /path/to/Library --execute +``` + +脚本会优先把音频内嵌封面提取为专辑目录的 `cover.jpg/png`。只有目录和音频都没有封面时,才删除音频及同 basename 的 `.lrc`、`.cue`、`.json`、`.txt` 和图片 sidecar;不会删除公共 `cover.jpg/png` 或其他曲目文件。 + ## 技术栈 - 后端:Spring Boot 2.7 + Java 8 + Maven diff --git a/backend/src/main/java/com/music/service/AudioValidationService.java b/backend/src/main/java/com/music/service/AudioValidationService.java index 699b6bb..3ba951c 100644 --- a/backend/src/main/java/com/music/service/AudioValidationService.java +++ b/backend/src/main/java/com/music/service/AudioValidationService.java @@ -153,6 +153,42 @@ public class AudioValidationService { } } + // ========== 嵌套内嵌封面结果类 ========== + + /** + * 内嵌 attached-picture(封面)探测结果。 + *

{@link #isPresent()} 为 true 表示源媒体含有 disposition.attached_pic 的图像流; + * {@link #getStreamIndex()} 为该流索引,{@link #getCodecName()} 为其编码名(如 mjpeg/png)。 + * 仅识别内嵌 attached-picture,绝不从文件名推断或联网获取。

+ */ + public static class EmbeddedArtwork { + private final boolean present; + private final int streamIndex; + private final String codecName; + + EmbeddedArtwork(boolean present, int streamIndex, String codecName) { + this.present = present; + this.streamIndex = streamIndex; + this.codecName = codecName != null ? codecName : ""; + } + + public boolean isPresent() { + return present; + } + + public int getStreamIndex() { + return streamIndex; + } + + public String getCodecName() { + return codecName; + } + + static EmbeddedArtwork absent() { + return new EmbeddedArtwork(false, -1, ""); + } + } + // ========== 嵌套进程运行结果 ========== /** @@ -335,6 +371,39 @@ public class AudioValidationService { } } + /** + * 探测文件第一个音频流的编码名(如 {@code aac}/{@code mp3}/{@code flac}/{@code vorbis}/ + * {@code opus}/{@code alac}/{@code wmav2})。用于后备 remux 时选择编码兼容的容器, + * 避免把 AAC 之类误封进 ogg 等不兼容容器。无法识别时返回空字符串。 + */ + public String probeAudioCodec(Path media) { + int timeout = resolveProbeTimeout(); + ProcessResult result; + try { + result = runProcess(getMetadataCommandArgs(media), timeout, MAX_METADATA_JSON_LENGTH); + } catch (IOException e) { + log.warn("FFprobe 编码探测执行失败: {} - {}", media, e.getMessage()); + return ""; + } + if (result.timedOut || result.exitCode != 0) { + return ""; + } + try { + JsonNode root = JSON_MAPPER.readTree(result.output); + JsonNode streams = root.path("streams"); + if (streams.isArray()) { + for (JsonNode stream : streams) { + if ("audio".equals(stream.path("codec_type").asText())) { + return stream.path("codec_name").asText("").toLowerCase(); + } + } + } + } catch (IOException e) { + log.warn("FFprobe 编码探测 JSON 解析失败: {} - {}", media, e.getMessage()); + } + return ""; + } + /** * 判断 FFprobe JSON 中是否存在音频流。 */ @@ -368,6 +437,136 @@ public class AudioValidationService { } } + // ========== 内嵌封面探测与提取(FFprobe / FFmpeg) ========== + + /** 可直接以 {@code -c copy} 原样导出的图像编码 → 目标扩展名 */ + private static final Map COPYABLE_IMAGE_CODECS; + static { + Map m = new LinkedHashMap<>(); + m.put("mjpeg", "jpg"); + m.put("jpeg", "jpg"); + m.put("png", "png"); + COPYABLE_IMAGE_CODECS = m; + } + + /** + * 探测源媒体是否含有内嵌 attached-picture(封面)图像流。 + *

基于 FFprobe 结构化 JSON 的 {@code disposition.attached_pic == 1} 判定, + * 仅识别真正的内嵌封面,普通视频流不计入。

+ */ + public EmbeddedArtwork probeAttachedPicture(Path media) { + int timeout = resolveProbeTimeout(); + ProcessResult result; + try { + result = runProcess(getMetadataCommandArgs(media), timeout, MAX_METADATA_JSON_LENGTH); + } catch (IOException e) { + log.warn("FFprobe 封面探测执行失败: {} - {}", media, e.getMessage()); + return EmbeddedArtwork.absent(); + } + if (result.timedOut || result.exitCode != 0) { + return EmbeddedArtwork.absent(); + } + try { + JsonNode root = JSON_MAPPER.readTree(result.output); + JsonNode streams = root.path("streams"); + if (streams.isArray()) { + for (JsonNode stream : streams) { + JsonNode disp = stream.path("disposition").path("attached_pic"); + if (disp.asInt(0) == 1) { + int idx = stream.path("index").asInt(-1); + String codec = stream.path("codec_name").asText(""); + return new EmbeddedArtwork(true, idx, codec); + } + } + } + } catch (IOException e) { + log.warn("FFprobe 封面 JSON 解析失败: {} - {}", media, e.getMessage()); + } + return EmbeddedArtwork.absent(); + } + + /** + * 返回从内嵌封面流导出到 {@code output} 的 FFmpeg 命令参数列表(用于测试观察)。 + *

可直接复制的编码(mjpeg/png)使用 {@code -c copy} 保留原始质量; + * 其他可解码的图像格式则重编码为 JPEG(由 {@code output} 扩展名决定)。

+ */ + List getCoverExtractCommandArgs(Path media, int streamIndex, boolean copyStream, Path output) { + List cmd = new ArrayList<>(); + cmd.add(getFfmpegCommand()); + cmd.add("-y"); + cmd.add("-i"); + cmd.add(media.toAbsolutePath().toString()); + cmd.add("-map"); + cmd.add("0:" + streamIndex); + if (copyStream) { + cmd.add("-c"); + cmd.add("copy"); + } + cmd.add("-frames:v"); + cmd.add("1"); + cmd.add(output.toAbsolutePath().toString()); + return cmd; + } + + /** + * 将内嵌 attached-picture 封面提取到指定目录,返回写入的封面文件路径。 + *

JPEG/PNG 原样保留({@code -c copy});其他 FFmpeg 可解码的图像格式规范化为 + * {@code cover.jpg}。绝不联网或从文件名推断。

+ * + * @param media 源媒体 + * @param art {@link #probeAttachedPicture} 的探测结果(必须 present) + * @param coverTarget 目标封面文件路径(扩展名应与保留/规范化策略一致,调用方决定去重命名) + * @return true 表示成功写入非空封面文件 + */ + public boolean extractCoverTo(Path media, EmbeddedArtwork art, Path coverTarget) { + if (art == null || !art.isPresent() || art.getStreamIndex() < 0) { + return false; + } + String codec = art.getCodecName().toLowerCase(); + boolean copyable = COPYABLE_IMAGE_CODECS.containsKey(codec); + int timeout = resolveDecodeTimeout(); + List cmd = getCoverExtractCommandArgs(media, art.getStreamIndex(), copyable, coverTarget); + try { + ProcessResult result = runProcess(cmd, timeout); + if (result.timedOut || result.exitCode != 0) { + String diag = capDiagnostic(result.output); + log.warn("封面提取失败: {} (exit={}) diag={}", media, result.exitCode, diag); + deleteQuietly(coverTarget); + return false; + } + } catch (IOException e) { + log.warn("封面提取执行失败: {} - {}", media, e.getMessage()); + deleteQuietly(coverTarget); + return false; + } + try { + if (!java.nio.file.Files.exists(coverTarget) || java.nio.file.Files.size(coverTarget) == 0) { + deleteQuietly(coverTarget); + return false; + } + } catch (IOException e) { + return false; + } + return true; + } + + /** + * 依据封面图像编码返回目标扩展名:mjpeg/jpeg→jpg,png→png,其余规范化为 jpg。 + */ + public String coverExtensionFor(EmbeddedArtwork art) { + if (art == null) return "jpg"; + String codec = art.getCodecName().toLowerCase(); + return COPYABLE_IMAGE_CODECS.getOrDefault(codec, "jpg"); + } + + private static void deleteQuietly(Path p) { + if (p == null) return; + try { + java.nio.file.Files.deleteIfExists(p); + } catch (IOException ignored) { + } + } + // ========== 解码(FFmpeg,严格模式) ========== /** diff --git a/backend/src/main/java/com/music/service/IngestService.java b/backend/src/main/java/com/music/service/IngestService.java index a21747e..6f262c2 100644 --- a/backend/src/main/java/com/music/service/IngestService.java +++ b/backend/src/main/java/com/music/service/IngestService.java @@ -192,6 +192,10 @@ public class IngestService { } } + // 历史 Library 清理由 scripts/cleanup-library.sh 显式执行一次。 + // 正常入库不执行破坏性全量扫描,避免每批导入反复删除历史数据。 + int cleanupRemoved = 0; + // 扫描 Library 中已有的文件作为重复检测参考(t2s 归一化) Set libraryIdentities = scanLibraryIdentities(libraryPath); log.info("Library 中已有 {} 个曲目用于重复检测", libraryIdentities.size()); @@ -206,6 +210,7 @@ public class IngestService { 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); // 批内重复检测集合 @@ -223,7 +228,7 @@ public class IngestService { outcome = processSingleFile(srcFile, libraryPath, rejectedPath, libraryIdentities, batchIdentities, ingested, duplicates, missingMeta, unreadable, - convFailed, otherRejected); + convFailed, otherRejected, missingCover); } catch (Exception e) { otherRejected.incrementAndGet(); moveToRejected(srcFile, rejectedPath, "Other", fileName); @@ -242,15 +247,16 @@ public class IngestService { // 写入结构化报告 writeReport(taskId, fileOutcomes, rejectedPath, ingested.get(), duplicates.get(), - missingMeta.get(), unreadable.get(), convFailed.get(), otherRejected.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", - ingested.get(), duplicates.get(), missingMeta.get(), - unreadable.get(), convFailed.get(), otherRejected.get()), + 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) { @@ -306,7 +312,8 @@ public class IngestService { Set batchIdentities, AtomicInteger ingested, AtomicInteger duplicates, AtomicInteger missingMeta, AtomicInteger unreadable, - AtomicInteger convFailed, AtomicInteger otherRejected) throws Exception { + AtomicInteger convFailed, AtomicInteger otherRejected, + AtomicInteger missingCover) throws Exception { String fileName = srcFile.getFileName().toString(); String baseName = getBaseName(fileName); @@ -380,6 +387,9 @@ public class IngestService { // 3. 繁简转换文本标签并写回源文件(仅 jaudiotagger 路径; // FFprobe 后备路径稍后用 FFmpeg 无损 remux 写入简体标签) + // 若 commit 失败但已读到完整元数据,则切换到 FFmpeg remux 恢复路径(对受支持容器), + // 避免把合法音频误判为 Other。 + boolean commitFailedNeedsRemux = false; if (jaudioOk) { boolean tagsModified = false; for (FieldKey key : TEXT_FIELDS) { @@ -396,10 +406,17 @@ public class IngestService { try { audioFile.commit(); } catch (Exception e) { - otherRejected.incrementAndGet(); - moveToRejected(srcFile, rejectedPath, "Other", fileName); - log.warn("标签写入失败且无法恢复: {} - {}", fileName, e.getMessage()); - return "rejected:tag-write-failed"; + // 已读到完整元数据;对可 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"; + } } } } @@ -430,6 +447,7 @@ public class IngestService { // 5. 格式转换 / 后备 remux(如需要) Path effectiveFile = srcFile; boolean needsConversion = isLosslessFormat(srcFile); + String remuxExt = null; // 后备 remux 时的输出容器扩展名(决定入库文件扩展名) if (needsConversion) { try { Path flacFile = convertToFlac(srcFile, srcFile.getParent()); @@ -440,18 +458,21 @@ public class IngestService { log.warn("转码失败: {} - {}", fileName, e.getMessage()); return "rejected:conversion-failed"; } - } else if (usedFallback) { - // jaudiotagger 无法写回此容器的标签:用 FFmpeg 无损 remux(-c copy) + } 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); + simpAlbum, simpAlbumArtist, remuxExt); effectiveFile = remuxed; + usedFallback = true; // 后续按 FFprobe 重读元数据 + 从源提取封面处理 } catch (Exception e) { convFailed.incrementAndGet(); moveToRejected(srcFile, rejectedPath, "ConversionFailed", fileName); @@ -544,12 +565,12 @@ public class IngestService { } } - // 确定最终格式与文件名(后备 remux 统一输出 m4a 容器) + // 确定最终格式与文件名(后备 remux 使用编码兼容的容器扩展名) String ext; if (needsConversion) { ext = "flac"; - } else if (usedFallback) { - ext = "m4a"; + } else if (remuxExt != null) { + ext = remuxExt; } else { ext = getExtension(fileName); if (ext == null) ext = "flac"; @@ -573,7 +594,26 @@ public class IngestService { String destFileName = trackStr + " - " + safeTitle + "." + ext; Path targetFile = resolveUniqueFile(targetDir, destFileName); - // 8. 移动/复制到目标位置 + // 封面是入库硬性不变量: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) { @@ -582,34 +622,128 @@ public class IngestService { 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"; } - // 9. 如果生成了派生文件(转换或后备 remux)且成功,删除原文件 + // 如果生成了派生文件(转换或后备 remux)且成功,删除原文件 if (derivedFile) { deleteIfExists(srcFile); } - // 10. 更新身份标识集合 + // 更新身份标识集合 libraryIdentities.add(identity); batchIdentities.add(identity); ingested.incrementAndGet(); - // 11. 处理关联 LRC 文件(可选) + // 处理关联 LRC 文件(可选) handleAssociatedLrc(srcFile, targetDir, baseName, destFileName); - // 12. 提取嵌入式歌词与封面(可选,仅 jaudiotagger 路径有 tag 对象; - // 后备 remux 已保留原始嵌入式封面/歌词于输出容器中) + // 提取嵌入式歌词(可选,仅 jaudiotagger 路径有 tag 对象) if (tag != null) { extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist); - extractCover(tag, targetDir); } 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; + } + // ========== 关联文件处理 ========== /** @@ -658,33 +792,6 @@ public class IngestService { } } - /** - * 从标签中提取封面(可选),写入 cover.jpg - */ - private void extractCover(Tag tag, Path targetDir) { - try { - List artworks = tag.getArtworkList(); - if (artworks == null || artworks.isEmpty()) return; - - Artwork artwork = artworks.get(0); - byte[] data = artwork.getBinaryData(); - if (data == null || data.length == 0) return; - - String mime = artwork.getMimeType(); - String coverExt = "jpg"; - if (mime != null) { - if (mime.contains("png")) coverExt = "png"; - else if (mime.contains("gif")) coverExt = "gif"; - } - - Path coverFile = resolveUniqueFile(targetDir, "cover." + coverExt); - Files.write(coverFile, data); - log.info("已提取封面到: {}", coverFile); - } catch (Exception e) { - log.debug("提取封面失败(可选,忽略): {}", e.getMessage()); - } - } - // ========== 结构化报告 ========== /** @@ -693,7 +800,8 @@ public class IngestService { private void writeReport(String taskId, LinkedHashMap fileOutcomes, Path rejectedPath, int ingestedCount, int duplicateCount, int missingMetaCount, int unreadableCount, - int convFailedCount, int otherRejectedCount) { + int convFailedCount, int otherRejectedCount, + int missingCoverCount, int cleanupRemovedCount) { try { Path reportsDir = rejectedPath.resolve(REPORT_SUBDIR); Files.createDirectories(reportsDir); @@ -713,7 +821,10 @@ public class IngestService { 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(" \"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"); @@ -833,18 +944,41 @@ public class IngestService { } } - /** FFprobe 后备读取仅对这些容器格式启用(jaudiotagger 常拒绝的合法 AAC/M4A/MP4) */ - private static final Set FALLBACK_ELIGIBLE_EXTENSIONS = new HashSet<>(Arrays.asList( - "m4a", "mp4", "aac", "m4b", "m4p" - )); - /** - * 判断文件是否适用 FFprobe 元数据后备读取路径。 - * 仅限受支持的 M4A/MP4/AAC 容器,避免对其他格式做无意义的后备。 + * 判断文件是否适用 FFprobe 元数据后备/恢复路径。 + *

凡是受支持的音频类型(已通过 {@link #isAudioFile} 扫描)且启用了校验服务, + * 均允许在 jaudiotagger 无法解析/写回时改用 FFprobe 元数据分类并 remux 恢复, + * 使支持的可读文件由 FFprobe 元数据决定归类,而非一律 Unreadable。

*/ private boolean isFallbackEligible(Path file) { - String ext = getExtension(file.getFileName().toString()); - return ext != null && FALLBACK_ELIGIBLE_EXTENSIONS.contains(ext); + 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"; } /** @@ -863,7 +997,7 @@ public class IngestService { cmd.add("-y"); cmd.add("-i"); cmd.add(input.toAbsolutePath().toString()); - // 仅映射预期的第一个音频流,避免把普通视频/字幕/数据等流带入生成的 M4A, + // 仅映射预期的第一个音频流,避免把普通视频/字幕/数据等流带入生成的输出, // 确保输出是干净的 Navidrome 兼容音乐资产。 cmd.add("-map"); cmd.add("0:a:0"); @@ -872,6 +1006,12 @@ public class IngestService { // 保留原有 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); @@ -888,10 +1028,10 @@ public class IngestService { } private Path remuxWithSimplifiedTags(Path input, String title, String artist, - String album, String albumArtist) + String album, String albumArtist, String containerExt) throws IOException, InterruptedException { String baseName = getBaseName(input.getFileName().toString()); - Path output = resolveUniqueFile(input.getParent(), baseName + ".fallback.m4a"); + 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"); @@ -986,6 +1126,88 @@ public class IngestService { } } + /** + * 既有 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 文件一致) diff --git a/backend/src/test/java/com/music/service/AudioValidationServiceTest.java b/backend/src/test/java/com/music/service/AudioValidationServiceTest.java index 3dd18b6..02b7f4c 100644 --- a/backend/src/test/java/com/music/service/AudioValidationServiceTest.java +++ b/backend/src/test/java/com/music/service/AudioValidationServiceTest.java @@ -519,6 +519,106 @@ class AudioValidationServiceTest { assertFalse(meta.isAvailable(), "ffprobe 失败应返回 unavailable"); } + // ========== 内嵌封面探测与提取 ========== + + @Test + void probeAttachedPicture_detectsEmbeddedCover() throws Exception { + assumeFfmpegFfprobe(); + Path tmpDir = Files.createTempDirectory("probe-cover-"); + try { + Path m4a = createM4aWithCover(tmpDir, "withcover.m4a"); + AudioValidationService.EmbeddedArtwork art = service.probeAttachedPicture(m4a); + assertTrue(art.isPresent(), "应检测到内嵌 attached-picture"); + assertTrue(art.getStreamIndex() >= 0, "封面流索引应有效"); + assertFalse(art.getCodecName().isEmpty(), "应报告封面编码名"); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void probeAttachedPicture_realVideoNotTreatedAsCover() throws Exception { + assumeFfmpegFfprobe(); + Path tmpDir = Files.createTempDirectory("probe-cover-"); + try { + // 普通视频流(非 attached_pic)不应被识别为封面 + Path m4a = tmpDir.resolve("realvideo.m4a"); + ProcessBuilder pb = new ProcessBuilder( + "ffmpeg", "-y", + "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", + "-f", "lavfi", "-i", "testsrc=s=64x64:d=0.3", + "-map", "0:a", "-map", "1:v", + "-c:a", "aac", "-c:v", "libx264", "-shortest", "-t", "0.3", + "-f", "mp4", m4a.toAbsolutePath().toString()); + pb.redirectErrorStream(true); + Process p = pb.start(); + readAllBytes(p.getInputStream()); + p.waitFor(15, TimeUnit.SECONDS); + + AudioValidationService.EmbeddedArtwork art = service.probeAttachedPicture(m4a); + assertFalse(art.isPresent(), "普通视频流不应被当作封面"); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void extractCoverTo_writesCoverFile() throws Exception { + assumeFfmpegFfprobe(); + Path tmpDir = Files.createTempDirectory("probe-cover-"); + try { + Path m4a = createM4aWithCover(tmpDir, "withcover.m4a"); + AudioValidationService.EmbeddedArtwork art = service.probeAttachedPicture(m4a); + assertTrue(art.isPresent()); + + String ext = service.coverExtensionFor(art); + Path cover = tmpDir.resolve("cover." + ext); + boolean ok = service.extractCoverTo(m4a, art, cover); + assertTrue(ok, "封面提取应成功"); + assertTrue(Files.exists(cover) && Files.size(cover) > 0, "应写入非空封面文件"); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void getCoverExtractCommandArgs_copiesForJpegPng() { + Path media = Paths.get("/tmp/a.m4a"); + Path out = Paths.get("/tmp/cover.png"); + List copyArgs = service.getCoverExtractCommandArgs(media, 1, true, out); + assertTrue(copyArgs.contains("copy"), "可复制编码应使用 -c copy"); + assertTrue(copyArgs.contains("0:1"), "应按流索引映射"); + + List reencodeArgs = service.getCoverExtractCommandArgs(media, 1, false, out); + assertFalse(reencodeArgs.contains("copy"), "重编码路径不应带 -c copy"); + } + + private static Path createM4aWithCover(Path dir, String name) throws Exception { + Path cover = dir.resolve(".cover_src.png"); + runProcessLocal(new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=blue:s=48x48:d=0.1", + "-frames:v", "1", cover.toAbsolutePath().toString())); + Path out = dir.resolve(name); + runProcessLocal(new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", + "-i", cover.toAbsolutePath().toString(), + "-map", "0:a", "-map", "1:v", "-disposition:v:0", "attached_pic", + "-c:a", "aac", "-c:v", "png", "-t", "0.3", "-f", "mp4", + "-metadata", "title=T", "-metadata", "artist=A", "-metadata", "album=Al", + out.toAbsolutePath().toString())); + Files.deleteIfExists(cover); + return out; + } + + private static void runProcessLocal(ProcessBuilder pb) throws Exception { + pb.redirectErrorStream(true); + Process p = pb.start(); + readAllBytes(p.getInputStream()); + boolean ok = p.waitFor(15, TimeUnit.SECONDS); + if (!ok) { p.destroyForcibly(); throw new RuntimeException("ffmpeg 超时"); } + if (p.exitValue() != 0) throw new RuntimeException("ffmpeg 失败 exit=" + p.exitValue()); + } + /** * 使用 FFmpeg 创建一个带标签的有效短 M4A(AAC)文件。 */ diff --git a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java index 09f6b6a..c49f5ab 100644 --- a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java +++ b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java @@ -505,8 +505,16 @@ class IngestServiceE2ETest { String simpAlbum = "\u98CE\u9A6C"; // 风马 Path src = inputDir.resolve("trad.mp3"); + // 内嵌 PNG 封面,使 jaudiotagger artwork 路径可提取并满足入库不变量 + Path coverSrc = inputDir.resolve(".trad.cover.png"); + runProcess(new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=red:s=48x48:d=0.1", + "-frames:v", "1", coverSrc.toAbsolutePath().toString())); ProcessBuilder pb = new ProcessBuilder( "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", + "-i", coverSrc.toAbsolutePath().toString(), + "-map", "0:a", "-map", "1:v", "-disposition:v:0", "attached_pic", + "-c:v", "png", "-id3v2_version", "3", "-metadata", "title=" + tradTitle, "-metadata", "artist=" + tradArtist, "-metadata", "album=" + tradAlbum, @@ -514,6 +522,7 @@ class IngestServiceE2ETest { src.toAbsolutePath().toString() ); runProcess(pb); + Files.deleteIfExists(coverSrc); assertTrue(Files.exists(src), "FFmpeg 应创建带标签的 MP3"); assertTrue(Files.size(src) > 2000, "MP3 文件应有足够的音频帧"); @@ -876,7 +885,7 @@ class IngestServiceE2ETest { Files.createDirectories(rejDir); Path src = createTaggedMp3(inputDir, "corrupt.mp3", - "Valid Title", "Valid Artist", "Valid Album"); + "Valid Title", "Valid Artist", "Valid Album", false); // 截断文件到极小尺寸以破坏音频数据,但保留文件头部的 ID3v2 标签 // 使 jaudiotagger 仍可读取标签,但 FFprobe/FFmpeg 无法处理 long truncatedSize = Math.min(Files.size(src), 512); @@ -987,8 +996,9 @@ class IngestServiceE2ETest { assertEquals("ingested", result1); // 第二个相同内容的文件应为 duplicate,不应触发解码验证 + // (无封面以保证 512 字节截断后 ID3 标签仍可读,从而进入去重判定) Path src2 = createTaggedMp3(inputDir, "second.mp3", - "Dup Title", "Dup Artist", "Dup Album"); + "Dup Title", "Dup Artist", "Dup Album", false); // 截断第二个文件使其无法通过验证,但 duplicate 检测应提前阻止验证 long truncatedSize2 = Math.min(Files.size(src2), 512); try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(src2.toFile(), "rw")) { @@ -1089,6 +1099,11 @@ class IngestServiceE2ETest { Path src = createTaggedLossless(inputDir, "song.wav", "Flac Title", "Flac Artist", "Flac Album"); + // WAV 容器无法内嵌封面:预置 album 目录的 cover.jpg,验证“既有封面”即满足不变量。 + Path expectedDir = libDir.resolve("Flac Artist").resolve("Flac Album"); + Files.createDirectories(expectedDir); + Files.write(expectedDir.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + IngestService service = buildServiceWithValidation(); AtomicInteger ingested = new AtomicInteger(); AtomicInteger duplicates = new AtomicInteger(); @@ -1108,7 +1123,6 @@ class IngestServiceE2ETest { assertEquals(0, convFailed.get()); // 文件应作为 FLAC 存入 Library/Flac Artist/Flac Album/01 - Flac Title.flac - Path expectedDir = libDir.resolve("Flac Artist").resolve("Flac Album"); assertTrue(Files.isDirectory(expectedDir), "Library 应包含 Artist/Album 目录"); boolean foundFlac = Files.list(expectedDir).anyMatch(p -> @@ -1264,6 +1278,86 @@ class IngestServiceE2ETest { } } + // ========== Finding metadata-fallback-coverage:非 m4a/aac 扩展名也应走 FFprobe 分类 ========== + + @Test + void mislabeledSupportedFile_classifiedByFfprobe_ingestedWithCompatibleContainer() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-fallback-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + // MP4/AAC 内容但命名为 .ogg(受支持但此前不在 fallback-eligible 集合): + // 旧逻辑会直接判为 Unreadable;修复后应由 FFprobe 元数据分类并 remux 入库。 + Path src = createMp4ContainerAsAac(inputDir, "mislabel.ogg", + "Mis Title", "Mis Artist", "Mis Album", true); + assertJaudiotaggerCannotRead(src); + + IngestService service = buildServiceWithValidation(); + AtomicInteger ingested = new AtomicInteger(); + AtomicInteger unreadable = new AtomicInteger(); + AtomicInteger missingMeta = new AtomicInteger(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + ingested, new AtomicInteger(), + missingMeta, unreadable, + new AtomicInteger(), new AtomicInteger()); + + assertEquals("ingested", result, + "受支持但被 jaudiotagger 拒绝的文件应由 FFprobe 分类并入库,而非 Unreadable"); + assertEquals(1, ingested.get()); + assertEquals(0, unreadable.get()); + // AAC 内容应封入编码兼容的 m4a 容器 + Path albumDir = libDir.resolve("Mis Artist").resolve("Mis Album"); + assertTrue(Files.list(albumDir).anyMatch(p -> + p.getFileName().toString().equals("01 - Mis Title.m4a")), + "AAC 内容应 remux 为编码兼容的 .m4a 容器"); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void mislabeledSupportedFile_missingTag_classifiedMissingMetadataNotUnreadable() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-fallback-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + // MP4/AAC 内容命名为 .ogg,缺 album → FFprobe 可读但元数据不全,应为 MissingMetadata + Path src = createMp4ContainerAsAac(inputDir, "mislabel2.ogg", + "Only Title", "Only Artist", null, true); + assertJaudiotaggerCannotRead(src); + + IngestService service = buildServiceWithValidation(); + AtomicInteger unreadable = new AtomicInteger(); + AtomicInteger missingMeta = new AtomicInteger(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + new AtomicInteger(), new AtomicInteger(), + missingMeta, unreadable, + new AtomicInteger(), new AtomicInteger()); + + assertEquals("rejected:missing-metadata", result, + "FFprobe 可读但缺必填标签应为 MissingMetadata,而非 Unreadable"); + assertEquals(1, missingMeta.get()); + assertEquals(0, unreadable.get()); + assertTrue(Files.exists(rejDir.resolve("MissingMetadata").resolve("mislabel2.ogg"))); + } finally { + deleteDirectory(tmpDir); + } + } + @Test void jaudiotaggerUnreadableM4a_missingAlbumRejectedMissingMetadata() throws Exception { assumeFfmpeg(); @@ -1577,6 +1671,344 @@ class IngestServiceE2ETest { } } + // ========== 6. 封面不变量:提取 / 缺失拒绝 / 清理 ========== + + @Test + void embeddedArtworkExtractedAsCoverOnIngest() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-cover-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + // 带内嵌 PNG 封面的 MP3 + Path src = createTaggedMp3(inputDir, "song.mp3", + "Cov Title", "Cov Artist", "Cov Album", true); + + IngestService service = buildServiceWithValidation(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + new AtomicInteger(), new AtomicInteger(), + new AtomicInteger(), new AtomicInteger(), + new AtomicInteger(), new AtomicInteger()); + + assertEquals("ingested", result); + Path albumDir = libDir.resolve("Cov Artist").resolve("Cov Album"); + boolean coverExists = Files.exists(albumDir.resolve("cover.jpg")) + || Files.exists(albumDir.resolve("cover.png")); + assertTrue(coverExists, "内嵌封面应被提取为 album 目录下的 cover.jpg/png"); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void completeMetadataButNoCover_rejectedAsMissingCover() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-cover-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + // 完整元数据但无内嵌封面,且 album 目录也无既有 cover + Path src = createTaggedMp3(inputDir, "nocover.mp3", + "NC Title", "NC Artist", "NC Album", false); + + IngestService service = buildServiceWithValidation(); + AtomicInteger missingCover = new AtomicInteger(); + AtomicInteger ingested = new AtomicInteger(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + ingested, new AtomicInteger(), + new AtomicInteger(), new AtomicInteger(), + new AtomicInteger(), new AtomicInteger(), missingCover); + + assertEquals("rejected:missing-cover", result); + assertEquals(1, missingCover.get()); + assertEquals(0, ingested.get()); + assertTrue(Files.exists(rejDir.resolve("MissingCover").resolve("nocover.mp3")), + "无封面文件应进入 Rejected/MissingCover"); + // Library 中不应有任何音频文件或遗留 cover(可能存在空目录,属正常) + try (Stream walk = Files.walk(libDir)) { + boolean hasFile = walk.filter(Files::isRegularFile).findAny().isPresent(); + assertFalse(hasFile, "Library 中不应有任何音频或遗留封面文件"); + } + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void noEmbeddedArtworkButAlbumHasCover_accepted() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-cover-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + Path src = createTaggedMp3(inputDir, "song.mp3", + "AC Title", "AC Artist", "AC Album", false); + // 预置 album 封面 + Path albumDir = libDir.resolve("AC Artist").resolve("AC Album"); + Files.createDirectories(albumDir); + Files.write(albumDir.resolve("cover.png"), new byte[]{(byte) 0x89, 'P', 'N', 'G'}); + + IngestService service = buildServiceWithValidation(); + AtomicInteger ingested = new AtomicInteger(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + ingested, new AtomicInteger(), + new AtomicInteger(), new AtomicInteger(), + new AtomicInteger(), new AtomicInteger()); + + assertEquals("ingested", result, "album 已有封面时无内嵌封面也应入库"); + assertEquals(1, ingested.get()); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void fallbackRemux_extractsEmbeddedCoverBeforeSourceDeletion() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-cover-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + // .aac 扩展名的 M4A 容器(jaudiotagger 拒绝)+ 内嵌封面 → 走 remux 后备 + Path src = createMp4ContainerAsAac(inputDir, "fb.aac", + "FB Title", "FB Artist", "FB Album", true); + assertJaudiotaggerCannotRead(src); + + IngestService service = buildServiceWithValidation(); + AtomicInteger ingested = new AtomicInteger(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + ingested, new AtomicInteger(), + new AtomicInteger(), new AtomicInteger(), + new AtomicInteger(), new AtomicInteger()); + + assertEquals("ingested", result, "后备 remux 路径应在删除源前提取封面并入库"); + assertEquals(1, ingested.get()); + Path albumDir = libDir.resolve("FB Artist").resolve("FB Album"); + boolean coverExists = Files.exists(albumDir.resolve("cover.jpg")) + || Files.exists(albumDir.resolve("cover.png")); + assertTrue(coverExists, "封面应在源删除前从源提取到 album 目录"); + assertFalse(Files.exists(src), "源文件应已删除"); + } finally { + deleteDirectory(tmpDir); + } + } + + // ========== 7. 既有 Library 封面清理:安全性与幂等 ========== + + @Test + void cleanup_removesUncoveredAudio_preservesCoveredAndSidecars() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-cleanup-"); + try { + Path libDir = tmpDir.resolve("Library"); + // Album A:有 cover.jpg → 其内音频与侧车全部保留 + Path albumA = libDir.resolve("Artist").resolve("Album A"); + Files.createDirectories(albumA); + Files.write(albumA.resolve("cover.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + Path aAudio = createTaggedMp3(albumA, "01 - a.mp3", "a", "Artist", "Album A", false); + Path aLrc = albumA.resolve("01 - a.lrc"); + Files.write(aLrc, "[00:00]x".getBytes(StandardCharsets.UTF_8)); + + // Album B:无 cover 文件,音频也无内嵌封面 → 应被删除 + Path albumB = libDir.resolve("Artist").resolve("Album B"); + Files.createDirectories(albumB); + Path bAudio = createTaggedMp3(albumB, "01 - b.mp3", "b", "Artist", "Album B", false); + Path bLrc = albumB.resolve("01 - b.lrc"); + Files.write(bLrc, "[00:00]y".getBytes(StandardCharsets.UTF_8)); + + // Album C:无 cover 文件,但音频有内嵌封面 → 应保留并从内嵌封面提取出 cover 文件 + Path albumC = libDir.resolve("Artist").resolve("Album C"); + Files.createDirectories(albumC); + Path cAudio = createTaggedMp3(albumC, "01 - c.mp3", "c", "Artist", "Album C", true); + + IngestService service = buildServiceWithValidation(); + int removed = service.cleanupLibraryWithoutCover(libDir); + + assertEquals(1, removed, "只应删除 Album B 中的无封面音频"); + assertTrue(Files.exists(aAudio), "有封面 album 的音频应保留"); + assertTrue(Files.exists(aLrc), "有封面 album 的侧车应保留"); + assertFalse(Files.exists(bAudio), "无封面且无内嵌封面的音频应被删除"); + assertTrue(Files.exists(bLrc), "侧车 .lrc 不应被删除(仅删音频)"); + assertTrue(Files.exists(cAudio), "有内嵌封面的音频应保留"); + // Finding library-cover-extraction:内嵌封面应被提取为 Navidrome 可发现的 cover 文件 + boolean cCover = Files.exists(albumC.resolve("cover.jpg")) + || Files.exists(albumC.resolve("cover.png")); + assertTrue(cCover, "内嵌封面应被提取为 album 目录的 cover.jpg/png"); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void cleanup_isIdempotent() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-cleanup-"); + try { + Path libDir = tmpDir.resolve("Library"); + Path albumB = libDir.resolve("Artist").resolve("Album B"); + Files.createDirectories(albumB); + createTaggedMp3(albumB, "01 - b.mp3", "b", "Artist", "Album B", false); + createTaggedMp3(albumB, "02 - b2.mp3", "b2", "Artist", "Album B", false); + + IngestService service = buildServiceWithValidation(); + int first = service.cleanupLibraryWithoutCover(libDir); + int second = service.cleanupLibraryWithoutCover(libDir); + + assertEquals(2, first, "首次应删除两首无封面音频"); + assertEquals(0, second, "再次运行应无可删除项(幂等)"); + } finally { + deleteDirectory(tmpDir); + } + } + + @Test + void cleanup_extractsCoverFromEmbeddedArtwork_thenIdempotent() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-cleanup-"); + try { + Path libDir = tmpDir.resolve("Library"); + // 无 cover 文件,但两首音频均含内嵌封面 → 首轮应提取出 cover,且保留所有音频 + Path album = libDir.resolve("Artist").resolve("Album E"); + Files.createDirectories(album); + Path a1 = createTaggedMp3(album, "01 - e1.mp3", "e1", "Artist", "Album E", true); + Path a2 = createTaggedMp3(album, "02 - e2.mp3", "e2", "Artist", "Album E", true); + + IngestService service = buildServiceWithValidation(); + int removed1 = service.cleanupLibraryWithoutCover(libDir); + assertEquals(0, removed1, "含内嵌封面的音频不应被删除"); + boolean cover = Files.exists(album.resolve("cover.jpg")) + || Files.exists(album.resolve("cover.png")); + assertTrue(cover, "应从内嵌封面提取出 cover 文件"); + assertTrue(Files.exists(a1) && Files.exists(a2), "两首音频应保留"); + + // 幂等:已存在 cover,再次运行不删除、不重复 + long coverCountBefore = countCovers(album); + int removed2 = service.cleanupLibraryWithoutCover(libDir); + assertEquals(0, removed2, "再次运行应无删除(幂等)"); + assertEquals(coverCountBefore, countCovers(album), "不应重复生成 cover 文件"); + } finally { + deleteDirectory(tmpDir); + } + } + + private static long countCovers(Path dir) throws Exception { + try (Stream s = Files.list(dir)) { + return s.filter(p -> { + String n = p.getFileName().toString().toLowerCase(); + return n.equals("cover.jpg") || n.equals("cover.jpeg") || n.equals("cover.png"); + }).count(); + } + } + + @Test + void cleanup_doesNotTouchRejectedOrCoverFiles() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-cleanup-"); + try { + Path libDir = tmpDir.resolve("Library"); + Path albumA = libDir.resolve("Artist").resolve("Album A"); + Files.createDirectories(albumA); + Path cover = albumA.resolve("cover.png"); + Files.write(cover, new byte[]{(byte) 0x89, 'P', 'N', 'G'}); + // 无音频,只有 cover → cover 不应被删除 + IngestService service = buildServiceWithValidation(); + int removed = service.cleanupLibraryWithoutCover(libDir); + assertEquals(0, removed); + assertTrue(Files.exists(cover), "cover 文件不应被清理删除"); + } finally { + deleteDirectory(tmpDir); + } + } + + // ========== 8. M4A 标签 commit 失败经 remux 恢复 ========== + + @Test + void m4aCommitFailure_recoveredViaRemux_notRejectedAsOther() throws Exception { + assumeFfmpeg(); + Path tmpDir = Files.createTempDirectory("ingest-commit-"); + try { + Path inputDir = tmpDir.resolve("Input"); + Path libDir = tmpDir.resolve("Library"); + Path rejDir = tmpDir.resolve("Rejected"); + Files.createDirectories(inputDir); + Files.createDirectories(libDir); + Files.createDirectories(rejDir); + + // 合法 .m4a(含繁体标签 + 内嵌封面),jaudiotagger 可读取但设为只读使 commit() 失败 + Path src = inputDir.resolve("commit.m4a"); + Path coverSrc = inputDir.resolve(".c.png"); + runProcessChecked(new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=blue:s=48x48:d=0.1", + "-frames:v", "1", coverSrc.toAbsolutePath().toString()), "ffmpeg"); + // 繁体标签迫使 ingest 尝试写回(触发 commit) + runProcessChecked(new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", + "-i", coverSrc.toAbsolutePath().toString(), + "-map", "0:a", "-map", "1:v", "-disposition:v:0", "attached_pic", "-c:v", "png", + "-t", "0.3", "-c:a", "aac", "-f", "mp4", + "-metadata", "title=體", // 體 + "-metadata", "artist=門", // 門 + "-metadata", "album=風", // 風 + src.toAbsolutePath().toString()), "ffmpeg"); + Files.deleteIfExists(coverSrc); + + // 前置:jaudiotagger 能读取该 m4a + AudioFile pre = AudioFileIO.read(src.toFile()); + assumeTrueLocal(pre.getTag() != null, "前置:jaudiotagger 应能读取该 m4a"); + + // 设为只读,使 audioFile.commit() 抛异常,触发 remux 恢复 + src.toFile().setWritable(false); + + IngestService service = buildServiceWithValidation(); + AtomicInteger ingested = new AtomicInteger(); + AtomicInteger otherRejected = new AtomicInteger(); + AtomicInteger convFailed = new AtomicInteger(); + String result = invokeProcessSingleFile(service, src, libDir, rejDir, + new HashSet<>(), new HashSet<>(), + ingested, new AtomicInteger(), + new AtomicInteger(), new AtomicInteger(), + convFailed, otherRejected); + + src.toFile().setWritable(true); + + assertEquals("ingested", result, + "commit 失败的合法 m4a 应经 remux 恢复入库,而非 Other,实际: " + result); + assertEquals(0, otherRejected.get(), "不应归类为 Other"); + assertEquals(1, ingested.get()); + } finally { + deleteDirectory(tmpDir); + } + } + + /** 本类内联 assume(避免与 assumeFfmpeg 混淆) */ + private static void assumeTrueLocal(boolean cond, String msg) { + org.junit.jupiter.api.Assumptions.assumeTrue(cond, msg); + } + // ========== 工具方法 ========== /** @@ -1586,15 +2018,36 @@ class IngestServiceE2ETest { */ private static Path createMp4ContainerAsAac(Path dir, String name, String title, String artist, String album) throws Exception { + // 默认内嵌封面,使后备路径可从源提取封面并满足入库不变量。 + return createMp4ContainerAsAac(dir, name, title, artist, album, true); + } + + private static Path createMp4ContainerAsAac(Path dir, String name, + String title, String artist, String album, + boolean withCover) throws Exception { Path file = dir.resolve(name); java.util.List cmd = new java.util.ArrayList<>(Arrays.asList( - "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", - "-t", "0.3", "-c:a", "aac", "-f", "mp4")); + "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono")); + Path cover = null; + if (withCover) { + cover = dir.resolve("." + name + ".cover.png"); + runProcessChecked(new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=blue:s=48x48:d=0.1", + "-frames:v", "1", cover.toAbsolutePath().toString()), "ffmpeg"); + cmd.add("-i"); + cmd.add(cover.toAbsolutePath().toString()); + } + cmd.addAll(Arrays.asList("-map", "0:a")); + if (withCover) { + cmd.addAll(Arrays.asList("-map", "1:v", "-disposition:v:0", "attached_pic", "-c:v", "png")); + } + cmd.addAll(Arrays.asList("-t", "0.3", "-c:a", "aac", "-f", "mp4")); if (title != null) { cmd.add("-metadata"); cmd.add("title=" + title); } if (artist != null) { cmd.add("-metadata"); cmd.add("artist=" + artist); } if (album != null) { cmd.add("-metadata"); cmd.add("album=" + album); } cmd.add(file.toAbsolutePath().toString()); runProcessChecked(new ProcessBuilder(cmd), "ffmpeg"); + if (cover != null) Files.deleteIfExists(cover); return file; } @@ -1605,18 +2058,27 @@ class IngestServiceE2ETest { private static Path createMp4WithVideoAndAudio(Path dir, String name, String title, String artist, String album) throws Exception { Path file = dir.resolve(name); + // 内嵌一张 attached-picture 封面 + 一个普通视频流:验证 remux 丢弃普通视频, + // 而封面由 acquireCover 从源提取满足入库不变量。 + Path cover = dir.resolve("." + name + ".cover.png"); + runProcessChecked(new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=green:s=48x48:d=0.1", + "-frames:v", "1", cover.toAbsolutePath().toString()), "ffmpeg"); java.util.List cmd = new java.util.ArrayList<>(Arrays.asList( "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-f", "lavfi", "-i", "testsrc=s=64x64:d=0.3", - "-map", "0:a", "-map", "1:v", - "-c:a", "aac", "-c:v", "libx264", + "-i", cover.toAbsolutePath().toString(), + "-map", "0:a", "-map", "1:v", "-map", "2:v", + "-disposition:v:1", "attached_pic", + "-c:a", "aac", "-c:v:0", "libx264", "-c:v:1", "png", "-shortest", "-t", "0.3", "-f", "mp4")); if (title != null) { cmd.add("-metadata"); cmd.add("title=" + title); } if (artist != null) { cmd.add("-metadata"); cmd.add("artist=" + artist); } if (album != null) { cmd.add("-metadata"); cmd.add("album=" + album); } cmd.add(file.toAbsolutePath().toString()); runProcessChecked(new ProcessBuilder(cmd), "ffmpeg"); + Files.deleteIfExists(cover); return file; } @@ -1674,24 +2136,39 @@ class IngestServiceE2ETest { ); } + /** 现有调用方无需感知 missingCover 计数器;内部分配一个丢弃即可。 */ private String invokeProcessSingleFile( IngestService service, Path srcFile, Path libraryPath, Path rejectedPath, Set libraryIdentities, Set batchIdentities, AtomicInteger ingested, AtomicInteger duplicates, AtomicInteger missingMeta, AtomicInteger unreadable, AtomicInteger convFailed, AtomicInteger otherRejected) throws Exception { + return invokeProcessSingleFile(service, srcFile, libraryPath, rejectedPath, + libraryIdentities, batchIdentities, + ingested, duplicates, missingMeta, unreadable, + convFailed, otherRejected, new AtomicInteger()); + } + + private String invokeProcessSingleFile( + IngestService service, 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 { Method m = IngestService.class.getDeclaredMethod("processSingleFile", Path.class, Path.class, Path.class, Set.class, Set.class, AtomicInteger.class, AtomicInteger.class, AtomicInteger.class, AtomicInteger.class, - AtomicInteger.class, AtomicInteger.class); + AtomicInteger.class, AtomicInteger.class, + AtomicInteger.class); m.setAccessible(true); return (String) m.invoke(service, srcFile, libraryPath, rejectedPath, libraryIdentities, batchIdentities, ingested, duplicates, missingMeta, unreadable, - convFailed, otherRejected); + convFailed, otherRejected, missingCover); } private Class getIdentityKeyClass() throws Exception { @@ -1703,12 +2180,39 @@ class IngestServiceE2ETest { private static Path createTaggedMp3(Path dir, String name, String title, String artist, String album) throws Exception { + // 默认带内嵌封面:封面是入库硬性不变量,绝大多数成功路径测试需要它。 + return createTaggedMp3(dir, name, title, artist, album, true); + } + + /** + * 创建带标签的 MP3;{@code withCover=true} 时内嵌一张 PNG 封面。 + */ + private static Path createTaggedMp3(Path dir, String name, + String title, String artist, String album, + boolean withCover) throws Exception { Path file = dir.resolve(name); - ProcessBuilder pb = new ProcessBuilder( - "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc", - "-t", "0.1", file.toAbsolutePath().toString() - ); - runProcessChecked(pb, "ffmpeg"); + if (withCover) { + Path cover = dir.resolve(".__cover_src.png"); + ProcessBuilder mkCover = new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=blue:s=48x48:d=0.1", + "-frames:v", "1", cover.toAbsolutePath().toString()); + runProcessChecked(mkCover, "ffmpeg"); + ProcessBuilder pb = new ProcessBuilder( + "ffmpeg", "-y", + "-f", "lavfi", "-i", "sine=frequency=440:duration=0.2", + "-i", cover.toAbsolutePath().toString(), + "-map", "0:a", "-map", "1:v", "-disposition:v:0", "attached_pic", + "-c:a", "libmp3lame", "-c:v", "png", "-id3v2_version", "3", + file.toAbsolutePath().toString()); + runProcessChecked(pb, "ffmpeg"); + Files.deleteIfExists(cover); + } else { + ProcessBuilder pb = new ProcessBuilder( + "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc", + "-t", "0.1", file.toAbsolutePath().toString() + ); + runProcessChecked(pb, "ffmpeg"); + } writeTags(file, title, artist, album); return file; } diff --git a/scripts/cleanup-library.sh b/scripts/cleanup-library.sh new file mode 100755 index 0000000..cf700eb --- /dev/null +++ b/scripts/cleanup-library.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: cleanup-library.sh --library PATH [--dry-run|--execute] + +Default mode is --dry-run. --execute is required to delete files. +The script only handles audio files below PATH and same-basename sidecars. +EOF +} + +LIBRARY="" +MODE="dry-run" +while [[ $# -gt 0 ]]; do + case "$1" in + --library) [[ $# -ge 2 ]] || { usage >&2; exit 2; }; LIBRARY="$2"; shift 2 ;; + --dry-run) MODE="dry-run"; shift ;; + --execute) MODE="execute"; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[[ -n "$LIBRARY" && -d "$LIBRARY" ]] || { echo "Library directory is required" >&2; exit 2; } +command -v ffprobe >/dev/null || { echo "ffprobe is required" >&2; exit 2; } +command -v ffmpeg >/dev/null || { echo "ffmpeg is required" >&2; exit 2; } + +is_audio() { + local ext="${1##*.}" + ext="${ext,,}" + case "$ext" in + mp3|m4a|mp4|aac|flac|ogg|oga|opus|wma|wav|ape|aiff|aif|wv|tta) return 0 ;; + *) return 1 ;; + esac +} + +has_cover_file() { + [[ -s "$1/cover.jpg" || -s "$1/cover.jpeg" || -s "$1/cover.png" ]] +} + +embedded_stream() { + ffprobe -v error -select_streams v \ + -show_entries stream=index:stream_disposition=attached_pic \ + -of csv=p=0 "$1" 2>/dev/null | awk -F, '$2 == 1 { print $1; exit }' +} + +extract_cover() { + local audio="$1" album_dir="$2" stream codec ext target + stream="$(embedded_stream "$audio")" + [[ -n "$stream" ]] || return 1 + codec="$(ffprobe -v error -show_entries stream=index,codec_name -of csv=p=0 "$audio" 2>/dev/null | awk -F, -v idx="$stream" '$1 == idx { print $2; exit }')" + case "$codec" in + mjpeg|jpeg) ext="jpg"; target="$album_dir/cover.jpg" ;; + png) ext="png"; target="$album_dir/cover.png" ;; + *) ext="jpg"; target="$album_dir/cover.jpg" ;; + esac + [[ -e "$target" ]] && return 0 + if [[ "$MODE" == "dry-run" ]]; then + echo "WOULD EXTRACT $target FROM $audio" + return 0 + fi + if [[ "$ext" == "jpg" && "$codec" =~ ^(mjpeg|jpeg)$ || "$ext" == "png" ]]; then + ffmpeg -v error -y -i "$audio" -map "0:$stream" -frames:v 1 -c copy "$target" 2>/dev/null + else + ffmpeg -v error -y -i "$audio" -map "0:$stream" -frames:v 1 "$target" 2>/dev/null + fi + [[ -s "$target" ]] +} + +delete_sidecars() { + local audio="$1" dir base sidecar ext + dir="${audio%/*}"; base="${audio##*/}"; base="${base%.*}" + for sidecar in "$dir/$base".*; do + [[ -f "$sidecar" && "$sidecar" != "$audio" ]] || continue + ext="${sidecar##*.}" + case "${ext,,}" in + lrc|cue|json|txt|jpg|jpeg|png|webp) + [[ "${sidecar##*/}" == cover.jpg || "${sidecar##*/}" == cover.jpeg || "${sidecar##*/}" == cover.png ]] && continue + if [[ "$MODE" == "dry-run" ]]; then echo "WOULD DELETE SIDECAR $sidecar"; else rm -f -- "$sidecar"; fi ;; + esac + done +} + +removed=0 +sidecars=0 +extracted=0 +while IFS= read -r -d '' audio; do + album_dir="${audio%/*}" + if has_cover_file "$album_dir"; then continue; fi + if extract_cover "$audio" "$album_dir"; then + ((extracted += 1)) || true + continue + fi + if [[ "$MODE" == "dry-run" ]]; then + echo "WOULD DELETE AUDIO $audio" + else + rm -f -- "$audio" + fi + delete_sidecars "$audio" + ((removed += 1)) || true +done < <(find "$LIBRARY" -type f -print0 | while IFS= read -r -d '' f; do is_audio "$f" && printf '%s\0' "$f"; done) + +echo "mode=$MODE extracted=$extracted audio_removed=$removed"