Fix M4A fallback ingestion

This commit is contained in:
2026-07-19 23:29:55 +08:00
parent 4327b94fd0
commit 4c68e415ee
12 changed files with 1224 additions and 126 deletions
@@ -0,0 +1,72 @@
package com.music.config;
import org.junit.jupiter.api.Test;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.lang.reflect.Method;
import java.util.concurrent.Executor;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link AsyncConfig} 单元测试:验证 {@code @Async} 使用明确的应用 taskExecutor
* 从而消除多个 TaskExecutor 无法选择的警告,并保留异常处理器。
*/
class AsyncConfigTest {
private final AsyncConfig config = new AsyncConfig();
@Test
void taskExecutorBeanIsConfiguredThreadPool() {
ThreadPoolTaskExecutor executor = config.mangtoolTaskExecutor();
assertNotNull(executor);
assertEquals("mangtool-async-", executor.getThreadNamePrefix());
assertTrue(executor.getCorePoolSize() >= 1);
assertTrue(executor.getMaxPoolSize() >= executor.getCorePoolSize());
executor.shutdown();
}
@Test
void getAsyncExecutorReturnsExplicitExecutor() {
Executor executor = config.getAsyncExecutor();
assertNotNull(executor, "@Async 应有明确的默认执行器,避免多候选无法选择");
assertTrue(executor instanceof ThreadPoolTaskExecutor);
((ThreadPoolTaskExecutor) executor).shutdown();
}
@Test
void getAsyncUncaughtExceptionHandlerPresent() {
assertNotNull(config.getAsyncUncaughtExceptionHandler(),
"应提供异常处理器以保留异步任务异常可观测性");
}
@Test
void beanNameConstantMatchesFactoryMethod() throws Exception {
// @Bean 方法名即 bean 名称,需与供 @Async 引用的常量一致
Method m = AsyncConfig.class.getMethod(AsyncConfig.TASK_EXECUTOR_BEAN_NAME);
assertNotNull(m, "bean 名称常量应对应工厂方法名: " + AsyncConfig.TASK_EXECUTOR_BEAN_NAME);
}
@Test
void asyncAnnotationsReferenceExplicitExecutor() {
// 抽查关键的 @Async 服务方法,确认显式引用了应用级 executor
assertAsyncQualifier(com.music.service.IngestService.class, "ingest");
assertAsyncQualifier(com.music.service.ConvertService.class, "convert");
}
private static void assertAsyncQualifier(Class<?> serviceClass, String methodName) {
Method target = null;
for (Method m : serviceClass.getDeclaredMethods()) {
if (m.getName().equals(methodName)) {
target = m;
break;
}
}
assertNotNull(target, "应找到方法 " + methodName);
org.springframework.scheduling.annotation.Async async =
target.getAnnotation(org.springframework.scheduling.annotation.Async.class);
assertNotNull(async, methodName + " 应带 @Async 注解");
assertEquals(AsyncConfig.TASK_EXECUTOR_BEAN_NAME, async.value(),
methodName + " 的 @Async 应显式引用应用级 taskExecutor");
}
}
@@ -10,6 +10,7 @@ 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;
@@ -436,6 +437,115 @@ class AudioValidationServiceTest {
}
}
// ========== 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;
}
// ========== 帮助方法 ==========
/**
@@ -18,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.nio.file.Paths;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
@@ -28,8 +29,11 @@ import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
/**
* 一键导入服务端到端测试 —— 使用临时文件系统验证完整的导入管线行为。
@@ -1210,8 +1214,447 @@ class IngestServiceE2ETest {
}
}
// ========== 5. FFprobe 元数据后备(jaudiotagger 无法解析的合法 M4A/AAC ==========
@Test
void jaudiotaggerUnreadableM4a_ffprobeFallbackIngested() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-fallback-");
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);
// M4A 容器但扩展名为 .aac → jaudiotagger 无 AAC reader 而拒绝,
// FFprobe 能内容探测并读取完整 Title/Artist/Album
Path src = createMp4ContainerAsAac(inputDir, "legit.aac",
"Fb Title", "Fb Artist", "Fb Album");
assertJaudiotaggerCannotRead(src);
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,
"jaudiotagger 无法解析但 FFprobe 提供完整元数据的 M4A 应入库");
assertEquals(1, ingested.get());
assertEquals(0, unreadable.get());
assertEquals(0, missingMeta.get());
Path expectedDir = libDir.resolve("Fb Artist").resolve("Fb Album");
assertTrue(Files.isDirectory(expectedDir), "应创建 Artist/Album 目录");
boolean found = Files.list(expectedDir).anyMatch(p ->
p.getFileName().toString().equals("01 - Fb Title.m4a"));
assertTrue(found, "后备 remux 输出应为 01 - Fb Title.m4a");
assertFalse(Files.exists(src), "源文件应在 remux 后被删除");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void jaudiotaggerUnreadableM4a_missingAlbumRejectedMissingMetadata() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-fallback-");
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);
// 仅 title/artist,无 album → 后备读取后仍应归类 MissingMetadata
Path src = createMp4ContainerAsAac(inputDir, "noalbum.aac",
"Only Title", "Only Artist", null);
assertJaudiotaggerCannotRead(src);
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:missing-metadata", result,
"后备读取缺 Album 应归类 MissingMetadata");
assertEquals(1, missingMeta.get());
assertEquals(0, unreadable.get());
assertTrue(Files.exists(rejDir.resolve("MissingMetadata").resolve("noalbum.aac")));
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void jaudiotaggerUnreadable_ffprobeAlsoFails_rejectedUnreadable() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-fallback-");
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);
// 非音频内容但扩展名 .m4a → jaudiotagger 与 FFprobe 都无法识别
Path src = inputDir.resolve("garbage.m4a");
Files.write(src, "definitely not audio".getBytes(StandardCharsets.UTF_8));
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,
"FFprobe 也无法识别时应归类 Unreadable");
assertEquals(1, unreadable.get());
assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("garbage.m4a")));
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void fallbackPath_stillRunsDuplicateDetection() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-fallback-");
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 = createMp4ContainerAsAac(inputDir, "dup1.aac",
"Dup Title", "Dup Artist", "Dup Album");
Path src2 = createMp4ContainerAsAac(inputDir, "dup2.aac",
"Dup Title", "Dup Artist", "Dup Album");
IngestService service = buildServiceWithValidation();
Set<Object> libraryIds = new HashSet<>();
Set<Object> batchIds = new HashSet<>();
String r1 = invokeProcessSingleFile(service, src1, libDir, rejDir,
libraryIds, batchIds,
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", r1, "首个后备文件应入库");
AtomicInteger unreadable2 = new AtomicInteger();
AtomicInteger duplicates2 = new AtomicInteger();
String r2 = invokeProcessSingleFile(service, src2, libDir, rejDir,
libraryIds, batchIds,
new AtomicInteger(), duplicates2,
new AtomicInteger(), unreadable2,
new AtomicInteger(), new AtomicInteger());
assertEquals("rejected:duplicate", r2,
"后备路径应继续执行去重检测");
assertEquals(1, duplicates2.get());
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void fallbackPath_stillRunsDecodeValidation() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-fallback-");
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);
// 合法 M4Ajaudiotagger 无法读,FFprobe 元数据齐全)→ 后备路径应被触发
Path src = createMp4ContainerAsAac(inputDir, "legit.aac",
"Dec Title", "Dec Artist", "Dec Album");
assertJaudiotaggerCannotRead(src);
// 用受控的 AudioValidationService 替身:
// - readMetadata 走真实实现,确保后备元数据“确实可用”(前置成立)
// - validate() 强制失败,隔离出“完整解码校验”这一步作为唯一失败点
AudioValidationService realAvs = new AudioValidationService();
AudioValidationService avs = spy(realAvs);
doReturn(AudioValidationService.ValidationResult.invalid("模拟解码失败"))
.when(avs).validate(any(Path.class));
// 前置断言:后备元数据确实可用且完整
AudioValidationService.ProbeMetadata pre = avs.readMetadata(src);
assertTrue(pre.isAvailable(), "前置条件:FFprobe 后备元数据应可用");
assertEquals("Dec Title", pre.getFirst("title"));
assertEquals("Dec Artist", pre.getFirst("artist"));
assertEquals("Dec Album", pre.getFirst("album"));
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService(),
mock(ConfigService.class),
avs);
AtomicInteger ingested = new AtomicInteger();
AtomicInteger unreadable = new AtomicInteger();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
ingested, new AtomicInteger(),
new AtomicInteger(), unreadable,
new AtomicInteger(), new AtomicInteger());
assertEquals("rejected:unreadable", result,
"后备/remux 成功但完整解码校验失败时应归类 Unreadable");
assertEquals(1, unreadable.get());
assertEquals(0, ingested.get());
assertTrue(Files.exists(rejDir.resolve("Unreadable").resolve("legit.aac")),
"解码校验失败的源文件应进入 Rejected/Unreadable");
// 派生的临时 remux 产物应被清理
assertTrue(Files.list(inputDir).noneMatch(p ->
p.getFileName().toString().endsWith(".fallback.m4a")),
"解码校验失败后派生的 remux 产物应被清理");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void fallbackRemux_producesAudioOnlyOutputDroppingVideo() throws Exception {
assumeFfmpeg();
Path tmpDir = Files.createTempDirectory("ingest-remux-map-");
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);
// 构造包含真实视频流 + 音频流的 .aac(MP4 容器)文件
Path src = createMp4WithVideoAndAudio(inputDir, "withvideo.aac",
"Map Title", "Map Artist", "Map Album");
assertJaudiotaggerCannotRead(src);
// 前置:源文件确有视频流
assertTrue(streamCodecTypes(src).contains("video"),
"前置条件:源文件应包含视频流");
IngestService service = buildServiceWithValidation();
AtomicInteger ingested = new AtomicInteger();
String result = invokeProcessSingleFile(service, src, libDir, rejDir,
new HashSet<>(), new HashSet<>(),
ingested, new AtomicInteger(),
new AtomicInteger(), new AtomicInteger(),
new AtomicInteger(), new AtomicInteger());
assertEquals("ingested", result, "含视频流的合法音频应经后备 remux 入库");
Path out = libDir.resolve("Map Artist").resolve("Map Album")
.resolve("01 - Map Title.m4a");
assertTrue(Files.exists(out), "应生成入库的 M4A: " + out);
java.util.List<String> outTypes = streamCodecTypes(out);
assertTrue(outTypes.contains("audio"), "输出应保留音频流");
assertFalse(outTypes.contains("video"),
"输出不应携带普通视频流(应为干净的音乐资产),实际: " + outTypes);
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void buildRemuxCommand_mapsOnlyFirstAudioStream() throws Exception {
IngestService service = buildServiceWithValidation();
Method m = IngestService.class.getDeclaredMethod("buildRemuxCommand",
Path.class, Path.class, String.class, String.class, String.class, String.class);
m.setAccessible(true);
@SuppressWarnings("unchecked")
java.util.List<String> cmd = (java.util.List<String>) m.invoke(service,
Paths.get("/tmp/in.aac"), Paths.get("/tmp/out.m4a"),
"T", "A", "Al", "AA");
// 必须显式仅映射第一个音频流,且不得使用无限制的 -map 0
int mapIdx = cmd.indexOf("-map");
assertTrue(mapIdx >= 0, "应包含 -map 参数");
assertEquals("0:a:0", cmd.get(mapIdx + 1), "应仅映射第一个音频流");
// 确认没有任何 '-map 0' 无限制映射
for (int i = 0; i < cmd.size() - 1; i++) {
if ("-map".equals(cmd.get(i))) {
assertNotEquals("0", cmd.get(i + 1),
"不得使用无限制的 -map 0(会带入视频/字幕/数据流)");
}
}
assertTrue(cmd.contains("copy"), "应无损复制音频(-c copy");
}
@Test
void runFfmpegDrained_doesNotDeadlockOnVerboseOutput() throws Exception {
// 使用产生大量输出的可控进程验证并发排空不会因管道写满而死锁。
// 该测试不依赖 ffmpeg,直接反射调用 runFfmpegDrained。
Path tmpDir = Files.createTempDirectory("ffmpeg-drain-");
try {
IngestService service = buildService();
Method m = IngestService.class.getDeclaredMethod("runFfmpegDrained",
java.util.List.class, int.class, Path.class, String.class);
m.setAccessible(true);
// 产生远超管道缓冲区(通常 64KB)的输出:约 2MB
java.util.List<String> cmd = Arrays.asList(
"sh", "-c",
"i=0; while [ $i -lt 40000 ]; do " +
"echo 'noisy ffmpeg style diagnostic line to fill the pipe buffer'; " +
"i=$((i+1)); done");
long start = System.currentTimeMillis();
// 成功退出(exit 0)→ 不抛异常;若死锁则会一直阻塞直到超时
m.invoke(service, cmd, 30, null, "test");
long elapsed = System.currentTimeMillis() - start;
assertTrue(elapsed < 25_000,
"并发排空应快速完成而非因管道写满死锁,耗时(ms)=" + elapsed);
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void runFfmpegDrained_nonZeroExitCleansOutputAndThrows() throws Exception {
Path tmpDir = Files.createTempDirectory("ffmpeg-drain-fail-");
try {
IngestService service = buildService();
Method m = IngestService.class.getDeclaredMethod("runFfmpegDrained",
java.util.List.class, int.class, Path.class, String.class);
m.setAccessible(true);
Path output = tmpDir.resolve("out.m4a");
Files.write(output, "partial".getBytes(StandardCharsets.UTF_8));
// 命令先产生大量输出再以非零退出,验证:不死锁、清理输出、抛异常
java.util.List<String> cmd = Arrays.asList(
"sh", "-c",
"i=0; while [ $i -lt 20000 ]; do echo 'noise line'; i=$((i+1)); done; exit 3");
try {
m.invoke(service, cmd, 30, output, "test");
fail("非零退出应抛出异常");
} catch (java.lang.reflect.InvocationTargetException e) {
assertTrue(e.getCause() instanceof RuntimeException,
"应抛出 RuntimeException,实际: " + e.getCause());
assertTrue(e.getCause().getMessage().contains("退出码"),
"异常应包含退出码信息");
}
assertFalse(Files.exists(output), "失败时应清理输出文件");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 工具方法 ==========
/**
* 创建一个 MP4/M4A 容器但使用 .aac 扩展名的文件。
* <p>jaudiotagger 按扩展名分派 reader,会因缺少 AAC reader 而拒绝;
* FFprobe 通过内容探测能识别并读取完整元数据。用于触发后备读取路径。</p>
*/
private static Path createMp4ContainerAsAac(Path dir, String name,
String title, String artist, String album) throws Exception {
Path file = dir.resolve(name);
java.util.List<String> cmd = new java.util.ArrayList<>(Arrays.asList(
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", "0.3", "-c:a", "aac", "-f", "mp4"));
if (title != null) { cmd.add("-metadata"); cmd.add("title=" + title); }
if (artist != null) { cmd.add("-metadata"); cmd.add("artist=" + artist); }
if (album != null) { cmd.add("-metadata"); cmd.add("album=" + album); }
cmd.add(file.toAbsolutePath().toString());
runProcessChecked(new ProcessBuilder(cmd), "ffmpeg");
return file;
}
/**
* 创建一个同时包含真实视频流与音频流的 MP4/M4A 容器(.aac 扩展名)。
* 用于验证后备 remux 仅保留音频、丢弃普通视频流。
*/
private static Path createMp4WithVideoAndAudio(Path dir, String name,
String title, String artist, String album) throws Exception {
Path file = dir.resolve(name);
java.util.List<String> cmd = new java.util.ArrayList<>(Arrays.asList(
"ffmpeg", "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-f", "lavfi", "-i", "testsrc=s=64x64:d=0.3",
"-map", "0:a", "-map", "1:v",
"-c:a", "aac", "-c:v", "libx264",
"-shortest", "-t", "0.3", "-f", "mp4"));
if (title != null) { cmd.add("-metadata"); cmd.add("title=" + title); }
if (artist != null) { cmd.add("-metadata"); cmd.add("artist=" + artist); }
if (album != null) { cmd.add("-metadata"); cmd.add("album=" + album); }
cmd.add(file.toAbsolutePath().toString());
runProcessChecked(new ProcessBuilder(cmd), "ffmpeg");
return file;
}
/**
* 用 ffprobe 返回文件中所有流的 codec_type 列表。
*/
private static java.util.List<String> streamCodecTypes(Path file) throws Exception {
ProcessBuilder pb = new ProcessBuilder(
"ffprobe", "-v", "error",
"-show_entries", "stream=codec_type",
"-of", "default=nw=1:nk=1",
file.toAbsolutePath().toString());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readStream(p.getInputStream());
p.waitFor(10, TimeUnit.SECONDS);
java.util.List<String> types = new java.util.ArrayList<>();
for (String line : out.split("\\R")) {
String t = line.trim();
if (!t.isEmpty()) types.add(t);
}
return types;
}
/**
* 断言 jaudiotagger 无法读取该文件(无 tag 或抛异常),确认后备路径确会被触发。
*/
private static void assertJaudiotaggerCannotRead(Path file) {
boolean readable;
try {
AudioFile af = AudioFileIO.read(file.toFile());
readable = af.getTag() != null;
} catch (Exception e) {
readable = false;
}
assertFalse(readable, "前置条件:jaudiotagger 应无法读取该文件的标签");
}
private IngestService buildService() {
return new IngestService(
mock(SimpMessagingTemplate.class),