Template
Validate audio integrity before ingestion
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
package com.music.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 音频完整性验证服务。
|
||||
* <p>使用 FFprobe 探测文件是否包含可识别的音频流,再用 FFmpeg 以严格模式
|
||||
* ({@code -xerror -map 0:a:0}) 完整解码到 null muxer,检测截断/损坏等问题。
|
||||
* 验证通过的音频文件保证可被 Navidrome 识别和播放。</p>
|
||||
*
|
||||
* <p>可执行路径与超时通过系统属性配置,与 {@link IngestService} 和 {@link ConvertService}
|
||||
* 的 ffmpeg 属性模式一致:</p>
|
||||
* <ul>
|
||||
* <li>{@code mangtool.ffprobe.bin} — FFprobe 可执行路径(默认 {@code ffprobe})</li>
|
||||
* <li>{@code mangtool.ffmpeg.bin} — FFmpeg 可执行路径(默认 {@code ffmpeg})</li>
|
||||
* <li>{@code mangtool.validation.probe.timeout} — 探测超时秒数(默认 30,必须 > 0)</li>
|
||||
* <li>{@code mangtool.validation.decode.timeout} — 解码超时秒数(默认 300,必须 > 0)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>运行时输出通过守护线程并发读取并限制大小({@value #MAX_DIAGNOSTIC_LENGTH} 字节),
|
||||
* 防止被长时间或无限输出的子进程阻塞。超时后强制终止子进程并调用 {@code waitFor()} 回收。</p>
|
||||
*/
|
||||
@Component
|
||||
public class AudioValidationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AudioValidationService.class);
|
||||
|
||||
/** 系统属性键:FFprobe 可执行路径 */
|
||||
static final String FFPROBE_BIN_PROPERTY = "mangtool.ffprobe.bin";
|
||||
/** 系统属性键:FFmpeg 可执行路径(复用现有属性) */
|
||||
static final String FFMPEG_BIN_PROPERTY = "mangtool.ffmpeg.bin";
|
||||
/** 系统属性键:探测超时秒数 */
|
||||
static final String PROBE_TIMEOUT_PROPERTY = "mangtool.validation.probe.timeout";
|
||||
/** 系统属性键:解码超时秒数 */
|
||||
static final String DECODE_TIMEOUT_PROPERTY = "mangtool.validation.decode.timeout";
|
||||
|
||||
/** 探测超时默认值(秒) */
|
||||
static final int DEFAULT_PROBE_TIMEOUT_SECONDS = 30;
|
||||
/** 解码超时默认值(秒) */
|
||||
static final int DEFAULT_DECODE_TIMEOUT_SECONDS = 300;
|
||||
/** 保留诊断信息最大长度 */
|
||||
static final int MAX_DIAGNOSTIC_LENGTH = 512;
|
||||
/** 流读取缓冲区大小 */
|
||||
private static final int BUFFER_SIZE = 4096;
|
||||
/** 回收子进程 / 合并 drainer 线程的最长等待秒数 */
|
||||
private static final int CLEANUP_WAIT_SECONDS = 5;
|
||||
|
||||
// ========== 嵌套结果类 ==========
|
||||
|
||||
/**
|
||||
* 验证结果。{@link #isValid()} 返回 {@code true} 表示文件可通过完整解码验证。
|
||||
*/
|
||||
public static class ValidationResult {
|
||||
private final boolean valid;
|
||||
private final String diagnostic;
|
||||
|
||||
ValidationResult(boolean valid, String diagnostic) {
|
||||
this.valid = valid;
|
||||
String raw = diagnostic != null ? diagnostic : "";
|
||||
this.diagnostic = raw.length() > MAX_DIAGNOSTIC_LENGTH
|
||||
? raw.substring(0, MAX_DIAGNOSTIC_LENGTH)
|
||||
: raw;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
public String getDiagnostic() {
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
static ValidationResult valid() {
|
||||
return new ValidationResult(true, "");
|
||||
}
|
||||
|
||||
static ValidationResult invalid(String diagnostic) {
|
||||
return new ValidationResult(false, diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 嵌套进程运行结果 ==========
|
||||
|
||||
/**
|
||||
* {@link #runProcess(List, int)} 的返回结果。
|
||||
*/
|
||||
private static class ProcessResult {
|
||||
final boolean timedOut;
|
||||
final int exitCode;
|
||||
final String output;
|
||||
|
||||
ProcessResult(boolean timedOut, int exitCode, String output) {
|
||||
this.timedOut = timedOut;
|
||||
this.exitCode = exitCode;
|
||||
this.output = output;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 嵌套并发 Drainer ==========
|
||||
|
||||
/**
|
||||
* 守护线程读取子进程标准输出 / 错误,并限制累积大小。
|
||||
*/
|
||||
private static class DrainResult {
|
||||
final Thread thread;
|
||||
final ByteArrayOutputStream buffer;
|
||||
|
||||
DrainResult(Thread thread, ByteArrayOutputStream buffer) {
|
||||
this.thread = thread;
|
||||
this.buffer = buffer;
|
||||
}
|
||||
|
||||
String getOutput() {
|
||||
return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 公开 API ==========
|
||||
|
||||
/**
|
||||
* 验证音频文件完整性。
|
||||
* <ol>
|
||||
* <li>运行 FFprobe 确认至少有一个可识别的音频流;</li>
|
||||
* <li>运行 FFmpeg 以严格模式({@code -xerror -map 0:a:0})完整解码到 null muxer。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param audioFile 待验证的音频文件路径
|
||||
* @return 验证结果
|
||||
*/
|
||||
public ValidationResult validate(Path audioFile) {
|
||||
ValidationResult probeResult = runProbe(audioFile);
|
||||
if (!probeResult.isValid()) {
|
||||
return probeResult;
|
||||
}
|
||||
|
||||
return runDecode(audioFile);
|
||||
}
|
||||
|
||||
// ========== 探测(FFprobe) ==========
|
||||
|
||||
/**
|
||||
* 返回 FFprobe 探测命令参数列表(用于测试观察)。
|
||||
*/
|
||||
List<String> getProbeCommandArgs(Path audioFile) {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(getFfprobeCommand());
|
||||
cmd.add("-v");
|
||||
cmd.add("error");
|
||||
cmd.add("-show_entries");
|
||||
cmd.add("stream=codec_type");
|
||||
cmd.add("-of");
|
||||
cmd.add("default=noprint_wrappers=1:nokey=1");
|
||||
cmd.add(audioFile.toAbsolutePath().toString());
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 FFprobe 要求至少一个可识别的音频流。
|
||||
*/
|
||||
private ValidationResult runProbe(Path audioFile) {
|
||||
int timeout = resolveProbeTimeout();
|
||||
try {
|
||||
ProcessResult result = runProcess(getProbeCommandArgs(audioFile), timeout);
|
||||
if (result.timedOut) {
|
||||
log.warn("FFprobe 探测超时: {} ({}s)", audioFile, timeout);
|
||||
return ValidationResult.invalid("音频探测超时");
|
||||
}
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
String diag = capDiagnostic(result.output);
|
||||
log.warn("FFprobe 探测失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag);
|
||||
return ValidationResult.invalid("FFprobe 探测失败: " + diag);
|
||||
}
|
||||
|
||||
if (!result.output.contains("audio")) {
|
||||
log.warn("FFprobe 未发现音频流: {}", audioFile);
|
||||
return ValidationResult.invalid("文件中未发现可识别的音频流");
|
||||
}
|
||||
|
||||
return ValidationResult.valid();
|
||||
} catch (IOException e) {
|
||||
log.warn("FFprobe 执行失败: {} - {}", audioFile, e.getMessage());
|
||||
return ValidationResult.invalid("FFprobe 执行失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 解码(FFmpeg,严格模式) ==========
|
||||
|
||||
/**
|
||||
* 运行 FFmpeg 以 error 日志级别 + 严格模式完整解码到 null muxer。
|
||||
* <ul>
|
||||
* <li>{@code -xerror}:将大多数日志错误视为致命错误(非零退出);</li>
|
||||
* <li>{@code -map 0:a:0}:仅解码第一个输入的第一个音频流,避免干扰;</li>
|
||||
* <li>即使 {@code -xerror} 下退出码为 0,若 error 级别输出非空也视为失败
|
||||
* (FFmpeg 6.1.1 实验发现某些解码错误不会触发非零退出)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private ValidationResult runDecode(Path audioFile) {
|
||||
int timeout = resolveDecodeTimeout();
|
||||
try {
|
||||
ProcessResult result = runProcess(getDecodeCommandArgs(audioFile), timeout);
|
||||
if (result.timedOut) {
|
||||
log.warn("FFmpeg 解码超时: {} ({}s)", audioFile, timeout);
|
||||
return ValidationResult.invalid("音频解码超时");
|
||||
}
|
||||
|
||||
// 同时检查退出码和 error 级别输出:
|
||||
// -xerror 下 exitCode != 0 表示 fatal 错误
|
||||
// 但某些 FFmpeg 版本(如 6.1.1)的部分解码错误仅输出到 stderr 而不改变退出码
|
||||
String trimmedOutput = result.output.trim();
|
||||
if (result.exitCode != 0 || !trimmedOutput.isEmpty()) {
|
||||
String diag = capDiagnostic(result.output);
|
||||
log.warn("FFmpeg 解码失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag);
|
||||
return ValidationResult.invalid("音频解码失败: " + diag);
|
||||
}
|
||||
|
||||
return ValidationResult.valid();
|
||||
} catch (IOException e) {
|
||||
log.warn("FFmpeg 执行失败: {} - {}", audioFile, e.getMessage());
|
||||
return ValidationResult.invalid("FFmpeg 执行失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 FFmpeg 解码命令参数列表(用于测试观察)。
|
||||
*/
|
||||
List<String> getDecodeCommandArgs(Path audioFile) {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(getFfmpegCommand());
|
||||
cmd.add("-xerror");
|
||||
cmd.add("-v");
|
||||
cmd.add("error");
|
||||
cmd.add("-i");
|
||||
cmd.add(audioFile.toAbsolutePath().toString());
|
||||
cmd.add("-map");
|
||||
cmd.add("0:a:0");
|
||||
cmd.add("-f");
|
||||
cmd.add("null");
|
||||
cmd.add("-");
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ========== 通用进程运行 ==========
|
||||
|
||||
/**
|
||||
* 运行一个外部进程,并发读取输出,超时处理。
|
||||
* <p>超时后自动 {@link Process#destroyForcibly()} 并以有界等待回收。
|
||||
* drainer 线程为守护线程,保证不阻塞 JVM 退出。
|
||||
* 调用线程的中断不会泄漏子进程:强制终止、有界回收、先 join drainer 确保输出被读取、再关闭流。</p>
|
||||
*/
|
||||
private static ProcessResult runProcess(List<String> command, int timeoutSeconds)
|
||||
throws IOException {
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
DrainResult drain = startConcurrentDrainer(p);
|
||||
|
||||
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(CLEANUP_WAIT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 先 join drainer 线程确保所有输出已读取完毕,再关闭流。
|
||||
// 顺序不能反:若先关闭流,drainer 可能尚未读取完输出,
|
||||
// 导致 fast-exit 子进程的 error 诊断丢失(FFmpeg 6.1.1 等)。
|
||||
try {
|
||||
drain.thread.join(TimeUnit.SECONDS.toMillis(CLEANUP_WAIT_SECONDS));
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
|
||||
// drainer 结束后安全关闭子进程输出流
|
||||
try {
|
||||
p.getInputStream().close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
return new ProcessResult(true, -1, drain.getOutput());
|
||||
}
|
||||
return new ProcessResult(false, p.exitValue(), drain.getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动守护线程并发读取进程标准输出(合并后),保留前 {@link #MAX_DIAGNOSTIC_LENGTH}
|
||||
* 字节为诊断信息,继续读取剩余数据以排空管道,防止子进程因管道写满而阻塞或收到 SIGPIPE。
|
||||
*/
|
||||
private static DrainResult startConcurrentDrainer(Process p) {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream(MAX_DIAGNOSTIC_LENGTH);
|
||||
Thread t = new Thread(() -> {
|
||||
try {
|
||||
byte[] buf = new byte[BUFFER_SIZE];
|
||||
int n;
|
||||
InputStream is = p.getInputStream();
|
||||
while ((n = is.read(buf)) != -1) {
|
||||
if (buffer.size() < MAX_DIAGNOSTIC_LENGTH) {
|
||||
int remaining = MAX_DIAGNOSTIC_LENGTH - buffer.size();
|
||||
buffer.write(buf, 0, Math.min(n, remaining));
|
||||
}
|
||||
// buffer 已满后继续读取以排空管道,丢弃多余数据
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// 进程终止后正常关闭
|
||||
}
|
||||
}, "audio-validation-drainer");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
return new DrainResult(t, buffer);
|
||||
}
|
||||
|
||||
// ========== 可执行路径解析 ==========
|
||||
|
||||
/**
|
||||
* 获取 FFprobe 可执行路径(可被系统属性 {@code mangtool.ffprobe.bin} 覆盖)。
|
||||
*/
|
||||
String getFfprobeCommand() {
|
||||
String configured = System.getProperty(FFPROBE_BIN_PROPERTY);
|
||||
if (configured == null || configured.trim().isEmpty()) {
|
||||
return "ffprobe";
|
||||
}
|
||||
return configured.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 FFmpeg 可执行路径(可被系统属性 {@code mangtool.ffmpeg.bin} 覆盖)。
|
||||
*/
|
||||
String getFfmpegCommand() {
|
||||
String configured = System.getProperty(FFMPEG_BIN_PROPERTY);
|
||||
if (configured == null || configured.trim().isEmpty()) {
|
||||
return "ffmpeg";
|
||||
}
|
||||
return configured.trim();
|
||||
}
|
||||
|
||||
// ========== 超时解析 ==========
|
||||
|
||||
/**
|
||||
* 解析探测超时配置(秒),不可解析或 ≤ 0 时返回默认值。
|
||||
*/
|
||||
int resolveProbeTimeout() {
|
||||
return parseIntProperty(PROBE_TIMEOUT_PROPERTY, DEFAULT_PROBE_TIMEOUT_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析解码超时配置(秒),不可解析或 ≤ 0 时返回默认值。
|
||||
*/
|
||||
int resolveDecodeTimeout() {
|
||||
return parseIntProperty(DECODE_TIMEOUT_PROPERTY, DEFAULT_DECODE_TIMEOUT_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析整数系统属性,仅返回正数;否则返回默认值。
|
||||
*/
|
||||
private static int parseIntProperty(String key, int defaultValue) {
|
||||
String val = System.getProperty(key);
|
||||
if (val != null) {
|
||||
try {
|
||||
int parsed = Integer.parseInt(val.trim());
|
||||
if (parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// ========== 工具方法 ==========
|
||||
|
||||
/**
|
||||
* 截断诊断信息到最大长度。
|
||||
*/
|
||||
private static String capDiagnostic(String s) {
|
||||
if (s == null || s.isEmpty()) return "";
|
||||
return s.length() > MAX_DIAGNOSTIC_LENGTH
|
||||
? s.substring(0, MAX_DIAGNOSTIC_LENGTH)
|
||||
: s;
|
||||
}
|
||||
}
|
||||
@@ -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