Template
Fix M4A fallback ingestion
This commit is contained in:
@@ -12,6 +12,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import com.music.config.AsyncConfig;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -77,6 +78,10 @@ public class IngestService {
|
||||
private static final int FFMPEG_COMPRESSION_LEVEL = 5;
|
||||
private static final int FFMPEG_CHECK_TIMEOUT_SECONDS = 10;
|
||||
private static final int FFMPEG_CONVERT_TIMEOUT_SECONDS = 600;
|
||||
/** FFmpeg 合并输出保留的诊断字节上限(超出继续读取以排空管道) */
|
||||
private static final int FFMPEG_DIAGNOSTIC_CAP = 4096;
|
||||
/** 强制终止后回收子进程 / 合并 drainer 的最长等待秒数 */
|
||||
private static final int FFMPEG_CLEANUP_WAIT_SECONDS = 5;
|
||||
private static final String FFMPEG_BIN_PROPERTY = "mangtool.ffmpeg.bin";
|
||||
private static final String REPORT_SUBDIR = "Reports";
|
||||
|
||||
@@ -122,7 +127,7 @@ public class IngestService {
|
||||
* @param taskId 唯一任务标识
|
||||
* @param runningLock 由控制器传入的 AtomicBoolean,任务结束时置为 false
|
||||
*/
|
||||
@Async
|
||||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||||
public void ingest(String taskId, AtomicBoolean runningLock) {
|
||||
try {
|
||||
String inputDir = configService.getInputDir();
|
||||
@@ -306,80 +311,103 @@ public class IngestService {
|
||||
String fileName = srcFile.getFileName().toString();
|
||||
String baseName = getBaseName(fileName);
|
||||
|
||||
// 1. 读取元数据
|
||||
AudioFile audioFile;
|
||||
Tag tag;
|
||||
// 1. 读取元数据:优先 jaudiotagger;受支持的 M4A/MP4 无法解析时回退 FFprobe 结构化输出
|
||||
AudioFile audioFile = null;
|
||||
Tag tag = null;
|
||||
boolean usedFallback = false;
|
||||
|
||||
String title;
|
||||
String artist;
|
||||
String album;
|
||||
String albumArtist;
|
||||
String yearStr;
|
||||
String trackRaw;
|
||||
String discRaw;
|
||||
|
||||
boolean jaudioOk = false;
|
||||
String jaudioError = null;
|
||||
try {
|
||||
audioFile = AudioFileIO.read(srcFile.toFile());
|
||||
tag = audioFile.getTag();
|
||||
if (tag == null) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
return "rejected:unreadable";
|
||||
jaudioOk = (tag != null);
|
||||
if (!jaudioOk) {
|
||||
jaudioError = "标签为空";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("无法读取文件元数据: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:unreadable";
|
||||
jaudioError = e.getMessage();
|
||||
}
|
||||
|
||||
// 2. 读取关键字段
|
||||
String title = trim(tag.getFirst(FieldKey.TITLE));
|
||||
String artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||
String album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||
if (jaudioOk) {
|
||||
title = trim(tag.getFirst(FieldKey.TITLE));
|
||||
artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||
album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||
albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||
yearStr = safeGetFirst(tag, FieldKey.YEAR);
|
||||
trackRaw = safeGetFirst(tag, FieldKey.TRACK);
|
||||
discRaw = safeGetFirst(tag, FieldKey.DISC_NO);
|
||||
} else {
|
||||
// jaudiotagger 无法解析:仅对受支持的 M4A/MP4 且启用了 FFprobe 校验服务时回退
|
||||
if (audioValidationService == null || !isFallbackEligible(srcFile)) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("无法读取文件元数据: {} - {}", fileName, jaudioError);
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
AudioValidationService.ProbeMetadata meta = audioValidationService.readMetadata(srcFile);
|
||||
if (!meta.isAvailable()) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("FFprobe 后备元数据读取失败: {} - {}", fileName, meta.getDiagnostic());
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
usedFallback = true;
|
||||
title = meta.getFirst("title");
|
||||
artist = meta.getFirst("artist");
|
||||
album = meta.getFirst("album");
|
||||
albumArtist = meta.getFirst("album_artist", "albumartist");
|
||||
yearStr = meta.getFirst("date", "year");
|
||||
trackRaw = meta.getFirst("track");
|
||||
discRaw = meta.getFirst("disc", "discnumber");
|
||||
log.info("jaudiotagger 无法解析,改用 FFprobe 后备读取元数据: {}", fileName);
|
||||
}
|
||||
|
||||
// 3. 校验 Title/Artist/Album 非空
|
||||
// 2. 校验 Title/Artist/Album 非空(任一为空 → MissingMetadata)
|
||||
if (title.isEmpty() || artist.isEmpty() || album.isEmpty()) {
|
||||
missingMeta.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "MissingMetadata", fileName);
|
||||
return "rejected:missing-metadata";
|
||||
}
|
||||
|
||||
// 4. 繁简转换文本标签
|
||||
boolean tagsModified = false;
|
||||
for (FieldKey key : TEXT_FIELDS) {
|
||||
String value = trim(tag.getFirst(key));
|
||||
if (!value.isEmpty()) {
|
||||
String converted = traditionalFilterService.toSimplified(value);
|
||||
if (!converted.equals(value)) {
|
||||
tag.setField(key, converted);
|
||||
tagsModified = true;
|
||||
// 3. 繁简转换文本标签并写回源文件(仅 jaudiotagger 路径;
|
||||
// FFprobe 后备路径稍后用 FFmpeg 无损 remux 写入简体标签)
|
||||
if (jaudioOk) {
|
||||
boolean tagsModified = false;
|
||||
for (FieldKey key : TEXT_FIELDS) {
|
||||
String value = trim(tag.getFirst(key));
|
||||
if (!value.isEmpty()) {
|
||||
String converted = traditionalFilterService.toSimplified(value);
|
||||
if (!converted.equals(value)) {
|
||||
tag.setField(key, converted);
|
||||
tagsModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tagsModified) {
|
||||
try {
|
||||
audioFile.commit();
|
||||
} catch (Exception e) {
|
||||
otherRejected.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
log.warn("标签写入失败且无法恢复: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:tag-write-failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对 albumArtist 也进行繁简转换
|
||||
String albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||
if (!albumArtist.isEmpty()) {
|
||||
String converted = traditionalFilterService.toSimplified(albumArtist);
|
||||
if (!converted.equals(albumArtist)) {
|
||||
tag.setField(FieldKey.ALBUM_ARTIST, converted);
|
||||
tagsModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 将简化后的标签写回源文件
|
||||
if (tagsModified) {
|
||||
try {
|
||||
audioFile.commit();
|
||||
} catch (Exception e) {
|
||||
otherRejected.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
log.warn("标签写入失败且无法恢复: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:tag-write-failed";
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 提取其他可选字段(WavTag 等实现可能不支持 YEAR/TRACK/DISC_NO,使用安全读取)
|
||||
String yearStr = safeGetFirst(tag, FieldKey.YEAR);
|
||||
String trackRaw = safeGetFirst(tag, FieldKey.TRACK);
|
||||
String discRaw = safeGetFirst(tag, FieldKey.DISC_NO);
|
||||
|
||||
int trackNum = parseInt(trackRaw, 0);
|
||||
int discNum = parseInt(discRaw, 0);
|
||||
|
||||
// 7. 计算 MD5 用于去重兜底
|
||||
// 4. 计算 MD5 用于去重兜底
|
||||
String fileMd5 = computeMd5(srcFile);
|
||||
|
||||
// 8. 构建归一化身份标识
|
||||
@@ -399,7 +427,7 @@ public class IngestService {
|
||||
return "rejected:duplicate";
|
||||
}
|
||||
|
||||
// 10. 格式转换(如需要)
|
||||
// 5. 格式转换 / 后备 remux(如需要)
|
||||
Path effectiveFile = srcFile;
|
||||
boolean needsConversion = isLosslessFormat(srcFile);
|
||||
if (needsConversion) {
|
||||
@@ -412,13 +440,33 @@ public class IngestService {
|
||||
log.warn("转码失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:conversion-failed";
|
||||
}
|
||||
} else if (usedFallback) {
|
||||
// jaudiotagger 无法写回此容器的标签:用 FFmpeg 无损 remux(-c copy)
|
||||
// 生成保留音频质量与原有元数据的 Navidrome 兼容输出,并写入简体 Title/Artist/Album。
|
||||
String simpTitle = traditionalFilterService.toSimplified(title);
|
||||
String simpArtist = traditionalFilterService.toSimplified(artist);
|
||||
String simpAlbum = traditionalFilterService.toSimplified(album);
|
||||
String simpAlbumArtist = albumArtist.isEmpty()
|
||||
? "" : traditionalFilterService.toSimplified(albumArtist);
|
||||
try {
|
||||
Path remuxed = remuxWithSimplifiedTags(srcFile, simpTitle, simpArtist,
|
||||
simpAlbum, simpAlbumArtist);
|
||||
effectiveFile = remuxed;
|
||||
} catch (Exception e) {
|
||||
convFailed.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "ConversionFailed", fileName);
|
||||
log.warn("后备 remux 失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:conversion-failed";
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 音频完整性验证:确保有效文件可被 FFprobe 识别且 FFmpeg 可完整解码
|
||||
boolean derivedFile = !effectiveFile.equals(srcFile);
|
||||
|
||||
// 6. 音频完整性验证:确保有效文件可被 FFprobe 识别且 FFmpeg 可完整解码
|
||||
if (audioValidationService != null) {
|
||||
ValidationResult vr = audioValidationService.validate(effectiveFile);
|
||||
if (!vr.isValid()) {
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
@@ -428,50 +476,84 @@ public class IngestService {
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 重新打开有效文件,重新检查 Title/Artist/Album
|
||||
// (确保转换过程或标签持久化未导致 Navidrome 必需的元数据丢失)
|
||||
// 7. 重新检查有效文件的 Title/Artist/Album(确保转换/remux/标签持久化未丢失必需元数据)
|
||||
if (audioValidationService != null) {
|
||||
try {
|
||||
AudioFile validatedAudio = AudioFileIO.read(effectiveFile.toFile());
|
||||
Tag validatedTag = validatedAudio.getTag();
|
||||
String vTitle = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.TITLE) : "");
|
||||
String vArtist = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.ARTIST) : "");
|
||||
String vAlbum = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.ALBUM) : "");
|
||||
if (usedFallback) {
|
||||
// 后备路径:jaudiotagger 仍无法解析该容器,改用 FFprobe 再次读取校验
|
||||
AudioValidationService.ProbeMetadata reMeta =
|
||||
audioValidationService.readMetadata(effectiveFile);
|
||||
String vTitle = reMeta.isAvailable() ? reMeta.getFirst("title") : "";
|
||||
String vArtist = reMeta.isAvailable() ? reMeta.getFirst("artist") : "";
|
||||
String vAlbum = reMeta.isAvailable() ? reMeta.getFirst("album") : "";
|
||||
if (vTitle.isEmpty() || vArtist.isEmpty() || vAlbum.isEmpty()) {
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("完整性验证后元数据缺失: {}(Title='{}' Artist='{}' Album='{}')",
|
||||
log.warn("后备 remux 后元数据缺失: {}(Title='{}' Artist='{}' Album='{}')",
|
||||
fileName, vTitle, vArtist, vAlbum);
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
// 使用有效文件的标签更新后续处理引用
|
||||
tag = validatedTag;
|
||||
title = vTitle;
|
||||
artist = vArtist;
|
||||
album = vAlbum;
|
||||
albumArtist = trim(validatedTag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||
yearStr = safeGetFirst(validatedTag, FieldKey.YEAR);
|
||||
trackRaw = safeGetFirst(validatedTag, FieldKey.TRACK);
|
||||
discRaw = safeGetFirst(validatedTag, FieldKey.DISC_NO);
|
||||
albumArtist = reMeta.getFirst("album_artist", "albumartist");
|
||||
yearStr = reMeta.getFirst("date", "year");
|
||||
trackRaw = reMeta.getFirst("track");
|
||||
discRaw = reMeta.getFirst("disc", "discnumber");
|
||||
trackNum = parseInt(trackRaw, 0);
|
||||
discNum = parseInt(discRaw, 0);
|
||||
} catch (Exception e) {
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
deleteIfExists(effectiveFile);
|
||||
} else {
|
||||
try {
|
||||
AudioFile validatedAudio = AudioFileIO.read(effectiveFile.toFile());
|
||||
Tag validatedTag = validatedAudio.getTag();
|
||||
String vTitle = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.TITLE) : "");
|
||||
String vArtist = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.ARTIST) : "");
|
||||
String vAlbum = trim(validatedTag != null ? validatedTag.getFirst(FieldKey.ALBUM) : "");
|
||||
if (vTitle.isEmpty() || vArtist.isEmpty() || vAlbum.isEmpty()) {
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("完整性验证后元数据缺失: {}(Title='{}' Artist='{}' Album='{}')",
|
||||
fileName, vTitle, vArtist, vAlbum);
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
// 使用有效文件的标签更新后续处理引用
|
||||
tag = validatedTag;
|
||||
title = vTitle;
|
||||
artist = vArtist;
|
||||
album = vAlbum;
|
||||
albumArtist = trim(validatedTag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||
yearStr = safeGetFirst(validatedTag, FieldKey.YEAR);
|
||||
trackRaw = safeGetFirst(validatedTag, FieldKey.TRACK);
|
||||
discRaw = safeGetFirst(validatedTag, FieldKey.DISC_NO);
|
||||
trackNum = parseInt(trackRaw, 0);
|
||||
discNum = parseInt(discRaw, 0);
|
||||
} catch (Exception e) {
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("完整性验证后重新读取元数据失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("完整性验证后重新读取元数据失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
}
|
||||
|
||||
// 13. 确定最终格式与文件名
|
||||
String ext = needsConversion ? "flac" : getExtension(fileName);
|
||||
if (ext == null) ext = "flac";
|
||||
// 确定最终格式与文件名(后备 remux 统一输出 m4a 容器)
|
||||
String ext;
|
||||
if (needsConversion) {
|
||||
ext = "flac";
|
||||
} else if (usedFallback) {
|
||||
ext = "m4a";
|
||||
} else {
|
||||
ext = getExtension(fileName);
|
||||
if (ext == null) ext = "flac";
|
||||
}
|
||||
|
||||
// 12. 构建目标路径
|
||||
String effectiveArtist = !albumArtist.isEmpty()
|
||||
@@ -491,40 +573,39 @@ public class IngestService {
|
||||
String destFileName = trackStr + " - " + safeTitle + "." + ext;
|
||||
Path targetFile = resolveUniqueFile(targetDir, destFileName);
|
||||
|
||||
// 13. 移动/复制到目标位置
|
||||
// 8. 移动/复制到目标位置
|
||||
try {
|
||||
FileTransferUtils.moveWithFallback(effectiveFile, targetFile);
|
||||
} catch (IOException e) {
|
||||
otherRejected.incrementAndGet();
|
||||
// 如果经过转换,删除临时 FLAC 并隔离源文件
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
// 如果经过转换/remux,删除派生文件并隔离源文件
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
} else {
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
}
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
log.warn("移动文件到 Library 失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:move-failed";
|
||||
}
|
||||
|
||||
// 14. 如果进行了格式转换且成功,删除原文件
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
// 9. 如果生成了派生文件(转换或后备 remux)且成功,删除原文件
|
||||
if (derivedFile) {
|
||||
deleteIfExists(srcFile);
|
||||
}
|
||||
|
||||
// 15. 更新身份标识集合
|
||||
// 10. 更新身份标识集合
|
||||
libraryIdentities.add(identity);
|
||||
batchIdentities.add(identity);
|
||||
ingested.incrementAndGet();
|
||||
|
||||
// 16. 处理关联 LRC 文件(可选)
|
||||
// 11. 处理关联 LRC 文件(可选)
|
||||
handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
|
||||
|
||||
// 17. 提取嵌入式歌词(可选)写入 LRC
|
||||
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
|
||||
|
||||
// 18. 提取封面(可选)
|
||||
extractCover(tag, targetDir);
|
||||
// 12. 提取嵌入式歌词与封面(可选,仅 jaudiotagger 路径有 tag 对象;
|
||||
// 后备 remux 已保留原始嵌入式封面/歌词于输出容器中)
|
||||
if (tag != null) {
|
||||
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
|
||||
extractCover(tag, targetDir);
|
||||
}
|
||||
|
||||
return "ingested";
|
||||
}
|
||||
@@ -732,30 +813,19 @@ public class IngestService {
|
||||
Path output = outputDir.resolve(baseName + ".flac");
|
||||
output = resolveUniqueFile(outputDir, baseName + ".flac");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
List<String> cmd = new ArrayList<>(Arrays.asList(
|
||||
getFfmpegCommand(),
|
||||
"-y",
|
||||
"-i", input.toAbsolutePath().toString(),
|
||||
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
|
||||
output.toAbsolutePath().toString()
|
||||
);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
boolean finished = p.waitFor(FFMPEG_CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
p.destroyForcibly();
|
||||
deleteIfExists(output);
|
||||
throw new RuntimeException("ffmpeg 转码超时(" + FFMPEG_CONVERT_TIMEOUT_SECONDS + "s)");
|
||||
}
|
||||
int exit = p.exitValue();
|
||||
if (exit != 0) {
|
||||
deleteIfExists(output);
|
||||
throw new RuntimeException("ffmpeg 退出码: " + exit);
|
||||
}
|
||||
));
|
||||
runFfmpegDrained(cmd, FFMPEG_CONVERT_TIMEOUT_SECONDS, output, "转码");
|
||||
return output;
|
||||
}
|
||||
|
||||
private void deleteIfExists(Path path) {
|
||||
if (path == null) return;
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
@@ -763,6 +833,159 @@ 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 容器,避免对其他格式做无意义的后备。
|
||||
*/
|
||||
private boolean isFallbackEligible(Path file) {
|
||||
String ext = getExtension(file.getFileName().toString());
|
||||
return ext != null && FALLBACK_ELIGIBLE_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 FFmpeg 无损 remux({@code -c copy})生成保留音频质量与原有元数据的
|
||||
* Navidrome 兼容 M4A 输出,并写入简体 Title/Artist/Album(及可选 Album Artist)。
|
||||
* <p>用于 jaudiotagger 无法安全写回该容器标签、且不能修改原文件的场景,
|
||||
* 避免因写回失败而误拒绝合法音频。不伪造缺失的必要元数据。</p>
|
||||
* <p>输出为独立临时文件;调用方负责在失败或验证不通过时清理。</p>
|
||||
*
|
||||
* @return 生成的 M4A 输出路径
|
||||
*/
|
||||
List<String> buildRemuxCommand(Path input, Path output, String title, String artist,
|
||||
String album, String albumArtist) {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(getFfmpegCommand());
|
||||
cmd.add("-y");
|
||||
cmd.add("-i");
|
||||
cmd.add(input.toAbsolutePath().toString());
|
||||
// 仅映射预期的第一个音频流,避免把普通视频/字幕/数据等流带入生成的 M4A,
|
||||
// 确保输出是干净的 Navidrome 兼容音乐资产。
|
||||
cmd.add("-map");
|
||||
cmd.add("0:a:0");
|
||||
cmd.add("-c");
|
||||
cmd.add("copy");
|
||||
// 保留原有 format 级元数据(不含未映射的流),随后覆盖简体标签
|
||||
cmd.add("-map_metadata");
|
||||
cmd.add("0");
|
||||
// 覆盖简体标签(不伪造缺失字段:调用方已确保三项必需字段非空)
|
||||
cmd.add("-metadata");
|
||||
cmd.add("title=" + title);
|
||||
cmd.add("-metadata");
|
||||
cmd.add("artist=" + artist);
|
||||
cmd.add("-metadata");
|
||||
cmd.add("album=" + album);
|
||||
if (albumArtist != null && !albumArtist.isEmpty()) {
|
||||
cmd.add("-metadata");
|
||||
cmd.add("album_artist=" + albumArtist);
|
||||
}
|
||||
cmd.add(output.toAbsolutePath().toString());
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private Path remuxWithSimplifiedTags(Path input, String title, String artist,
|
||||
String album, String albumArtist)
|
||||
throws IOException, InterruptedException {
|
||||
String baseName = getBaseName(input.getFileName().toString());
|
||||
Path output = resolveUniqueFile(input.getParent(), baseName + ".fallback.m4a");
|
||||
|
||||
List<String> cmd = buildRemuxCommand(input, output, title, artist, album, albumArtist);
|
||||
runFfmpegDrained(cmd, FFMPEG_CONVERT_TIMEOUT_SECONDS, output, "remux");
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行一个 FFmpeg 子进程,并发排空其合并后的标准输出,避免管道写满导致死锁;
|
||||
* 超时或中断时强制终止并有界回收,失败时清理输出文件。
|
||||
* <p>合并 stderr→stdout 后由守护线程持续读取(仅保留前 {@value #FFMPEG_DIAGNOSTIC_CAP}
|
||||
* 字节作诊断),超出部分丢弃以排空管道。非零退出或超时抛出 {@link RuntimeException}。</p>
|
||||
*
|
||||
* @param command 完整命令行
|
||||
* @param timeoutSeconds 超时秒数
|
||||
* @param outputToClean 失败/超时时需要删除的输出文件(可为 null)
|
||||
* @param label 日志/异常用的操作标签(如 "remux" / "转码")
|
||||
*/
|
||||
private void runFfmpegDrained(List<String> command, int timeoutSeconds,
|
||||
Path outputToClean, String label)
|
||||
throws IOException, InterruptedException {
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
|
||||
final StringBuilder diag = new StringBuilder();
|
||||
Thread drainer = new Thread(() -> {
|
||||
try (InputStream is = p.getInputStream()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = is.read(buf)) != -1) {
|
||||
synchronized (diag) {
|
||||
int remaining = FFMPEG_DIAGNOSTIC_CAP - diag.length();
|
||||
if (remaining > 0) {
|
||||
diag.append(new String(buf, 0, Math.min(n, remaining),
|
||||
StandardCharsets.UTF_8));
|
||||
}
|
||||
// 已达上限后继续读取以排空管道,丢弃多余数据
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// 进程终止后流关闭属正常情况
|
||||
}
|
||||
}, "ffmpeg-" + label + "-drainer");
|
||||
drainer.setDaemon(true);
|
||||
drainer.start();
|
||||
|
||||
boolean interrupted = false;
|
||||
boolean finished;
|
||||
try {
|
||||
finished = p.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
finished = false;
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
p.destroyForcibly();
|
||||
try {
|
||||
p.waitFor(FFMPEG_CLEANUP_WAIT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 先 join drainer 确保输出读尽,再判定结果
|
||||
try {
|
||||
drainer.join(TimeUnit.SECONDS.toMillis(FFMPEG_CLEANUP_WAIT_SECONDS));
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
|
||||
if (interrupted) {
|
||||
deleteIfExists(outputToClean);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new InterruptedException("ffmpeg " + label + " 被中断");
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
deleteIfExists(outputToClean);
|
||||
throw new RuntimeException("ffmpeg " + label + " 超时(" + timeoutSeconds + "s)");
|
||||
}
|
||||
|
||||
int exit = p.exitValue();
|
||||
if (exit != 0) {
|
||||
String tail;
|
||||
synchronized (diag) {
|
||||
tail = diag.toString().trim();
|
||||
}
|
||||
deleteIfExists(outputToClean);
|
||||
throw new RuntimeException("ffmpeg " + label + " 退出码: " + exit
|
||||
+ (tail.isEmpty() ? "" : " - " + tail));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 Library 目录中已有的音频文件,构建身份标识集合
|
||||
* (所有文本字段经过 t2s + lowercase 归一化,与 incoming 文件一致)
|
||||
|
||||
Reference in New Issue
Block a user