Template
603 lines
24 KiB
Java
603 lines
24 KiB
Java
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.nio.file.Paths;
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ========== FFprobe 后备元数据读取 ==========
|
||
|
||
@Test
|
||
void getMetadataCommandArgs_usesStructuredJson() {
|
||
Path f = Paths.get("/tmp/song.m4a");
|
||
List<String> args = service.getMetadataCommandArgs(f);
|
||
assertTrue(args.contains("-print_format"));
|
||
assertTrue(args.contains("json"));
|
||
assertTrue(args.contains("-show_format"));
|
||
assertTrue(args.contains("-show_streams"));
|
||
// 绝不使用任何人类可读文本或文件名猜测选项
|
||
assertFalse(args.contains("-of"));
|
||
assertEquals(f.toAbsolutePath().toString(), args.get(args.size() - 1));
|
||
}
|
||
|
||
@Test
|
||
void readMetadata_returnsFullTags() throws Exception {
|
||
assumeFfmpegFfprobe();
|
||
Path tmpDir = Files.createTempDirectory("probe-meta-");
|
||
try {
|
||
Path m4a = createTaggedM4a(tmpDir, "song.m4a",
|
||
"ProbeTitle", "ProbeArtist", "ProbeAlbum");
|
||
|
||
AudioValidationService.ProbeMetadata meta = service.readMetadata(m4a);
|
||
assertTrue(meta.isAvailable(), "应成功读取元数据");
|
||
assertEquals("ProbeTitle", meta.getFirst("title"));
|
||
assertEquals("ProbeArtist", meta.getFirst("artist"));
|
||
assertEquals("ProbeAlbum", meta.getFirst("album"));
|
||
} finally {
|
||
deleteDirectory(tmpDir);
|
||
}
|
||
}
|
||
|
||
@Test
|
||
void readMetadata_missingAlbumReturnsEmpty() throws Exception {
|
||
assumeFfmpegFfprobe();
|
||
Path tmpDir = Files.createTempDirectory("probe-meta-");
|
||
try {
|
||
// 仅写 title/artist,故意不写 album
|
||
Path m4a = tmpDir.resolve("noalbum.m4a");
|
||
ProcessBuilder pb = new ProcessBuilder(
|
||
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
|
||
"-t", "0.3", "-c:a", "aac",
|
||
"-metadata", "title=T", "-metadata", "artist=A",
|
||
m4a.toAbsolutePath().toString());
|
||
pb.redirectErrorStream(true);
|
||
Process p = pb.start();
|
||
readAllBytes(p.getInputStream());
|
||
p.waitFor(15, TimeUnit.SECONDS);
|
||
|
||
AudioValidationService.ProbeMetadata meta = service.readMetadata(m4a);
|
||
assertTrue(meta.isAvailable());
|
||
assertEquals("", meta.getFirst("album"), "缺失的 album 应返回空字符串");
|
||
} finally {
|
||
deleteDirectory(tmpDir);
|
||
}
|
||
}
|
||
|
||
@Test
|
||
void readMetadata_nonAudioFileUnavailable() throws Exception {
|
||
assumeFfmpegFfprobe();
|
||
Path tmpDir = Files.createTempDirectory("probe-meta-");
|
||
try {
|
||
Path bogus = tmpDir.resolve("bogus.m4a");
|
||
Files.write(bogus, "not an audio file".getBytes(StandardCharsets.UTF_8));
|
||
|
||
AudioValidationService.ProbeMetadata meta = service.readMetadata(bogus);
|
||
assertFalse(meta.isAvailable(), "无法识别的文件应返回 unavailable");
|
||
} finally {
|
||
deleteDirectory(tmpDir);
|
||
}
|
||
}
|
||
|
||
@Test
|
||
void readMetadata_ffprobeFailureUnavailable() {
|
||
// 强制 ffprobe 指向失败命令
|
||
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/bin/false");
|
||
AudioValidationService.ProbeMetadata meta =
|
||
service.readMetadata(Paths.get("/tmp/whatever.m4a"));
|
||
assertFalse(meta.isAvailable(), "ffprobe 失败应返回 unavailable");
|
||
}
|
||
|
||
/**
|
||
* 使用 FFmpeg 创建一个带标签的有效短 M4A(AAC)文件。
|
||
*/
|
||
private static Path createTaggedM4a(Path dir, String fileName,
|
||
String title, String artist, String album) throws Exception {
|
||
Path out = dir.resolve(fileName);
|
||
ProcessBuilder pb = new ProcessBuilder(
|
||
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
|
||
"-t", "0.3", "-c:a", "aac",
|
||
"-metadata", "title=" + title,
|
||
"-metadata", "artist=" + artist,
|
||
"-metadata", "album=" + album,
|
||
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 创建 M4A 超时");
|
||
}
|
||
if (p.exitValue() != 0) {
|
||
throw new RuntimeException("ffmpeg 创建 M4A 失败 (exit=" + p.exitValue() + ")");
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ========== 帮助方法 ==========
|
||
|
||
/**
|
||
* 使用 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) {}
|
||
}
|
||
}
|