Template
Validate audio integrity before ingestion
This commit is contained in:
@@ -2,6 +2,7 @@ package com.music.service;
|
||||
|
||||
import com.music.common.FileTransferUtils;
|
||||
import com.music.dto.ProgressMessage;
|
||||
import com.music.service.AudioValidationService.ValidationResult;
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.AudioFileIO;
|
||||
import org.jaudiotagger.tag.FieldKey;
|
||||
@@ -9,6 +10,7 @@ import org.jaudiotagger.tag.Tag;
|
||||
import org.jaudiotagger.tag.images.Artwork;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -39,7 +41,7 @@ import java.util.concurrent.TimeUnit;
|
||||
* <li>success / ingestedFiles:成功入库文件数</li>
|
||||
* <li>duplicateFiles:跳过重复文件数</li>
|
||||
* <li>missingMetadataFiles:缺少 Title/Artist/Album 的文件数</li>
|
||||
* <li>unreadableFiles:无法读取元数据的文件数</li>
|
||||
* <li>unreadableFiles:无法读取元数据或无法完整解码的文件数</li>
|
||||
* <li>conversionFailedFiles:格式转换失败文件数</li>
|
||||
* <li>failed / otherRejectedFiles:其他原因拒绝文件数</li>
|
||||
* </ul>
|
||||
@@ -82,15 +84,34 @@ public class IngestService {
|
||||
private final ProgressStore progressStore;
|
||||
private final TraditionalFilterService traditionalFilterService;
|
||||
private final ConfigService configService;
|
||||
private final AudioValidationService audioValidationService;
|
||||
|
||||
/**
|
||||
* Spring DI 构造器。
|
||||
* <p>包级 4 参数构造器保留给测试,此时 {@code audioValidationService} 为 null,
|
||||
* 跳过音频完整性验证步骤。</p>
|
||||
*/
|
||||
@Autowired
|
||||
public IngestService(SimpMessagingTemplate messagingTemplate,
|
||||
ProgressStore progressStore,
|
||||
TraditionalFilterService traditionalFilterService,
|
||||
ConfigService configService) {
|
||||
ConfigService configService,
|
||||
AudioValidationService audioValidationService) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.progressStore = progressStore;
|
||||
this.traditionalFilterService = traditionalFilterService;
|
||||
this.configService = configService;
|
||||
this.audioValidationService = audioValidationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 包级构造器(测试用,跳过音频完整性验证)。
|
||||
*/
|
||||
IngestService(SimpMessagingTemplate messagingTemplate,
|
||||
ProgressStore progressStore,
|
||||
TraditionalFilterService traditionalFilterService,
|
||||
ConfigService configService) {
|
||||
this(messagingTemplate, progressStore, traditionalFilterService, configService, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,9 +171,14 @@ public class IngestService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 预检查 FFmpeg(如果需要转换)
|
||||
boolean mayNeedFfmpeg = audioFiles.stream().anyMatch(f -> isLosslessFormat(f));
|
||||
if (mayNeedFfmpeg) {
|
||||
// 预检查 FFprobe 和 FFmpeg(每个新文件都需要完整解码验证)
|
||||
if (!audioFiles.isEmpty()) {
|
||||
String ffprobeError = checkFfprobeAvailable();
|
||||
if (ffprobeError != null) {
|
||||
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||||
null, ffprobeError, true);
|
||||
return;
|
||||
}
|
||||
String ffmpegError = checkFfmpegAvailable();
|
||||
if (ffmpegError != null) {
|
||||
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||||
@@ -345,10 +371,10 @@ public class IngestService {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 提取其他可选字段
|
||||
String yearStr = trim(tag.getFirst(FieldKey.YEAR));
|
||||
String trackRaw = trim(tag.getFirst(FieldKey.TRACK));
|
||||
String discRaw = trim(tag.getFirst(FieldKey.DISC_NO));
|
||||
// 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);
|
||||
@@ -388,7 +414,62 @@ public class IngestService {
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 确定最终格式与文件名
|
||||
// 11. 音频完整性验证:确保有效文件可被 FFprobe 识别且 FFmpeg 可完整解码
|
||||
if (audioValidationService != null) {
|
||||
ValidationResult vr = audioValidationService.validate(effectiveFile);
|
||||
if (!vr.isValid()) {
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("音频完整性验证失败: {} - {}", fileName, vr.getDiagnostic());
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 重新打开有效文件,重新检查 Title/Artist/Album
|
||||
// (确保转换过程或标签持久化未导致 Navidrome 必需的元数据丢失)
|
||||
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 (vTitle.isEmpty() || vArtist.isEmpty() || vAlbum.isEmpty()) {
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
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 (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -707,8 +788,8 @@ public class IngestService {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
int trackNum = parseInt(trim(tag.getFirst(FieldKey.TRACK)), 0);
|
||||
int discNum = parseInt(trim(tag.getFirst(FieldKey.DISC_NO)), 0);
|
||||
int trackNum = parseInt(safeGetFirst(tag, FieldKey.TRACK), 0);
|
||||
int discNum = parseInt(safeGetFirst(tag, FieldKey.DISC_NO), 0);
|
||||
|
||||
String md5 = computeMd5(file);
|
||||
identities.add(new IdentityKey(
|
||||
@@ -759,6 +840,18 @@ public class IngestService {
|
||||
return s == null ? "" : s.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全读取标签字段,对于不支持该字段的标签实现(如 WavTag 不支持 TRACK/DISC_NO/YEAR)
|
||||
* 返回空字符串而非抛出 {@link UnsupportedOperationException}。
|
||||
*/
|
||||
private String safeGetFirst(Tag tag, FieldKey key) {
|
||||
try {
|
||||
return trim(tag.getFirst(key));
|
||||
} catch (UnsupportedOperationException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private int parseInt(String s, int defaultValue) {
|
||||
if (s == null || s.trim().isEmpty()) return defaultValue;
|
||||
try {
|
||||
@@ -875,6 +968,30 @@ public class IngestService {
|
||||
}
|
||||
}
|
||||
|
||||
private String checkFfprobeAvailable() {
|
||||
String ffprobeCmd = audioValidationService != null
|
||||
? audioValidationService.getFfprobeCommand() : "ffprobe";
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(ffprobeCmd, "-version");
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
boolean finished = p.waitFor(FFMPEG_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
p.destroyForcibly();
|
||||
return "ffprobe 预检查超时,请检查环境配置";
|
||||
}
|
||||
if (p.exitValue() != 0) {
|
||||
return "ffprobe 不可用,请确认已正确安装并加入 PATH";
|
||||
}
|
||||
return null;
|
||||
} catch (IOException e) {
|
||||
return "ffprobe 不可用,请确认已正确安装并加入 PATH";
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return "ffprobe 预检查被中断";
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 进度消息 ==========
|
||||
|
||||
private void sendProgress(String taskId, int total, int processed,
|
||||
|
||||
Reference in New Issue
Block a user