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 属性模式一致:

+ * + * + *

运行时输出通过守护线程并发读取并限制大小({@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 ========== + + /** + * 验证音频文件完整性。 + *
    + *
  1. 运行 FFprobe 确认至少有一个可识别的音频流;
  2. + *
  3. 运行 FFmpeg 以严格模式({@code -xerror -map 0:a:0})完整解码到 null muxer。
  4. + *
+ * + * @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。 + * + */ + 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)和音频完整性验证自动使用容器内的工具。 ## 常见问题