Template
Validate audio integrity before ingestion
This commit is contained in:
@@ -0,0 +1,409 @@
|
|||||||
|
package com.music.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频完整性验证服务。
|
||||||
|
* <p>使用 FFprobe 探测文件是否包含可识别的音频流,再用 FFmpeg 以严格模式
|
||||||
|
* ({@code -xerror -map 0:a:0}) 完整解码到 null muxer,检测截断/损坏等问题。
|
||||||
|
* 验证通过的音频文件保证可被 Navidrome 识别和播放。</p>
|
||||||
|
*
|
||||||
|
* <p>可执行路径与超时通过系统属性配置,与 {@link IngestService} 和 {@link ConvertService}
|
||||||
|
* 的 ffmpeg 属性模式一致:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code mangtool.ffprobe.bin} — FFprobe 可执行路径(默认 {@code ffprobe})</li>
|
||||||
|
* <li>{@code mangtool.ffmpeg.bin} — FFmpeg 可执行路径(默认 {@code ffmpeg})</li>
|
||||||
|
* <li>{@code mangtool.validation.probe.timeout} — 探测超时秒数(默认 30,必须 > 0)</li>
|
||||||
|
* <li>{@code mangtool.validation.decode.timeout} — 解码超时秒数(默认 300,必须 > 0)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>运行时输出通过守护线程并发读取并限制大小({@value #MAX_DIAGNOSTIC_LENGTH} 字节),
|
||||||
|
* 防止被长时间或无限输出的子进程阻塞。超时后强制终止子进程并调用 {@code waitFor()} 回收。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AudioValidationService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AudioValidationService.class);
|
||||||
|
|
||||||
|
/** 系统属性键:FFprobe 可执行路径 */
|
||||||
|
static final String FFPROBE_BIN_PROPERTY = "mangtool.ffprobe.bin";
|
||||||
|
/** 系统属性键:FFmpeg 可执行路径(复用现有属性) */
|
||||||
|
static final String FFMPEG_BIN_PROPERTY = "mangtool.ffmpeg.bin";
|
||||||
|
/** 系统属性键:探测超时秒数 */
|
||||||
|
static final String PROBE_TIMEOUT_PROPERTY = "mangtool.validation.probe.timeout";
|
||||||
|
/** 系统属性键:解码超时秒数 */
|
||||||
|
static final String DECODE_TIMEOUT_PROPERTY = "mangtool.validation.decode.timeout";
|
||||||
|
|
||||||
|
/** 探测超时默认值(秒) */
|
||||||
|
static final int DEFAULT_PROBE_TIMEOUT_SECONDS = 30;
|
||||||
|
/** 解码超时默认值(秒) */
|
||||||
|
static final int DEFAULT_DECODE_TIMEOUT_SECONDS = 300;
|
||||||
|
/** 保留诊断信息最大长度 */
|
||||||
|
static final int MAX_DIAGNOSTIC_LENGTH = 512;
|
||||||
|
/** 流读取缓冲区大小 */
|
||||||
|
private static final int BUFFER_SIZE = 4096;
|
||||||
|
/** 回收子进程 / 合并 drainer 线程的最长等待秒数 */
|
||||||
|
private static final int CLEANUP_WAIT_SECONDS = 5;
|
||||||
|
|
||||||
|
// ========== 嵌套结果类 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证结果。{@link #isValid()} 返回 {@code true} 表示文件可通过完整解码验证。
|
||||||
|
*/
|
||||||
|
public static class ValidationResult {
|
||||||
|
private final boolean valid;
|
||||||
|
private final String diagnostic;
|
||||||
|
|
||||||
|
ValidationResult(boolean valid, String diagnostic) {
|
||||||
|
this.valid = valid;
|
||||||
|
String raw = diagnostic != null ? diagnostic : "";
|
||||||
|
this.diagnostic = raw.length() > MAX_DIAGNOSTIC_LENGTH
|
||||||
|
? raw.substring(0, MAX_DIAGNOSTIC_LENGTH)
|
||||||
|
: raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid() {
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDiagnostic() {
|
||||||
|
return diagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ValidationResult valid() {
|
||||||
|
return new ValidationResult(true, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
static ValidationResult invalid(String diagnostic) {
|
||||||
|
return new ValidationResult(false, diagnostic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 嵌套进程运行结果 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link #runProcess(List, int)} 的返回结果。
|
||||||
|
*/
|
||||||
|
private static class ProcessResult {
|
||||||
|
final boolean timedOut;
|
||||||
|
final int exitCode;
|
||||||
|
final String output;
|
||||||
|
|
||||||
|
ProcessResult(boolean timedOut, int exitCode, String output) {
|
||||||
|
this.timedOut = timedOut;
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
this.output = output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 嵌套并发 Drainer ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 守护线程读取子进程标准输出 / 错误,并限制累积大小。
|
||||||
|
*/
|
||||||
|
private static class DrainResult {
|
||||||
|
final Thread thread;
|
||||||
|
final ByteArrayOutputStream buffer;
|
||||||
|
|
||||||
|
DrainResult(Thread thread, ByteArrayOutputStream buffer) {
|
||||||
|
this.thread = thread;
|
||||||
|
this.buffer = buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getOutput() {
|
||||||
|
return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公开 API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证音频文件完整性。
|
||||||
|
* <ol>
|
||||||
|
* <li>运行 FFprobe 确认至少有一个可识别的音频流;</li>
|
||||||
|
* <li>运行 FFmpeg 以严格模式({@code -xerror -map 0:a:0})完整解码到 null muxer。</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param audioFile 待验证的音频文件路径
|
||||||
|
* @return 验证结果
|
||||||
|
*/
|
||||||
|
public ValidationResult validate(Path audioFile) {
|
||||||
|
ValidationResult probeResult = runProbe(audioFile);
|
||||||
|
if (!probeResult.isValid()) {
|
||||||
|
return probeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return runDecode(audioFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 探测(FFprobe) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 FFprobe 探测命令参数列表(用于测试观察)。
|
||||||
|
*/
|
||||||
|
List<String> getProbeCommandArgs(Path audioFile) {
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(getFfprobeCommand());
|
||||||
|
cmd.add("-v");
|
||||||
|
cmd.add("error");
|
||||||
|
cmd.add("-show_entries");
|
||||||
|
cmd.add("stream=codec_type");
|
||||||
|
cmd.add("-of");
|
||||||
|
cmd.add("default=noprint_wrappers=1:nokey=1");
|
||||||
|
cmd.add(audioFile.toAbsolutePath().toString());
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行 FFprobe 要求至少一个可识别的音频流。
|
||||||
|
*/
|
||||||
|
private ValidationResult runProbe(Path audioFile) {
|
||||||
|
int timeout = resolveProbeTimeout();
|
||||||
|
try {
|
||||||
|
ProcessResult result = runProcess(getProbeCommandArgs(audioFile), timeout);
|
||||||
|
if (result.timedOut) {
|
||||||
|
log.warn("FFprobe 探测超时: {} ({}s)", audioFile, timeout);
|
||||||
|
return ValidationResult.invalid("音频探测超时");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
String diag = capDiagnostic(result.output);
|
||||||
|
log.warn("FFprobe 探测失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag);
|
||||||
|
return ValidationResult.invalid("FFprobe 探测失败: " + diag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.output.contains("audio")) {
|
||||||
|
log.warn("FFprobe 未发现音频流: {}", audioFile);
|
||||||
|
return ValidationResult.invalid("文件中未发现可识别的音频流");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult.valid();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("FFprobe 执行失败: {} - {}", audioFile, e.getMessage());
|
||||||
|
return ValidationResult.invalid("FFprobe 执行失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 解码(FFmpeg,严格模式) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行 FFmpeg 以 error 日志级别 + 严格模式完整解码到 null muxer。
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code -xerror}:将大多数日志错误视为致命错误(非零退出);</li>
|
||||||
|
* <li>{@code -map 0:a:0}:仅解码第一个输入的第一个音频流,避免干扰;</li>
|
||||||
|
* <li>即使 {@code -xerror} 下退出码为 0,若 error 级别输出非空也视为失败
|
||||||
|
* (FFmpeg 6.1.1 实验发现某些解码错误不会触发非零退出)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private ValidationResult runDecode(Path audioFile) {
|
||||||
|
int timeout = resolveDecodeTimeout();
|
||||||
|
try {
|
||||||
|
ProcessResult result = runProcess(getDecodeCommandArgs(audioFile), timeout);
|
||||||
|
if (result.timedOut) {
|
||||||
|
log.warn("FFmpeg 解码超时: {} ({}s)", audioFile, timeout);
|
||||||
|
return ValidationResult.invalid("音频解码超时");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时检查退出码和 error 级别输出:
|
||||||
|
// -xerror 下 exitCode != 0 表示 fatal 错误
|
||||||
|
// 但某些 FFmpeg 版本(如 6.1.1)的部分解码错误仅输出到 stderr 而不改变退出码
|
||||||
|
String trimmedOutput = result.output.trim();
|
||||||
|
if (result.exitCode != 0 || !trimmedOutput.isEmpty()) {
|
||||||
|
String diag = capDiagnostic(result.output);
|
||||||
|
log.warn("FFmpeg 解码失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag);
|
||||||
|
return ValidationResult.invalid("音频解码失败: " + diag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult.valid();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("FFmpeg 执行失败: {} - {}", audioFile, e.getMessage());
|
||||||
|
return ValidationResult.invalid("FFmpeg 执行失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 FFmpeg 解码命令参数列表(用于测试观察)。
|
||||||
|
*/
|
||||||
|
List<String> getDecodeCommandArgs(Path audioFile) {
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(getFfmpegCommand());
|
||||||
|
cmd.add("-xerror");
|
||||||
|
cmd.add("-v");
|
||||||
|
cmd.add("error");
|
||||||
|
cmd.add("-i");
|
||||||
|
cmd.add(audioFile.toAbsolutePath().toString());
|
||||||
|
cmd.add("-map");
|
||||||
|
cmd.add("0:a:0");
|
||||||
|
cmd.add("-f");
|
||||||
|
cmd.add("null");
|
||||||
|
cmd.add("-");
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 通用进程运行 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行一个外部进程,并发读取输出,超时处理。
|
||||||
|
* <p>超时后自动 {@link Process#destroyForcibly()} 并以有界等待回收。
|
||||||
|
* drainer 线程为守护线程,保证不阻塞 JVM 退出。
|
||||||
|
* 调用线程的中断不会泄漏子进程:强制终止、有界回收、先 join drainer 确保输出被读取、再关闭流。</p>
|
||||||
|
*/
|
||||||
|
private static ProcessResult runProcess(List<String> command, int timeoutSeconds)
|
||||||
|
throws IOException {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process p = pb.start();
|
||||||
|
DrainResult drain = startConcurrentDrainer(p);
|
||||||
|
|
||||||
|
boolean interrupted = false;
|
||||||
|
boolean finished;
|
||||||
|
try {
|
||||||
|
finished = p.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
// 调用线程中断 — 清理子进程后再传播
|
||||||
|
interrupted = true;
|
||||||
|
finished = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finished) {
|
||||||
|
p.destroyForcibly();
|
||||||
|
try {
|
||||||
|
p.waitFor(CLEANUP_WAIT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
interrupted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先 join drainer 线程确保所有输出已读取完毕,再关闭流。
|
||||||
|
// 顺序不能反:若先关闭流,drainer 可能尚未读取完输出,
|
||||||
|
// 导致 fast-exit 子进程的 error 诊断丢失(FFmpeg 6.1.1 等)。
|
||||||
|
try {
|
||||||
|
drain.thread.join(TimeUnit.SECONDS.toMillis(CLEANUP_WAIT_SECONDS));
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
interrupted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainer 结束后安全关闭子进程输出流
|
||||||
|
try {
|
||||||
|
p.getInputStream().close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interrupted) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finished) {
|
||||||
|
return new ProcessResult(true, -1, drain.getOutput());
|
||||||
|
}
|
||||||
|
return new ProcessResult(false, p.exitValue(), drain.getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动守护线程并发读取进程标准输出(合并后),保留前 {@link #MAX_DIAGNOSTIC_LENGTH}
|
||||||
|
* 字节为诊断信息,继续读取剩余数据以排空管道,防止子进程因管道写满而阻塞或收到 SIGPIPE。
|
||||||
|
*/
|
||||||
|
private static DrainResult startConcurrentDrainer(Process p) {
|
||||||
|
ByteArrayOutputStream buffer = new ByteArrayOutputStream(MAX_DIAGNOSTIC_LENGTH);
|
||||||
|
Thread t = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
byte[] buf = new byte[BUFFER_SIZE];
|
||||||
|
int n;
|
||||||
|
InputStream is = p.getInputStream();
|
||||||
|
while ((n = is.read(buf)) != -1) {
|
||||||
|
if (buffer.size() < MAX_DIAGNOSTIC_LENGTH) {
|
||||||
|
int remaining = MAX_DIAGNOSTIC_LENGTH - buffer.size();
|
||||||
|
buffer.write(buf, 0, Math.min(n, remaining));
|
||||||
|
}
|
||||||
|
// buffer 已满后继续读取以排空管道,丢弃多余数据
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// 进程终止后正常关闭
|
||||||
|
}
|
||||||
|
}, "audio-validation-drainer");
|
||||||
|
t.setDaemon(true);
|
||||||
|
t.start();
|
||||||
|
return new DrainResult(t, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 可执行路径解析 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 FFprobe 可执行路径(可被系统属性 {@code mangtool.ffprobe.bin} 覆盖)。
|
||||||
|
*/
|
||||||
|
String getFfprobeCommand() {
|
||||||
|
String configured = System.getProperty(FFPROBE_BIN_PROPERTY);
|
||||||
|
if (configured == null || configured.trim().isEmpty()) {
|
||||||
|
return "ffprobe";
|
||||||
|
}
|
||||||
|
return configured.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 FFmpeg 可执行路径(可被系统属性 {@code mangtool.ffmpeg.bin} 覆盖)。
|
||||||
|
*/
|
||||||
|
String getFfmpegCommand() {
|
||||||
|
String configured = System.getProperty(FFMPEG_BIN_PROPERTY);
|
||||||
|
if (configured == null || configured.trim().isEmpty()) {
|
||||||
|
return "ffmpeg";
|
||||||
|
}
|
||||||
|
return configured.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 超时解析 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析探测超时配置(秒),不可解析或 ≤ 0 时返回默认值。
|
||||||
|
*/
|
||||||
|
int resolveProbeTimeout() {
|
||||||
|
return parseIntProperty(PROBE_TIMEOUT_PROPERTY, DEFAULT_PROBE_TIMEOUT_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析解码超时配置(秒),不可解析或 ≤ 0 时返回默认值。
|
||||||
|
*/
|
||||||
|
int resolveDecodeTimeout() {
|
||||||
|
return parseIntProperty(DECODE_TIMEOUT_PROPERTY, DEFAULT_DECODE_TIMEOUT_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析整数系统属性,仅返回正数;否则返回默认值。
|
||||||
|
*/
|
||||||
|
private static int parseIntProperty(String key, int defaultValue) {
|
||||||
|
String val = System.getProperty(key);
|
||||||
|
if (val != null) {
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(val.trim());
|
||||||
|
if (parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 工具方法 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断诊断信息到最大长度。
|
||||||
|
*/
|
||||||
|
private static String capDiagnostic(String s) {
|
||||||
|
if (s == null || s.isEmpty()) return "";
|
||||||
|
return s.length() > MAX_DIAGNOSTIC_LENGTH
|
||||||
|
? s.substring(0, MAX_DIAGNOSTIC_LENGTH)
|
||||||
|
: s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.music.service;
|
|||||||
|
|
||||||
import com.music.common.FileTransferUtils;
|
import com.music.common.FileTransferUtils;
|
||||||
import com.music.dto.ProgressMessage;
|
import com.music.dto.ProgressMessage;
|
||||||
|
import com.music.service.AudioValidationService.ValidationResult;
|
||||||
import org.jaudiotagger.audio.AudioFile;
|
import org.jaudiotagger.audio.AudioFile;
|
||||||
import org.jaudiotagger.audio.AudioFileIO;
|
import org.jaudiotagger.audio.AudioFileIO;
|
||||||
import org.jaudiotagger.tag.FieldKey;
|
import org.jaudiotagger.tag.FieldKey;
|
||||||
@@ -9,6 +10,7 @@ import org.jaudiotagger.tag.Tag;
|
|||||||
import org.jaudiotagger.tag.images.Artwork;
|
import org.jaudiotagger.tag.images.Artwork;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -39,7 +41,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
* <li>success / ingestedFiles:成功入库文件数</li>
|
* <li>success / ingestedFiles:成功入库文件数</li>
|
||||||
* <li>duplicateFiles:跳过重复文件数</li>
|
* <li>duplicateFiles:跳过重复文件数</li>
|
||||||
* <li>missingMetadataFiles:缺少 Title/Artist/Album 的文件数</li>
|
* <li>missingMetadataFiles:缺少 Title/Artist/Album 的文件数</li>
|
||||||
* <li>unreadableFiles:无法读取元数据的文件数</li>
|
* <li>unreadableFiles:无法读取元数据或无法完整解码的文件数</li>
|
||||||
* <li>conversionFailedFiles:格式转换失败文件数</li>
|
* <li>conversionFailedFiles:格式转换失败文件数</li>
|
||||||
* <li>failed / otherRejectedFiles:其他原因拒绝文件数</li>
|
* <li>failed / otherRejectedFiles:其他原因拒绝文件数</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
@@ -82,15 +84,34 @@ public class IngestService {
|
|||||||
private final ProgressStore progressStore;
|
private final ProgressStore progressStore;
|
||||||
private final TraditionalFilterService traditionalFilterService;
|
private final TraditionalFilterService traditionalFilterService;
|
||||||
private final ConfigService configService;
|
private final ConfigService configService;
|
||||||
|
private final AudioValidationService audioValidationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring DI 构造器。
|
||||||
|
* <p>包级 4 参数构造器保留给测试,此时 {@code audioValidationService} 为 null,
|
||||||
|
* 跳过音频完整性验证步骤。</p>
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
public IngestService(SimpMessagingTemplate messagingTemplate,
|
public IngestService(SimpMessagingTemplate messagingTemplate,
|
||||||
ProgressStore progressStore,
|
ProgressStore progressStore,
|
||||||
TraditionalFilterService traditionalFilterService,
|
TraditionalFilterService traditionalFilterService,
|
||||||
ConfigService configService) {
|
ConfigService configService,
|
||||||
|
AudioValidationService audioValidationService) {
|
||||||
this.messagingTemplate = messagingTemplate;
|
this.messagingTemplate = messagingTemplate;
|
||||||
this.progressStore = progressStore;
|
this.progressStore = progressStore;
|
||||||
this.traditionalFilterService = traditionalFilterService;
|
this.traditionalFilterService = traditionalFilterService;
|
||||||
this.configService = configService;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预检查 FFmpeg(如果需要转换)
|
// 预检查 FFprobe 和 FFmpeg(每个新文件都需要完整解码验证)
|
||||||
boolean mayNeedFfmpeg = audioFiles.stream().anyMatch(f -> isLosslessFormat(f));
|
if (!audioFiles.isEmpty()) {
|
||||||
if (mayNeedFfmpeg) {
|
String ffprobeError = checkFfprobeAvailable();
|
||||||
|
if (ffprobeError != null) {
|
||||||
|
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
null, ffprobeError, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
String ffmpegError = checkFfmpegAvailable();
|
String ffmpegError = checkFfmpegAvailable();
|
||||||
if (ffmpegError != null) {
|
if (ffmpegError != null) {
|
||||||
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
sendProgress(taskId, total, 0, 0, 0, 0, 0, 0, 0,
|
||||||
@@ -345,10 +371,10 @@ public class IngestService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 提取其他可选字段
|
// 6. 提取其他可选字段(WavTag 等实现可能不支持 YEAR/TRACK/DISC_NO,使用安全读取)
|
||||||
String yearStr = trim(tag.getFirst(FieldKey.YEAR));
|
String yearStr = safeGetFirst(tag, FieldKey.YEAR);
|
||||||
String trackRaw = trim(tag.getFirst(FieldKey.TRACK));
|
String trackRaw = safeGetFirst(tag, FieldKey.TRACK);
|
||||||
String discRaw = trim(tag.getFirst(FieldKey.DISC_NO));
|
String discRaw = safeGetFirst(tag, FieldKey.DISC_NO);
|
||||||
|
|
||||||
int trackNum = parseInt(trackRaw, 0);
|
int trackNum = parseInt(trackRaw, 0);
|
||||||
int discNum = parseInt(discRaw, 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);
|
String ext = needsConversion ? "flac" : getExtension(fileName);
|
||||||
if (ext == null) ext = "flac";
|
if (ext == null) ext = "flac";
|
||||||
|
|
||||||
@@ -707,8 +788,8 @@ public class IngestService {
|
|||||||
return FileVisitResult.CONTINUE;
|
return FileVisitResult.CONTINUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
int trackNum = parseInt(trim(tag.getFirst(FieldKey.TRACK)), 0);
|
int trackNum = parseInt(safeGetFirst(tag, FieldKey.TRACK), 0);
|
||||||
int discNum = parseInt(trim(tag.getFirst(FieldKey.DISC_NO)), 0);
|
int discNum = parseInt(safeGetFirst(tag, FieldKey.DISC_NO), 0);
|
||||||
|
|
||||||
String md5 = computeMd5(file);
|
String md5 = computeMd5(file);
|
||||||
identities.add(new IdentityKey(
|
identities.add(new IdentityKey(
|
||||||
@@ -759,6 +840,18 @@ public class IngestService {
|
|||||||
return s == null ? "" : s.trim();
|
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) {
|
private int parseInt(String s, int defaultValue) {
|
||||||
if (s == null || s.trim().isEmpty()) return defaultValue;
|
if (s == null || s.trim().isEmpty()) return defaultValue;
|
||||||
try {
|
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,
|
private void sendProgress(String taskId, int total, int processed,
|
||||||
|
|||||||
@@ -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} 的单元与集成测试。
|
||||||
|
*
|
||||||
|
* <p>依赖 FFprobe/FFmpeg 的测试通过 {@link #assumeFfmpegFfprobe()} 守卫,
|
||||||
|
* 工具不可用时自动跳过。</p>
|
||||||
|
*/
|
||||||
|
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<String> 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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.lang.reflect.Constructor;
|
import java.lang.reflect.Constructor;
|
||||||
@@ -17,6 +18,7 @@ import java.lang.reflect.Method;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.TimeUnit;
|
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<Object> libraryIds = new HashSet<>();
|
||||||
|
Set<Object> 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() {
|
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(
|
private String invokeProcessSingleFile(
|
||||||
IngestService service, Path srcFile, Path libraryPath, Path rejectedPath,
|
IngestService service, Path srcFile, Path libraryPath, Path rejectedPath,
|
||||||
Set<Object> libraryIdentities, Set<Object> batchIdentities,
|
Set<Object> libraryIdentities, Set<Object> batchIdentities,
|
||||||
@@ -905,6 +1270,29 @@ class IngestServiceE2ETest {
|
|||||||
return file;
|
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 {
|
private static void runProcess(ProcessBuilder pb) throws Exception {
|
||||||
pb.redirectErrorStream(true);
|
pb.redirectErrorStream(true);
|
||||||
Process p = pb.start();
|
Process p = pb.start();
|
||||||
@@ -966,4 +1354,22 @@ class IngestServiceE2ETest {
|
|||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} 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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
private void deleteDirectory(Path dir) {
|
||||||
|
|||||||
+2
-2
@@ -95,9 +95,9 @@ docker compose up -d --build
|
|||||||
- **端口**:宿主机 `8080` 映射容器 `8080`,可在 `docker-compose.yml` 中修改左侧端口,例如 `"8888:8080"`。
|
- **端口**:宿主机 `8080` 映射容器 `8080`,可在 `docker-compose.yml` 中修改左侧端口,例如 `"8888:8080"`。
|
||||||
- **数据**:工具读写路径在容器内通过 volume 挂载;请确保宿主机目录存在且容器内用户有读写权限。首次启动后系统会在工作根目录下自动创建 `Input/`、`Library/`、`Rejected/` 子目录。
|
- **数据**:工具读写路径在容器内通过 volume 挂载;请确保宿主机目录存在且容器内用户有读写权限。首次启动后系统会在工作根目录下自动创建 `Input/`、`Library/`、`Rejected/` 子目录。
|
||||||
|
|
||||||
### FFmpeg
|
### FFmpeg / FFprobe
|
||||||
|
|
||||||
容器内置 FFmpeg,一键导入过程中的格式转换(WAV/APE/AIFF/WV/TTA → FLAC)自动使用容器的 FFmpeg。
|
容器内置 FFmpeg 和 FFprobe(FFmpeg 套件自带),一键导入过程中的格式转换(WAV/APE/AIFF/WV/TTA → FLAC)和音频完整性验证自动使用容器内的工具。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user