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