Template
fix: enforce artwork-safe music ingestion
This commit is contained in:
@@ -192,6 +192,10 @@ public class IngestService {
|
||||
}
|
||||
}
|
||||
|
||||
// 历史 Library 清理由 scripts/cleanup-library.sh 显式执行一次。
|
||||
// 正常入库不执行破坏性全量扫描,避免每批导入反复删除历史数据。
|
||||
int cleanupRemoved = 0;
|
||||
|
||||
// 扫描 Library 中已有的文件作为重复检测参考(t2s 归一化)
|
||||
Set<IdentityKey> 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<IdentityKey> 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<Path> 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。
|
||||
* <p>优先使用 jaudiotagger 已解析的 artwork 字节(保留 JPEG/PNG 原样);不可用或为
|
||||
* 不支持的图像格式时,改用 FFmpeg 从源的 attached-picture 流提取/规范化为 cover.jpg。
|
||||
* 绝不联网或从文件名推断。</p>
|
||||
*
|
||||
* @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<Artwork> 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<Artwork> 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<String, String> 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<String> FALLBACK_ELIGIBLE_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"m4a", "mp4", "aac", "m4b", "m4p"
|
||||
));
|
||||
|
||||
/**
|
||||
* 判断文件是否适用 FFprobe 元数据后备读取路径。
|
||||
* 仅限受支持的 M4A/MP4/AAC 容器,避免对其他格式做无意义的后备。
|
||||
* 判断文件是否适用 FFprobe 元数据后备/恢复路径。
|
||||
* <p>凡是受支持的音频类型(已通过 {@link #isAudioFile} 扫描)且启用了校验服务,
|
||||
* 均允许在 jaudiotagger 无法解析/写回时改用 FFprobe 元数据分类并 remux 恢复,
|
||||
* 使支持的可读文件由 FFprobe 元数据决定归类,而非一律 Unreadable。</p>
|
||||
*/
|
||||
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<String, String> CODEC_TO_CONTAINER_EXT;
|
||||
static {
|
||||
Map<String, String> 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<String> 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 封面的音频文件。
|
||||
* <p>安全边界:仅删除 Library 内的音频文件;不删除封面、非音频侧车、任何 Rejected 文件;
|
||||
* album 目录中一旦存在有效 cover.jpg/png,则该目录内音频全部保留。
|
||||
* 操作确定性且幂等(相同输入多次运行结果一致)。</p>
|
||||
*
|
||||
* @return 被删除的音频文件数量
|
||||
*/
|
||||
int cleanupLibraryWithoutCover(Path libraryPath) {
|
||||
if (libraryPath == null || !Files.isDirectory(libraryPath)) return 0;
|
||||
|
||||
// 收集所有含音频的目录(album 目录以其直接父目录音频为单位判定封面)
|
||||
List<Path> audioFiles = new ArrayList<>();
|
||||
try {
|
||||
Files.walkFileTree(libraryPath, new SimpleFileVisitor<Path>() {
|
||||
@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<Path, Boolean> 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 文件一致)
|
||||
|
||||
Reference in New Issue
Block a user