Template
fix: enforce artwork-safe music ingestion
This commit is contained in:
@@ -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<String> copyArgs = service.getCoverExtractCommandArgs(media, 1, true, out);
|
||||
assertTrue(copyArgs.contains("copy"), "可复制编码应使用 -c copy");
|
||||
assertTrue(copyArgs.contains("0:1"), "应按流索引映射");
|
||||
|
||||
List<String> 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)文件。
|
||||
*/
|
||||
|
||||
@@ -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<Path> 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<Path> 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<String> 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<String> 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<Object> libraryIdentities, Set<Object> 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<Object> libraryIdentities, Set<Object> 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user