From 761956f047041fd7b007defb24dcbda67551e2ee Mon Sep 17 00:00:00 2001
From: mangmang <362165265@qq.com>
Date: Sun, 19 Jul 2026 11:50:49 +0800
Subject: [PATCH] Validate audio integrity before ingestion
---
.../music/service/AudioValidationService.java | 409 +++++++++++++++
.../java/com/music/service/IngestService.java | 141 ++++-
.../service/AudioValidationServiceTest.java | 492 ++++++++++++++++++
.../music/service/IngestServiceE2ETest.java | 406 +++++++++++++++
.../service/IngestServiceInternalTest.java | 16 +
docker/README.md | 4 +-
6 files changed, 1454 insertions(+), 14 deletions(-)
create mode 100644 backend/src/main/java/com/music/service/AudioValidationService.java
create mode 100644 backend/src/test/java/com/music/service/AudioValidationServiceTest.java
diff --git a/backend/src/main/java/com/music/service/AudioValidationService.java b/backend/src/main/java/com/music/service/AudioValidationService.java
new file mode 100644
index 0000000..75bcc34
--- /dev/null
+++ b/backend/src/main/java/com/music/service/AudioValidationService.java
@@ -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;
+
+/**
+ * 音频完整性验证服务。
+ *
使用 FFprobe 探测文件是否包含可识别的音频流,再用 FFmpeg 以严格模式
+ * ({@code -xerror -map 0:a:0}) 完整解码到 null muxer,检测截断/损坏等问题。
+ * 验证通过的音频文件保证可被 Navidrome 识别和播放。
+ *
+ * 可执行路径与超时通过系统属性配置,与 {@link IngestService} 和 {@link ConvertService}
+ * 的 ffmpeg 属性模式一致:
+ *
+ * {@code mangtool.ffprobe.bin} — FFprobe 可执行路径(默认 {@code ffprobe})
+ * {@code mangtool.ffmpeg.bin} — FFmpeg 可执行路径(默认 {@code ffmpeg})
+ * {@code mangtool.validation.probe.timeout} — 探测超时秒数(默认 30,必须 > 0)
+ * {@code mangtool.validation.decode.timeout} — 解码超时秒数(默认 300,必须 > 0)
+ *
+ *
+ * 运行时输出通过守护线程并发读取并限制大小({@value #MAX_DIAGNOSTIC_LENGTH} 字节),
+ * 防止被长时间或无限输出的子进程阻塞。超时后强制终止子进程并调用 {@code waitFor()} 回收。
+ */
+@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 ==========
+
+ /**
+ * 验证音频文件完整性。
+ *
+ * 运行 FFprobe 确认至少有一个可识别的音频流;
+ * 运行 FFmpeg 以严格模式({@code -xerror -map 0:a:0})完整解码到 null muxer。
+ *
+ *
+ * @param audioFile 待验证的音频文件路径
+ * @return 验证结果
+ */
+ public ValidationResult validate(Path audioFile) {
+ ValidationResult probeResult = runProbe(audioFile);
+ if (!probeResult.isValid()) {
+ return probeResult;
+ }
+
+ return runDecode(audioFile);
+ }
+
+ // ========== 探测(FFprobe) ==========
+
+ /**
+ * 返回 FFprobe 探测命令参数列表(用于测试观察)。
+ */
+ List getProbeCommandArgs(Path audioFile) {
+ List 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。
+ *
+ * {@code -xerror}:将大多数日志错误视为致命错误(非零退出);
+ * {@code -map 0:a:0}:仅解码第一个输入的第一个音频流,避免干扰;
+ * 即使 {@code -xerror} 下退出码为 0,若 error 级别输出非空也视为失败
+ * (FFmpeg 6.1.1 实验发现某些解码错误不会触发非零退出)。
+ *
+ */
+ 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 getDecodeCommandArgs(Path audioFile) {
+ List 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;
+ }
+
+ // ========== 通用进程运行 ==========
+
+ /**
+ * 运行一个外部进程,并发读取输出,超时处理。
+ * 超时后自动 {@link Process#destroyForcibly()} 并以有界等待回收。
+ * drainer 线程为守护线程,保证不阻塞 JVM 退出。
+ * 调用线程的中断不会泄漏子进程:强制终止、有界回收、先 join drainer 确保输出被读取、再关闭流。
+ */
+ private static ProcessResult runProcess(List 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;
+ }
+}
diff --git a/backend/src/main/java/com/music/service/IngestService.java b/backend/src/main/java/com/music/service/IngestService.java
index ecaa9fe..721d87c 100644
--- a/backend/src/main/java/com/music/service/IngestService.java
+++ b/backend/src/main/java/com/music/service/IngestService.java
@@ -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;
* success / ingestedFiles:成功入库文件数
* duplicateFiles:跳过重复文件数
* missingMetadataFiles:缺少 Title/Artist/Album 的文件数
- * unreadableFiles:无法读取元数据的文件数
+ * unreadableFiles:无法读取元数据或无法完整解码的文件数
* conversionFailedFiles:格式转换失败文件数
* failed / otherRejectedFiles:其他原因拒绝文件数
*
@@ -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 构造器。
+ * 包级 4 参数构造器保留给测试,此时 {@code audioValidationService} 为 null,
+ * 跳过音频完整性验证步骤。
+ */
+ @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,
diff --git a/backend/src/test/java/com/music/service/AudioValidationServiceTest.java b/backend/src/test/java/com/music/service/AudioValidationServiceTest.java
new file mode 100644
index 0000000..911ff73
--- /dev/null
+++ b/backend/src/test/java/com/music/service/AudioValidationServiceTest.java
@@ -0,0 +1,492 @@
+package com.music.service;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * {@link AudioValidationService} 的单元与集成测试。
+ *
+ * 依赖 FFprobe/FFmpeg 的测试通过 {@link #assumeFfmpegFfprobe()} 守卫,
+ * 工具不可用时自动跳过。
+ */
+class AudioValidationServiceTest {
+
+ private static boolean FFMPEG_AVAILABLE;
+
+ private final AudioValidationService service = new AudioValidationService();
+
+ @BeforeAll
+ static void checkFfmpegFfprobe() throws Exception {
+ try {
+ ProcessBuilder pb = new ProcessBuilder("ffmpeg", "-version");
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+ boolean ok = p.waitFor(5, TimeUnit.SECONDS);
+ FFMPEG_AVAILABLE = ok && p.exitValue() == 0;
+ if (!ok) p.destroyForcibly();
+ } catch (Exception e) {
+ FFMPEG_AVAILABLE = false;
+ }
+ if (FFMPEG_AVAILABLE) {
+ // ffprobe 通常随 ffmpeg 一起安装
+ try {
+ ProcessBuilder pb = new ProcessBuilder("ffprobe", "-version");
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+ boolean ok = p.waitFor(5, TimeUnit.SECONDS);
+ FFMPEG_AVAILABLE = ok && p.exitValue() == 0;
+ if (!ok) p.destroyForcibly();
+ } catch (Exception e) {
+ FFMPEG_AVAILABLE = false;
+ }
+ }
+ }
+
+ private static void assumeFfmpegFfprobe() {
+ assumeTrue(FFMPEG_AVAILABLE, "此测试需要 ffmpeg 和 ffprobe");
+ }
+
+ @AfterEach
+ void clearProperties() {
+ System.clearProperty(AudioValidationService.FFPROBE_BIN_PROPERTY);
+ System.clearProperty(AudioValidationService.FFMPEG_BIN_PROPERTY);
+ System.clearProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY);
+ System.clearProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY);
+ }
+
+ // ========== 可执行路径解析 ==========
+
+ @Test
+ void getFfprobeCommand_defaultIsFfprobe() {
+ assertEquals("ffprobe", service.getFfprobeCommand());
+ }
+
+ @Test
+ void getFfprobeCommand_respectsSystemProperty() {
+ System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/custom/ffprobe");
+ assertEquals("/custom/ffprobe", service.getFfprobeCommand());
+ }
+
+ @Test
+ void getFfmpegCommand_defaultIsFfmpeg() {
+ assertEquals("ffmpeg", service.getFfmpegCommand());
+ }
+
+ @Test
+ void getFfmpegCommand_respectsSystemProperty() {
+ System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/custom/ffmpeg");
+ assertEquals("/custom/ffmpeg", service.getFfmpegCommand());
+ }
+
+ // ========== 超时解析 ==========
+
+ @Test
+ void resolveProbeTimeout_default() {
+ assertEquals(30, service.resolveProbeTimeout());
+ }
+
+ @Test
+ void resolveProbeTimeout_respectsSystemProperty() {
+ System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "15");
+ assertEquals(15, service.resolveProbeTimeout());
+ }
+
+ @Test
+ void resolveProbeTimeout_ignoresInvalidProperty() {
+ System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "not-a-number");
+ assertEquals(30, service.resolveProbeTimeout());
+ }
+
+ @Test
+ void resolveDecodeTimeout_default() {
+ assertEquals(300, service.resolveDecodeTimeout());
+ }
+
+ @Test
+ void resolveDecodeTimeout_respectsSystemProperty() {
+ System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "60");
+ assertEquals(60, service.resolveDecodeTimeout());
+ }
+
+ @Test
+ void resolveDecodeTimeout_ignoresInvalidProperty() {
+ System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "");
+ assertEquals(300, service.resolveDecodeTimeout());
+ }
+
+ // ========== 超时解析(零值/负值回退) ==========
+
+ @Test
+ void resolveProbeTimeout_negativeFallsBackToDefault() {
+ System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "-1");
+ assertEquals(30, service.resolveProbeTimeout());
+ }
+
+ @Test
+ void resolveProbeTimeout_zeroFallsBackToDefault() {
+ System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "0");
+ assertEquals(30, service.resolveProbeTimeout());
+ }
+
+ @Test
+ void resolveDecodeTimeout_negativeFallsBackToDefault() {
+ System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "-5");
+ assertEquals(300, service.resolveDecodeTimeout());
+ }
+
+ @Test
+ void resolveDecodeTimeout_zeroFallsBackToDefault() {
+ System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "0");
+ assertEquals(300, service.resolveDecodeTimeout());
+ }
+
+ // ========== ValidationResult ==========
+
+ @Test
+ void validationResult_diagnosticCappedAtMaxLength() {
+ String longDiag = new String(new char[600]).replace('\0', 'X');
+ AudioValidationService.ValidationResult r =
+ new AudioValidationService.ValidationResult(false, longDiag);
+ assertEquals(512, r.getDiagnostic().length());
+ assertFalse(r.isValid());
+ }
+
+ @Test
+ void validationResult_nullDiagnosticBecomesEmpty() {
+ AudioValidationService.ValidationResult r =
+ new AudioValidationService.ValidationResult(true, null);
+ assertTrue(r.isValid());
+ assertEquals("", r.getDiagnostic());
+ }
+
+ // ========== 集成测试(需要 FFmpeg + FFprobe) ==========
+
+ @Test
+ void validMp3_passesValidation() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ assertTrue(r.isValid(), "有效 MP3 应通过验证,诊断: " + r.getDiagnostic());
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void nonAudioFile_failsProbe() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path garbage = tmpDir.resolve("notaudio.mp3");
+ Files.write(garbage, new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9});
+ AudioValidationService.ValidationResult r = service.validate(garbage);
+ assertFalse(r.isValid(), "非音频文件应验证失败");
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void decode_failsWhenFfmpegExitsNonZero() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+ // 使用 /bin/false 模拟 ffmpeg 解码失败(ffprobe 使用真实工具先通过探测)
+ System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/bin/false");
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ assertFalse(r.isValid(), "ffmpeg 退出码非零时应验证失败");
+ assertTrue(r.getDiagnostic().contains("音频解码失败"),
+ "诊断信息应包含解码失败提示,实际: " + r.getDiagnostic());
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void decode_failsWhenOutputNonEmptyEvenIfExitZero() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+ // 创建一个模拟脚本:输出错误诊断到 stderr 但退出码为 0
+ // 这模拟了 FFmpeg 6.1.1 中部分解码错误(如 invalid residual)
+ // 仅输出到 error 级别而不设置非零退出码的情况
+ Path fakeFfmpeg = tmpDir.resolve("fake_ffmpeg.sh");
+ Files.write(fakeFfmpeg, Arrays.asList(
+ "#!/bin/sh",
+ "echo 'decoder error: invalid residual' >&2",
+ "exit 0"
+ ));
+ fakeFfmpeg.toFile().setExecutable(true);
+
+ System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, fakeFfmpeg.toString());
+
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ assertFalse(r.isValid(),
+ "ffmpeg 退出码 0 但有 error 输出时应验证失败");
+ assertTrue(r.getDiagnostic().contains("音频解码失败"),
+ "诊断信息应包含解码失败提示,实际: " + r.getDiagnostic());
+ assertTrue(r.getDiagnostic().contains("invalid residual"),
+ "诊断信息应包含原始的 error 输出,实际: " + r.getDiagnostic());
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void truncatedFile_failsProbe() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "trunc_test.mp3");
+ // 截断到 100 字节,保留 ID3v2 标签头但音频数据已不完整
+ long truncatedSize = Math.min(Files.size(mp3), 100);
+ try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(mp3.toFile(), "rw")) {
+ raf.setLength(truncatedSize);
+ }
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ assertFalse(r.isValid(), "截断的文件应验证失败");
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void missingExecutable_failsGracefully() throws Exception {
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+ // 使用不存在的 ffprobe 路径
+ System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/nonexistent/ffprobe");
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ assertFalse(r.isValid(), "ffprobe 不可用时验证应失败");
+ assertTrue(r.getDiagnostic().contains("FFprobe 执行失败"));
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void missingFfmpeg_failsDecodeGracefully() throws Exception {
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+ // 使用不存在的 ffmpeg 路径
+ System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/nonexistent/ffmpeg");
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ assertFalse(r.isValid(), "ffmpeg 不可用时验证应失败");
+ assertTrue(r.getDiagnostic().contains("FFmpeg 执行失败"));
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ // ========== 超时集成测试(需要 FFmpeg + FFprobe) ==========
+
+ @Test
+ void probe_timesOut() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+
+ // 创建一个忽略参数并 sleep 30s 的挂起脚本
+ Path hangScript = tmpDir.resolve("hang.sh");
+ Files.write(hangScript, Arrays.asList("#!/bin/sh", "sleep 30"));
+ hangScript.toFile().setExecutable(true);
+
+ System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, hangScript.toString());
+ System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "1");
+
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ assertFalse(r.isValid(), "探测超时应验证失败");
+ assertTrue(r.getDiagnostic().contains("音频探测超时"),
+ "诊断应提示超时,实际: " + r.getDiagnostic());
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void decode_timesOut() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+
+ // 创建一个忽略参数并 sleep 30s 的挂起脚本
+ Path hangScript = tmpDir.resolve("hang.sh");
+ Files.write(hangScript, Arrays.asList("#!/bin/sh", "sleep 30"));
+ hangScript.toFile().setExecutable(true);
+
+ System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, hangScript.toString());
+ System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "1");
+
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ assertFalse(r.isValid(), "解码超时应验证失败");
+ assertTrue(r.getDiagnostic().contains("音频解码超时"),
+ "诊断应提示解码超时,实际: " + r.getDiagnostic());
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void decodeCommand_usesXerrorAndMapArgs() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+ List args = service.getDecodeCommandArgs(mp3);
+ assertTrue(args.contains("-xerror"),
+ "decode 命令应包含 -xerror,实际参数: " + args);
+ assertTrue(args.contains("-map"),
+ "decode 命令应包含 -map,实际参数: " + args);
+ int mapIndex = args.indexOf("-map");
+ assertTrue(mapIndex >= 0 && mapIndex + 1 < args.size(),
+ "-map 参数后应有值");
+ assertEquals("0:a:0", args.get(mapIndex + 1),
+ "-map 参数应指定 0:a:0");
+ assertTrue(args.contains("-f"),
+ "decode 命令应包含 -f");
+ assertTrue(args.contains("null"),
+ "解码 muxer 应为 null");
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ // ========== 排空测试(大量输出不阻塞) ==========
+
+ @Test
+ void drainer_doesNotDeadlockWithVerboseOutput() throws Exception {
+ assumeFfmpegFfprobe();
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ // 创建一个输出远大于操作系统管道缓冲区(通常 64KB)的脚本
+ Path verboseScript = tmpDir.resolve("verbose.sh");
+ Files.write(verboseScript, Arrays.asList(
+ "#!/bin/sh",
+ "# 输出 ~200KB 数据以填满管道缓冲区",
+ "yes A | head -c 200000",
+ "exit 0"
+ ));
+ verboseScript.toFile().setExecutable(true);
+
+ System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, verboseScript.toString());
+ System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "10");
+
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+
+ // 需要 ffprobe 正常可用供 decode 阶段使用,但 probe 阶段使用 verbose 脚本
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+ // verbose 脚本输出大量 'A',不含 "audio",预计探测失败
+ assertFalse(r.isValid(), "非音频输出应验证失败");
+ // 诊断信息必须被截断到 MAX_DIAGNOSTIC_LENGTH
+ assertTrue(r.getDiagnostic().length() <= 512,
+ "诊断信息不应超过 512 字节,实际: " + r.getDiagnostic().length());
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ // ========== 中断清理测试 ==========
+
+ @Test
+ void interruptDuringProbe_cleansUpAndReinterrupts() throws Exception {
+ Path tmpDir = Files.createTempDirectory("val-test-");
+ try {
+ Path hangScript = tmpDir.resolve("hang.sh");
+ Files.write(hangScript, Arrays.asList("#!/bin/sh", "sleep 30"));
+ hangScript.toFile().setExecutable(true);
+
+ System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, hangScript.toString());
+ System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "10");
+
+ Path mp3 = createValidMp3(tmpDir, "test.mp3");
+
+ // 先设置中断标志,再调用 validate
+ // p.waitFor(timeout, ...) 会在中断标志已设置时立即抛出 InterruptedException
+ Thread.currentThread().interrupt();
+ AudioValidationService.ValidationResult r = service.validate(mp3);
+
+ assertFalse(r.isValid(), "中断后验证应失败");
+ // runProcess 应在清理后重新设置中断标志
+ assertTrue(Thread.interrupted(), "中断标志应被保留并由 Thread.interrupted() 清除");
+ } finally {
+ Thread.interrupted(); // 清除主线程残留中断
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ // ========== 帮助方法 ==========
+
+ /**
+ * 使用 FFmpeg 创建一个带标签的有效短 MP3 文件。
+ */
+ private static Path createValidMp3(Path dir, String fileName) throws Exception {
+ Path out = dir.resolve(fileName);
+ ProcessBuilder pb = new ProcessBuilder(
+ "ffmpeg", "-y",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=0.2",
+ "-b:a", "32k",
+ "-write_xing", "0",
+ "-id3v2_version", "3",
+ "-metadata", "title=TestTitle",
+ "-metadata", "artist=TestArtist",
+ "-metadata", "album=TestAlbum",
+ out.toAbsolutePath().toString()
+ );
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+ readAllBytes(p.getInputStream());
+ boolean finished = p.waitFor(15, TimeUnit.SECONDS);
+ if (!finished) {
+ p.destroyForcibly();
+ throw new RuntimeException("ffmpeg 创建 MP3 超时");
+ }
+ if (p.exitValue() != 0) {
+ throw new RuntimeException("ffmpeg 创建 MP3 失败 (exit=" + p.exitValue() + ")");
+ }
+ return out;
+ }
+
+ private static byte[] readAllBytes(InputStream is) throws IOException {
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ byte[] tmp = new byte[4096];
+ int n;
+ while ((n = is.read(tmp)) != -1) {
+ buf.write(tmp, 0, n);
+ }
+ return buf.toByteArray();
+ }
+
+ private static void deleteDirectory(Path dir) {
+ try {
+ if (Files.exists(dir)) {
+ Files.walk(dir)
+ .sorted((a, b) -> -a.compareTo(b))
+ .forEach(p -> {
+ try { Files.deleteIfExists(p); } catch (Exception ignored) {}
+ });
+ }
+ } catch (Exception ignored) {}
+ }
+}
diff --git a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java
index da12930..4205b48 100644
--- a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java
+++ b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.io.ByteArrayOutputStream;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
@@ -17,6 +18,7 @@ import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -855,6 +857,359 @@ class IngestServiceE2ETest {
}
}
+ // ========== 4. 音频完整性验证测试 ==========
+
+ @Test
+ void corruptedAudioWithReadableTags_rejectedAsUnreadable() throws Exception {
+ assumeFfmpeg();
+ Path tmpDir = Files.createTempDirectory("ingest-e2e-");
+ 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, "corrupt.mp3",
+ "Valid Title", "Valid Artist", "Valid Album");
+ // 截断文件到极小尺寸以破坏音频数据,但保留文件头部的 ID3v2 标签
+ // 使 jaudiotagger 仍可读取标签,但 FFprobe/FFmpeg 无法处理
+ long truncatedSize = Math.min(Files.size(src), 512);
+ try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(src.toFile(), "rw")) {
+ raf.setLength(truncatedSize);
+ }
+
+ // 确认 jaudiotagger 仍能读取标签
+ AudioFile af = AudioFileIO.read(src.toFile());
+ Tag tag = af.getTag();
+ assertNotNull(tag);
+ assertEquals("Valid Title", tag.getFirst(FieldKey.TITLE));
+
+ IngestService service = buildServiceWithValidation();
+ AtomicInteger ingested = new AtomicInteger();
+ AtomicInteger duplicates = new AtomicInteger();
+ AtomicInteger missingMeta = new AtomicInteger();
+ AtomicInteger unreadable = new AtomicInteger();
+ AtomicInteger convFailed = new AtomicInteger();
+ AtomicInteger otherRejected = new AtomicInteger();
+
+ String result = invokeProcessSingleFile(service, src, libDir, rejDir,
+ new HashSet<>(), new HashSet<>(),
+ ingested, duplicates, missingMeta, unreadable,
+ convFailed, otherRejected);
+
+ assertEquals("rejected:unreadable", result);
+ assertEquals(1, unreadable.get());
+ assertEquals(0, ingested.get());
+ // 源文件应在 Rejected/Unreadable 下
+ assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("corrupt.mp3")),
+ "损坏文件应放入 Rejected/Unreadable");
+ // Library 下不应有文件
+ assertFalse(Files.list(libDir).findAny().isPresent(),
+ "Library 应为空");
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void validTaggedMp3WithValidation_passesAndIngested() throws Exception {
+ assumeFfmpeg();
+ Path tmpDir = Files.createTempDirectory("ingest-e2e-");
+ 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, "good.mp3",
+ "Good Title", "Good Artist", "Good Album");
+
+ IngestService service = buildServiceWithValidation();
+ AtomicInteger ingested = new AtomicInteger();
+ AtomicInteger duplicated = new AtomicInteger();
+ AtomicInteger missingMeta = new AtomicInteger();
+ AtomicInteger unreadable = new AtomicInteger();
+ AtomicInteger convFailed = new AtomicInteger();
+ AtomicInteger otherRejected = new AtomicInteger();
+
+ String result = invokeProcessSingleFile(service, src, libDir, rejDir,
+ new HashSet<>(), new HashSet<>(),
+ ingested, duplicated, missingMeta, unreadable,
+ convFailed, otherRejected);
+
+ assertEquals("ingested", result);
+ assertEquals(1, ingested.get());
+ assertEquals(0, unreadable.get());
+ Path expected = libDir.resolve("Good Artist").resolve("Good Album").resolve("01 - Good Title.mp3");
+ assertTrue(Files.exists(expected), "有效文件应进入 Library");
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void duplicateSkippedBeforeDecodeValidation() throws Exception {
+ assumeFfmpeg();
+ Path tmpDir = Files.createTempDirectory("ingest-e2e-");
+ 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 src1 = createTaggedMp3(inputDir, "first.mp3",
+ "Dup Title", "Dup Artist", "Dup Album");
+ IngestService service = buildServiceWithValidation();
+ Set libraryIds = new HashSet<>();
+ Set batchIds = new HashSet<>();
+ AtomicInteger ingested1 = new AtomicInteger();
+ AtomicInteger duplicated1 = new AtomicInteger();
+ AtomicInteger missingMeta1 = new AtomicInteger();
+ AtomicInteger unreadable1 = new AtomicInteger();
+ AtomicInteger convFailed1 = new AtomicInteger();
+ AtomicInteger otherRejected1 = new AtomicInteger();
+
+ String result1 = invokeProcessSingleFile(service, src1, libDir, rejDir,
+ libraryIds, batchIds,
+ ingested1, duplicated1, missingMeta1, unreadable1,
+ convFailed1, otherRejected1);
+ assertEquals("ingested", result1);
+
+ // 第二个相同内容的文件应为 duplicate,不应触发解码验证
+ Path src2 = createTaggedMp3(inputDir, "second.mp3",
+ "Dup Title", "Dup Artist", "Dup Album");
+ // 截断第二个文件使其无法通过验证,但 duplicate 检测应提前阻止验证
+ long truncatedSize2 = Math.min(Files.size(src2), 512);
+ try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(src2.toFile(), "rw")) {
+ raf.setLength(truncatedSize2);
+ }
+
+ AtomicInteger ingested2 = new AtomicInteger();
+ AtomicInteger duplicated2 = new AtomicInteger();
+ AtomicInteger missingMeta2 = new AtomicInteger();
+ AtomicInteger unreadable2 = new AtomicInteger();
+ AtomicInteger convFailed2 = new AtomicInteger();
+ AtomicInteger otherRejected2 = new AtomicInteger();
+
+ String result2 = invokeProcessSingleFile(service, src2, libDir, rejDir,
+ libraryIds, batchIds,
+ ingested2, duplicated2, missingMeta2, unreadable2,
+ convFailed2, otherRejected2);
+
+ assertEquals("rejected:duplicate", result2,
+ "重复文件应在解码验证之前被拒绝");
+ assertEquals(1, duplicated2.get());
+ assertEquals(0, unreadable2.get(),
+ "重复文件不应触发解码验证,unreadable 应为 0");
+ assertEquals(1, ingested1.get());
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ // ========== 3. 格式转换 + 完整性验证 ==========
+
+ @Test
+ void losslessFile_withValidationFails_cleansUpFlacAndRejects() throws Exception {
+ assumeFfmpeg();
+ Path tmpDir = Files.createTempDirectory("ingest-e2e-");
+ 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);
+
+ // 创建一个有标签的无损 WAV 文件,需要转换为 FLAC
+ Path src = createTaggedLossless(inputDir, "song.wav",
+ "Lossless Title", "Lossless Artist", "Lossless Album");
+
+ // 让 ffprobe 模拟失败,使验证在探测阶段即失败
+ System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/bin/false");
+
+ IngestService service = buildServiceWithValidation();
+ AtomicInteger ingested = new AtomicInteger();
+ AtomicInteger duplicates = new AtomicInteger();
+ AtomicInteger missingMeta = new AtomicInteger();
+ AtomicInteger unreadable = new AtomicInteger();
+ AtomicInteger convFailed = new AtomicInteger();
+ AtomicInteger otherRejected = new AtomicInteger();
+
+ String result = invokeProcessSingleFile(service, src, libDir, rejDir,
+ new HashSet<>(), new HashSet<>(),
+ ingested, duplicates, missingMeta, unreadable,
+ convFailed, otherRejected);
+
+ assertEquals("rejected:unreadable", result);
+ assertEquals(1, unreadable.get());
+ assertEquals(0, ingested.get());
+ assertEquals(0, convFailed.get(), "转换本身应成功");
+
+ // 转换产生的临时 FLAC 应被清理
+ Path expectedFlac = inputDir.resolve("song.flac");
+ assertFalse(Files.exists(expectedFlac), "临时 FLAC 应在验证失败后被清理");
+
+ // 源文件应被移入 Rejected/Unreadable
+ assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("song.wav")),
+ "源文件应放入 Rejected/Unreadable");
+
+ // Library 应为空
+ assertFalse(Files.list(libDir).findAny().isPresent(),
+ "Library 应为空");
+ } finally {
+ System.clearProperty(AudioValidationService.FFPROBE_BIN_PROPERTY);
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void losslessFile_withValidation_passesAndIngestedAsFlac() throws Exception {
+ assumeFfmpeg();
+ Path tmpDir = Files.createTempDirectory("ingest-e2e-");
+ 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 = createTaggedLossless(inputDir, "song.wav",
+ "Flac Title", "Flac Artist", "Flac Album");
+
+ IngestService service = buildServiceWithValidation();
+ AtomicInteger ingested = new AtomicInteger();
+ AtomicInteger duplicates = new AtomicInteger();
+ AtomicInteger missingMeta = new AtomicInteger();
+ AtomicInteger unreadable = new AtomicInteger();
+ AtomicInteger convFailed = new AtomicInteger();
+ AtomicInteger otherRejected = new AtomicInteger();
+
+ String result = invokeProcessSingleFile(service, src, libDir, rejDir,
+ new HashSet<>(), new HashSet<>(),
+ ingested, duplicates, missingMeta, unreadable,
+ convFailed, otherRejected);
+
+ assertEquals("ingested", result);
+ assertEquals(1, ingested.get());
+ assertEquals(0, unreadable.get());
+ 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 ->
+ p.getFileName().toString().endsWith(".flac"));
+ assertTrue(foundFlac, "Library 中应包含 .flac 文件");
+
+ // 源 WAV 应已被删除(转换后清理)
+ assertFalse(Files.exists(src), "源 WAV 文件应在转换后被删除");
+
+ // Rejected 应为空
+ assertFalse(Files.list(rejDir).findAny().isPresent(),
+ "Rejected 应为空");
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
+ @Test
+ void losslessFile_withMetadataRereadFails_cleansUpAndRejects() throws Exception {
+ assumeFfmpeg();
+ Path tmpDir = Files.createTempDirectory("ingest-e2e-");
+ 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);
+
+ // 创建 WAV 文件(带 ffmpeg 元数据 → 初始 jaudiotagger 读取正常)
+ Path src = inputDir.resolve("song.wav");
+ ProcessBuilder pb = new ProcessBuilder(
+ "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc",
+ "-t", "0.1",
+ "-metadata", "title=RereadTitle",
+ "-metadata", "artist=RereadArtist",
+ "-metadata", "album=RereadAlbum",
+ src.toAbsolutePath().toString()
+ );
+ runProcessChecked(pb, "ffmpeg");
+
+ // 创建 ffmpeg 包装脚本:正常执行转换,但之后剥离输出 FLAC 的元数据
+ // 这样初始文件元数据可读,但转换后 FLAC 元数据为空,触发重读失败路径
+ Path wrapper = tmpDir.resolve("ffmpeg-wrapper.sh");
+ String realFfmpeg = resolveFfmpegPath();
+ Files.write(wrapper, Arrays.asList(
+ "#!/bin/sh",
+ "REAL_FF=\"" + realFfmpeg + "\"",
+ "\"$REAL_FF\" \"$@\"",
+ "rc=$?",
+ "if [ $rc -ne 0 ]; then exit $rc; fi",
+ "for arg in \"$@\"; do",
+ " case \"$arg\" in -*) ;; *) OUTPUT=\"$arg\" ;; esac",
+ "done",
+ // 如果输出是 FLAC,剥离所有元数据(需用 -f flac 指定格式)
+ "case \"$OUTPUT\" in",
+ " *.flac)",
+ " \"$REAL_FF\" -y -i \"$OUTPUT\" -map_metadata -1 -c copy -f flac \"${OUTPUT}.stripped\" 2>/dev/null &&",
+ " mv \"${OUTPUT}.stripped\" \"$OUTPUT\"",
+ " ;;",
+ "esac"
+ ));
+ wrapper.toFile().setExecutable(true);
+
+ IngestService service = buildServiceWithValidation();
+ // 注入包装脚本路径作为 ffmpeg 可执行文件(通过系统属性)
+ String oldBin = System.setProperty("mangtool.ffmpeg.bin",
+ wrapper.toAbsolutePath().toString());
+ try {
+
+ AtomicInteger ingested = new AtomicInteger();
+ AtomicInteger duplicates = new AtomicInteger();
+ AtomicInteger missingMeta = new AtomicInteger();
+ AtomicInteger unreadable = new AtomicInteger();
+ AtomicInteger convFailed = new AtomicInteger();
+ AtomicInteger otherRejected = new AtomicInteger();
+
+ String result = invokeProcessSingleFile(service, src, libDir, rejDir,
+ new HashSet<>(), new HashSet<>(),
+ ingested, duplicates, missingMeta, unreadable,
+ convFailed, otherRejected);
+
+ assertEquals("rejected:unreadable", result,
+ "转换后 FLAC 元数据为空应被拒绝为 Unreadable");
+ assertEquals(1, unreadable.get());
+ assertEquals(0, ingested.get());
+ // 确认转换产物 FLAC 已被清理
+ assertTrue(Files.list(tmpDir).noneMatch(p ->
+ p.toString().endsWith(".flac")),
+ "转换产物 FLAC 应被清理");
+ // 源文件应在 Rejected/Unreadable 下
+ assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("song.wav")),
+ "源文件应放入 Rejected/Unreadable");
+ } finally {
+ if (oldBin != null) {
+ System.setProperty("mangtool.ffmpeg.bin", oldBin);
+ } else {
+ System.clearProperty("mangtool.ffmpeg.bin");
+ }
+ }
+ } finally {
+ deleteDirectory(tmpDir);
+ }
+ }
+
// ========== 工具方法 ==========
private IngestService buildService() {
@@ -866,6 +1221,16 @@ class IngestServiceE2ETest {
);
}
+ private IngestService buildServiceWithValidation() {
+ return new IngestService(
+ mock(SimpMessagingTemplate.class),
+ new ProgressStore(),
+ new TraditionalFilterService(),
+ mock(ConfigService.class),
+ new AudioValidationService()
+ );
+ }
+
private String invokeProcessSingleFile(
IngestService service, Path srcFile, Path libraryPath, Path rejectedPath,
Set libraryIdentities, Set batchIdentities,
@@ -905,6 +1270,29 @@ class IngestServiceE2ETest {
return file;
}
+ /**
+ * 创建一个带标签的无损音频文件(WAV 格式)。
+ * 用于测试需要将无损格式转换为 FLAC 的场景。
+ */
+ private static Path createTaggedLossless(Path dir, String name,
+ String title, String artist, String album) throws Exception {
+ Path file = dir.resolve(name);
+ // 使用 ffmpeg 创建带元数据的 WAV 文件
+ ProcessBuilder pb = new ProcessBuilder(
+ "ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc",
+ "-t", "0.1",
+ "-metadata", "title=" + title,
+ "-metadata", "artist=" + artist,
+ "-metadata", "album=" + album,
+ file.toAbsolutePath().toString()
+ );
+ runProcessChecked(pb, "ffmpeg");
+ // 注意:ffmpeg 写入的 WAV 元数据(LIST INFO chunk)可被 jaudiotagger 读取,
+ // 但 jaudiotagger 的 commit() 会写入 ID3 chunk 导致二度读取出空字段。
+ // 因此此处不调用 writeTags(),使用 ffmpeg 原生元数据即可满足初始读取需求。
+ return file;
+ }
+
private static void runProcess(ProcessBuilder pb) throws Exception {
pb.redirectErrorStream(true);
Process p = pb.start();
@@ -966,4 +1354,22 @@ class IngestServiceE2ETest {
}
} catch (Exception ignored) {}
}
+
+ /** 解析系统路径中 ffmpeg 的绝对路径 */
+ private static String resolveFfmpegPath() throws IOException {
+ String userDefined = System.getProperty("mangtool.ffmpeg.bin");
+ if (userDefined != null && !userDefined.isEmpty()) {
+ return new File(userDefined).getCanonicalPath();
+ }
+ ProcessBuilder which = new ProcessBuilder("which", "ffmpeg");
+ which.redirectErrorStream(true);
+ Process p = which.start();
+ try {
+ if (p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0) {
+ return readStream(p.getInputStream()).trim();
+ }
+ } catch (InterruptedException ignored) {}
+ // 回退
+ return "ffmpeg";
+ }
}
diff --git a/backend/src/test/java/com/music/service/IngestServiceInternalTest.java b/backend/src/test/java/com/music/service/IngestServiceInternalTest.java
index 78cd8ea..97ac31c 100644
--- a/backend/src/test/java/com/music/service/IngestServiceInternalTest.java
+++ b/backend/src/test/java/com/music/service/IngestServiceInternalTest.java
@@ -509,6 +509,22 @@ class IngestServiceInternalTest {
}
}
+ @Test
+ void fiveParamConstructor_hasAutowiredAnnotation() throws Exception {
+ Constructor>[] ctors = IngestService.class.getConstructors();
+ Constructor> fiveParamCtor = null;
+ for (Constructor> c : ctors) {
+ if (c.getParameterCount() == 5) {
+ fiveParamCtor = c;
+ break;
+ }
+ }
+ assertNotNull(fiveParamCtor, "应存在 5 参数构造器");
+ assertTrue(fiveParamCtor.isAnnotationPresent(
+ org.springframework.beans.factory.annotation.Autowired.class),
+ "5 参数构造器应带有 @Autowired 注解");
+ }
+
// ========== 工具方法 ==========
private void deleteDirectory(Path dir) {
diff --git a/docker/README.md b/docker/README.md
index af28412..c0a8157 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -95,9 +95,9 @@ docker compose up -d --build
- **端口**:宿主机 `8080` 映射容器 `8080`,可在 `docker-compose.yml` 中修改左侧端口,例如 `"8888:8080"`。
- **数据**:工具读写路径在容器内通过 volume 挂载;请确保宿主机目录存在且容器内用户有读写权限。首次启动后系统会在工作根目录下自动创建 `Input/`、`Library/`、`Rejected/` 子目录。
-### FFmpeg
+### FFmpeg / FFprobe
-容器内置 FFmpeg,一键导入过程中的格式转换(WAV/APE/AIFF/WV/TTA → FLAC)自动使用容器的 FFmpeg。
+容器内置 FFmpeg 和 FFprobe(FFmpeg 套件自带),一键导入过程中的格式转换(WAV/APE/AIFF/WV/TTA → FLAC)和音频完整性验证自动使用容器内的工具。
## 常见问题