Template
Fix M4A fallback ingestion
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package com.music.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
|
||||
import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.AsyncConfigurer;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* 应用级异步执行配置。
|
||||
*
|
||||
* <p>显式声明名为 {@value #TASK_EXECUTOR_BEAN_NAME} 的 {@link ThreadPoolTaskExecutor},
|
||||
* 并作为 {@link AsyncConfigurer#getAsyncExecutor()} 的默认 {@code @Async} 执行器。
|
||||
* 这样可消除多个候选 {@code TaskExecutor}(如 WebSocket message broker 通道执行器、
|
||||
* Spring Boot 自动配置的 {@code applicationTaskExecutor})导致 {@code @Async} 无法选择
|
||||
* 而记录的警告,同时保留任务终态与异常可观测性。</p>
|
||||
*
|
||||
* <p>各 {@code @Async} 方法显式引用 {@value #TASK_EXECUTOR_BEAN_NAME},
|
||||
* 避免依赖隐式的按类型/按名称解析。</p>
|
||||
*/
|
||||
@Configuration
|
||||
public class AsyncConfig implements AsyncConfigurer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);
|
||||
|
||||
/** 应用级异步执行器 bean 名称(供 {@code @Async("...")} 引用) */
|
||||
public static final String TASK_EXECUTOR_BEAN_NAME = "mangtoolTaskExecutor";
|
||||
|
||||
@Bean(name = TASK_EXECUTOR_BEAN_NAME)
|
||||
public ThreadPoolTaskExecutor mangtoolTaskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(4);
|
||||
executor.setQueueCapacity(50);
|
||||
executor.setThreadNamePrefix("mangtool-async-");
|
||||
// 关闭时等待正在执行的任务完成,保证任务终态可被观测
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
executor.setAwaitTerminationSeconds(30);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Executor getAsyncExecutor() {
|
||||
return mangtoolTaskExecutor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||||
return (throwable, method, params) -> {
|
||||
log.error("异步任务 {} 执行抛出未捕获异常", method.getName(), throwable);
|
||||
new SimpleAsyncUncaughtExceptionHandler()
|
||||
.handleUncaughtException(throwable, method, params);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.music.service;
|
||||
import com.music.common.FileTransferUtils;
|
||||
import com.music.dto.ProgressMessage;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import com.music.config.AsyncConfig;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -30,7 +31,7 @@ public class AggregatorService {
|
||||
/**
|
||||
* 异步执行音频文件汇聚任务
|
||||
*/
|
||||
@Async
|
||||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||||
public void aggregate(String taskId, String srcDir, String dstDir, String mode) {
|
||||
Path sourcePath = Paths.get(srcDir);
|
||||
Path targetPath = Paths.get(dstDir);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -10,7 +12,10 @@ import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
@@ -51,11 +56,16 @@ public class AudioValidationService {
|
||||
static final int DEFAULT_DECODE_TIMEOUT_SECONDS = 300;
|
||||
/** 保留诊断信息最大长度 */
|
||||
static final int MAX_DIAGNOSTIC_LENGTH = 512;
|
||||
/** FFprobe 元数据 JSON 输出最大捕获字节数(远大于诊断上限,容纳完整机器可读输出) */
|
||||
static final int MAX_METADATA_JSON_LENGTH = 1024 * 1024;
|
||||
/** 流读取缓冲区大小 */
|
||||
private static final int BUFFER_SIZE = 4096;
|
||||
/** 回收子进程 / 合并 drainer 线程的最长等待秒数 */
|
||||
private static final int CLEANUP_WAIT_SECONDS = 5;
|
||||
|
||||
/** 解析 FFprobe 结构化 JSON 输出的线程安全共享实例 */
|
||||
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
|
||||
|
||||
// ========== 嵌套结果类 ==========
|
||||
|
||||
/**
|
||||
@@ -90,6 +100,59 @@ public class AudioValidationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 嵌套元数据结果类 ==========
|
||||
|
||||
/**
|
||||
* FFprobe 后备元数据读取结果。
|
||||
* <p>{@link #isAvailable()} 表示 FFprobe 成功识别音频流并返回了 format tag 元数据;
|
||||
* 仅在此情况下 {@link #getTags()} 才有意义。字段值均取自 FFprobe 结构化 JSON 输出,
|
||||
* 不做任何文件名猜测或人类可读文本解析。</p>
|
||||
*/
|
||||
public static class ProbeMetadata {
|
||||
private final boolean available;
|
||||
private final String diagnostic;
|
||||
private final Map<String, String> tags;
|
||||
|
||||
ProbeMetadata(boolean available, String diagnostic, Map<String, String> tags) {
|
||||
this.available = available;
|
||||
this.diagnostic = diagnostic != null ? diagnostic : "";
|
||||
this.tags = tags != null ? tags : new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
return available;
|
||||
}
|
||||
|
||||
public String getDiagnostic() {
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回小写键 → 值的标签映射(键取自 FFprobe format.tags)。
|
||||
*/
|
||||
public Map<String, String> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按候选键列表返回第一个非空标签值(键不区分大小写)。
|
||||
*/
|
||||
public String getFirst(String... keys) {
|
||||
for (String key : keys) {
|
||||
if (key == null) continue;
|
||||
String v = tags.get(key.toLowerCase());
|
||||
if (v != null && !v.trim().isEmpty()) {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static ProbeMetadata unavailable(String diagnostic) {
|
||||
return new ProbeMetadata(false, diagnostic, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 嵌套进程运行结果 ==========
|
||||
|
||||
/**
|
||||
@@ -195,6 +258,116 @@ public class AudioValidationService {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 元数据后备读取(FFprobe 结构化 JSON) ==========
|
||||
|
||||
/**
|
||||
* 返回 FFprobe 元数据读取命令参数列表(结构化 JSON 输出,用于测试观察)。
|
||||
* <p>使用 {@code -print_format json} + {@code -show_format} + {@code -show_streams},
|
||||
* 仅解析机器可读 JSON,绝不解析人类可读文本或文件名。</p>
|
||||
*/
|
||||
List<String> getMetadataCommandArgs(Path audioFile) {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(getFfprobeCommand());
|
||||
cmd.add("-v");
|
||||
cmd.add("error");
|
||||
cmd.add("-print_format");
|
||||
cmd.add("json");
|
||||
cmd.add("-show_format");
|
||||
cmd.add("-show_streams");
|
||||
cmd.add(audioFile.toAbsolutePath().toString());
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 FFprobe 结构化 JSON 输出读取音频元数据(jaudiotagger 无法解析时的后备路径)。
|
||||
* <p>要求文件至少含一个可识别的音频流,且能返回 {@code format.tags} 或
|
||||
* {@code streams[].tags}。标签键统一小写化后存入 {@link ProbeMetadata#getTags()}。
|
||||
* 仅解析 JSON,绝不进行文件名猜测或人类可读文本解析。</p>
|
||||
*
|
||||
* @param audioFile 待读取的音频文件路径
|
||||
* @return 后备元数据结果;{@link ProbeMetadata#isAvailable()} 为 false 表示
|
||||
* FFprobe 无法识别音频或读取失败(调用方应归类为 Unreadable)
|
||||
*/
|
||||
public ProbeMetadata readMetadata(Path audioFile) {
|
||||
int timeout = resolveProbeTimeout();
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = runProcess(getMetadataCommandArgs(audioFile), timeout, MAX_METADATA_JSON_LENGTH);
|
||||
} catch (IOException e) {
|
||||
log.warn("FFprobe 元数据读取执行失败: {} - {}", audioFile, e.getMessage());
|
||||
return ProbeMetadata.unavailable("FFprobe 执行失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
if (result.timedOut) {
|
||||
log.warn("FFprobe 元数据读取超时: {} ({}s)", audioFile, timeout);
|
||||
return ProbeMetadata.unavailable("元数据读取超时");
|
||||
}
|
||||
if (result.exitCode != 0) {
|
||||
String diag = capDiagnostic(result.output);
|
||||
log.warn("FFprobe 元数据读取失败: {} (exit={}) diag={}", audioFile, result.exitCode, diag);
|
||||
return ProbeMetadata.unavailable("FFprobe 读取失败: " + diag);
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode root = JSON_MAPPER.readTree(result.output);
|
||||
if (!hasAudioStream(root)) {
|
||||
log.warn("FFprobe 后备未发现音频流: {}", audioFile);
|
||||
return ProbeMetadata.unavailable("文件中未发现可识别的音频流");
|
||||
}
|
||||
|
||||
Map<String, String> tags = new LinkedHashMap<>();
|
||||
// 优先 format.tags
|
||||
collectTags(root.path("format").path("tags"), tags);
|
||||
// 再补充第一个音频流的 tags(不覆盖已有 format 值)
|
||||
JsonNode streams = root.path("streams");
|
||||
if (streams.isArray()) {
|
||||
for (JsonNode stream : streams) {
|
||||
if ("audio".equals(stream.path("codec_type").asText())) {
|
||||
collectTags(stream.path("tags"), tags);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ProbeMetadata(true, "", tags);
|
||||
} catch (IOException e) {
|
||||
log.warn("FFprobe JSON 解析失败: {} - {}", audioFile, e.getMessage());
|
||||
return ProbeMetadata.unavailable("FFprobe JSON 解析失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 FFprobe JSON 中是否存在音频流。
|
||||
*/
|
||||
private static boolean hasAudioStream(JsonNode root) {
|
||||
JsonNode streams = root.path("streams");
|
||||
if (!streams.isArray()) return false;
|
||||
for (JsonNode stream : streams) {
|
||||
if ("audio".equals(stream.path("codec_type").asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON tags 对象的字段收集到映射中(键小写化,已存在的键不覆盖)。
|
||||
*/
|
||||
private static void collectTags(JsonNode tagsNode, Map<String, String> target) {
|
||||
if (tagsNode == null || !tagsNode.isObject()) return;
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = tagsNode.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = fields.next();
|
||||
String key = entry.getKey();
|
||||
if (key == null) continue;
|
||||
String lower = key.toLowerCase();
|
||||
if (target.containsKey(lower)) continue;
|
||||
String value = entry.getValue().asText("");
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
target.put(lower, value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 解码(FFmpeg,严格模式) ==========
|
||||
|
||||
/**
|
||||
@@ -261,10 +434,20 @@ public class AudioValidationService {
|
||||
*/
|
||||
private static ProcessResult runProcess(List<String> command, int timeoutSeconds)
|
||||
throws IOException {
|
||||
return runProcess(command, timeoutSeconds, MAX_DIAGNOSTIC_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行一个外部进程,并发读取合并后的标准输出,最多保留 {@code maxCapture} 字节。
|
||||
* <p>诊断类调用使用 {@link #MAX_DIAGNOSTIC_LENGTH};需要完整机器可读输出
|
||||
* (如 FFprobe JSON)时传入更大的上限。超出上限的数据仍被读取以排空管道。</p>
|
||||
*/
|
||||
private static ProcessResult runProcess(List<String> command, int timeoutSeconds, int maxCapture)
|
||||
throws IOException {
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
DrainResult drain = startConcurrentDrainer(p);
|
||||
DrainResult drain = startConcurrentDrainer(p, maxCapture);
|
||||
|
||||
boolean interrupted = false;
|
||||
boolean finished;
|
||||
@@ -311,19 +494,19 @@ public class AudioValidationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动守护线程并发读取进程标准输出(合并后),保留前 {@link #MAX_DIAGNOSTIC_LENGTH}
|
||||
* 字节为诊断信息,继续读取剩余数据以排空管道,防止子进程因管道写满而阻塞或收到 SIGPIPE。
|
||||
* 启动守护线程并发读取进程标准输出(合并后),保留前 {@code maxCapture}
|
||||
* 字节,继续读取剩余数据以排空管道,防止子进程因管道写满而阻塞或收到 SIGPIPE。
|
||||
*/
|
||||
private static DrainResult startConcurrentDrainer(Process p) {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream(MAX_DIAGNOSTIC_LENGTH);
|
||||
private static DrainResult startConcurrentDrainer(Process p, int maxCapture) {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream(Math.min(maxCapture, BUFFER_SIZE));
|
||||
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();
|
||||
if (buffer.size() < maxCapture) {
|
||||
int remaining = maxCapture - buffer.size();
|
||||
buffer.write(buf, 0, Math.min(n, remaining));
|
||||
}
|
||||
// buffer 已满后继续读取以排空管道,丢弃多余数据
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.music.dto.ProgressMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import com.music.config.AsyncConfig;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -46,7 +47,7 @@ public class ConvertService {
|
||||
this.progressStore = progressStore;
|
||||
}
|
||||
|
||||
@Async
|
||||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||||
public void convert(String taskId, String srcDir, String dstDir, String mode) {
|
||||
Path srcPath = Paths.get(srcDir);
|
||||
Path dstPath = Paths.get(dstDir);
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.jaudiotagger.tag.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import com.music.config.AsyncConfig;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -52,7 +53,7 @@ public class DedupService {
|
||||
/**
|
||||
* 异步执行去重任务
|
||||
*/
|
||||
@Async
|
||||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||||
public void dedup(String taskId,
|
||||
String libraryDir,
|
||||
String trashDir,
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import com.music.config.AsyncConfig;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -77,6 +78,10 @@ public class IngestService {
|
||||
private static final int FFMPEG_COMPRESSION_LEVEL = 5;
|
||||
private static final int FFMPEG_CHECK_TIMEOUT_SECONDS = 10;
|
||||
private static final int FFMPEG_CONVERT_TIMEOUT_SECONDS = 600;
|
||||
/** FFmpeg 合并输出保留的诊断字节上限(超出继续读取以排空管道) */
|
||||
private static final int FFMPEG_DIAGNOSTIC_CAP = 4096;
|
||||
/** 强制终止后回收子进程 / 合并 drainer 的最长等待秒数 */
|
||||
private static final int FFMPEG_CLEANUP_WAIT_SECONDS = 5;
|
||||
private static final String FFMPEG_BIN_PROPERTY = "mangtool.ffmpeg.bin";
|
||||
private static final String REPORT_SUBDIR = "Reports";
|
||||
|
||||
@@ -122,7 +127,7 @@ public class IngestService {
|
||||
* @param taskId 唯一任务标识
|
||||
* @param runningLock 由控制器传入的 AtomicBoolean,任务结束时置为 false
|
||||
*/
|
||||
@Async
|
||||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||||
public void ingest(String taskId, AtomicBoolean runningLock) {
|
||||
try {
|
||||
String inputDir = configService.getInputDir();
|
||||
@@ -306,37 +311,76 @@ public class IngestService {
|
||||
String fileName = srcFile.getFileName().toString();
|
||||
String baseName = getBaseName(fileName);
|
||||
|
||||
// 1. 读取元数据
|
||||
AudioFile audioFile;
|
||||
Tag tag;
|
||||
// 1. 读取元数据:优先 jaudiotagger;受支持的 M4A/MP4 无法解析时回退 FFprobe 结构化输出
|
||||
AudioFile audioFile = null;
|
||||
Tag tag = null;
|
||||
boolean usedFallback = false;
|
||||
|
||||
String title;
|
||||
String artist;
|
||||
String album;
|
||||
String albumArtist;
|
||||
String yearStr;
|
||||
String trackRaw;
|
||||
String discRaw;
|
||||
|
||||
boolean jaudioOk = false;
|
||||
String jaudioError = null;
|
||||
try {
|
||||
audioFile = AudioFileIO.read(srcFile.toFile());
|
||||
tag = audioFile.getTag();
|
||||
if (tag == null) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
return "rejected:unreadable";
|
||||
jaudioOk = (tag != null);
|
||||
if (!jaudioOk) {
|
||||
jaudioError = "标签为空";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("无法读取文件元数据: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:unreadable";
|
||||
jaudioError = e.getMessage();
|
||||
}
|
||||
|
||||
// 2. 读取关键字段
|
||||
String title = trim(tag.getFirst(FieldKey.TITLE));
|
||||
String artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||
String album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||
if (jaudioOk) {
|
||||
title = trim(tag.getFirst(FieldKey.TITLE));
|
||||
artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||
album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||
albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||
yearStr = safeGetFirst(tag, FieldKey.YEAR);
|
||||
trackRaw = safeGetFirst(tag, FieldKey.TRACK);
|
||||
discRaw = safeGetFirst(tag, FieldKey.DISC_NO);
|
||||
} else {
|
||||
// jaudiotagger 无法解析:仅对受支持的 M4A/MP4 且启用了 FFprobe 校验服务时回退
|
||||
if (audioValidationService == null || !isFallbackEligible(srcFile)) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("无法读取文件元数据: {} - {}", fileName, jaudioError);
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
AudioValidationService.ProbeMetadata meta = audioValidationService.readMetadata(srcFile);
|
||||
if (!meta.isAvailable()) {
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("FFprobe 后备元数据读取失败: {} - {}", fileName, meta.getDiagnostic());
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
usedFallback = true;
|
||||
title = meta.getFirst("title");
|
||||
artist = meta.getFirst("artist");
|
||||
album = meta.getFirst("album");
|
||||
albumArtist = meta.getFirst("album_artist", "albumartist");
|
||||
yearStr = meta.getFirst("date", "year");
|
||||
trackRaw = meta.getFirst("track");
|
||||
discRaw = meta.getFirst("disc", "discnumber");
|
||||
log.info("jaudiotagger 无法解析,改用 FFprobe 后备读取元数据: {}", fileName);
|
||||
}
|
||||
|
||||
// 3. 校验 Title/Artist/Album 非空
|
||||
// 2. 校验 Title/Artist/Album 非空(任一为空 → MissingMetadata)
|
||||
if (title.isEmpty() || artist.isEmpty() || album.isEmpty()) {
|
||||
missingMeta.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "MissingMetadata", fileName);
|
||||
return "rejected:missing-metadata";
|
||||
}
|
||||
|
||||
// 4. 繁简转换文本标签
|
||||
// 3. 繁简转换文本标签并写回源文件(仅 jaudiotagger 路径;
|
||||
// FFprobe 后备路径稍后用 FFmpeg 无损 remux 写入简体标签)
|
||||
if (jaudioOk) {
|
||||
boolean tagsModified = false;
|
||||
for (FieldKey key : TEXT_FIELDS) {
|
||||
String value = trim(tag.getFirst(key));
|
||||
@@ -348,18 +392,6 @@ public class IngestService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对 albumArtist 也进行繁简转换
|
||||
String albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||
if (!albumArtist.isEmpty()) {
|
||||
String converted = traditionalFilterService.toSimplified(albumArtist);
|
||||
if (!converted.equals(albumArtist)) {
|
||||
tag.setField(FieldKey.ALBUM_ARTIST, converted);
|
||||
tagsModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 将简化后的标签写回源文件
|
||||
if (tagsModified) {
|
||||
try {
|
||||
audioFile.commit();
|
||||
@@ -370,16 +402,12 @@ public class IngestService {
|
||||
return "rejected:tag-write-failed";
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 提取其他可选字段(WavTag 等实现可能不支持 YEAR/TRACK/DISC_NO,使用安全读取)
|
||||
String yearStr = safeGetFirst(tag, FieldKey.YEAR);
|
||||
String trackRaw = safeGetFirst(tag, FieldKey.TRACK);
|
||||
String discRaw = safeGetFirst(tag, FieldKey.DISC_NO);
|
||||
}
|
||||
|
||||
int trackNum = parseInt(trackRaw, 0);
|
||||
int discNum = parseInt(discRaw, 0);
|
||||
|
||||
// 7. 计算 MD5 用于去重兜底
|
||||
// 4. 计算 MD5 用于去重兜底
|
||||
String fileMd5 = computeMd5(srcFile);
|
||||
|
||||
// 8. 构建归一化身份标识
|
||||
@@ -399,7 +427,7 @@ public class IngestService {
|
||||
return "rejected:duplicate";
|
||||
}
|
||||
|
||||
// 10. 格式转换(如需要)
|
||||
// 5. 格式转换 / 后备 remux(如需要)
|
||||
Path effectiveFile = srcFile;
|
||||
boolean needsConversion = isLosslessFormat(srcFile);
|
||||
if (needsConversion) {
|
||||
@@ -412,13 +440,33 @@ public class IngestService {
|
||||
log.warn("转码失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:conversion-failed";
|
||||
}
|
||||
} else if (usedFallback) {
|
||||
// jaudiotagger 无法写回此容器的标签:用 FFmpeg 无损 remux(-c copy)
|
||||
// 生成保留音频质量与原有元数据的 Navidrome 兼容输出,并写入简体 Title/Artist/Album。
|
||||
String simpTitle = traditionalFilterService.toSimplified(title);
|
||||
String simpArtist = traditionalFilterService.toSimplified(artist);
|
||||
String simpAlbum = traditionalFilterService.toSimplified(album);
|
||||
String simpAlbumArtist = albumArtist.isEmpty()
|
||||
? "" : traditionalFilterService.toSimplified(albumArtist);
|
||||
try {
|
||||
Path remuxed = remuxWithSimplifiedTags(srcFile, simpTitle, simpArtist,
|
||||
simpAlbum, simpAlbumArtist);
|
||||
effectiveFile = remuxed;
|
||||
} catch (Exception e) {
|
||||
convFailed.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "ConversionFailed", fileName);
|
||||
log.warn("后备 remux 失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:conversion-failed";
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 音频完整性验证:确保有效文件可被 FFprobe 识别且 FFmpeg 可完整解码
|
||||
boolean derivedFile = !effectiveFile.equals(srcFile);
|
||||
|
||||
// 6. 音频完整性验证:确保有效文件可被 FFprobe 识别且 FFmpeg 可完整解码
|
||||
if (audioValidationService != null) {
|
||||
ValidationResult vr = audioValidationService.validate(effectiveFile);
|
||||
if (!vr.isValid()) {
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
@@ -428,9 +476,35 @@ public class IngestService {
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 重新打开有效文件,重新检查 Title/Artist/Album
|
||||
// (确保转换过程或标签持久化未导致 Navidrome 必需的元数据丢失)
|
||||
// 7. 重新检查有效文件的 Title/Artist/Album(确保转换/remux/标签持久化未丢失必需元数据)
|
||||
if (audioValidationService != null) {
|
||||
if (usedFallback) {
|
||||
// 后备路径:jaudiotagger 仍无法解析该容器,改用 FFprobe 再次读取校验
|
||||
AudioValidationService.ProbeMetadata reMeta =
|
||||
audioValidationService.readMetadata(effectiveFile);
|
||||
String vTitle = reMeta.isAvailable() ? reMeta.getFirst("title") : "";
|
||||
String vArtist = reMeta.isAvailable() ? reMeta.getFirst("artist") : "";
|
||||
String vAlbum = reMeta.isAvailable() ? reMeta.getFirst("album") : "";
|
||||
if (vTitle.isEmpty() || vArtist.isEmpty() || vAlbum.isEmpty()) {
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
moveToRejected(srcFile, rejectedPath, "Unreadable", fileName);
|
||||
log.warn("后备 remux 后元数据缺失: {}(Title='{}' Artist='{}' Album='{}')",
|
||||
fileName, vTitle, vArtist, vAlbum);
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
title = vTitle;
|
||||
artist = vArtist;
|
||||
album = vAlbum;
|
||||
albumArtist = reMeta.getFirst("album_artist", "albumartist");
|
||||
yearStr = reMeta.getFirst("date", "year");
|
||||
trackRaw = reMeta.getFirst("track");
|
||||
discRaw = reMeta.getFirst("disc", "discnumber");
|
||||
trackNum = parseInt(trackRaw, 0);
|
||||
discNum = parseInt(discRaw, 0);
|
||||
} else {
|
||||
try {
|
||||
AudioFile validatedAudio = AudioFileIO.read(effectiveFile.toFile());
|
||||
Tag validatedTag = validatedAudio.getTag();
|
||||
@@ -438,7 +512,7 @@ public class IngestService {
|
||||
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)) {
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
@@ -459,7 +533,7 @@ public class IngestService {
|
||||
trackNum = parseInt(trackRaw, 0);
|
||||
discNum = parseInt(discRaw, 0);
|
||||
} catch (Exception e) {
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
}
|
||||
unreadable.incrementAndGet();
|
||||
@@ -468,10 +542,18 @@ public class IngestService {
|
||||
return "rejected:unreadable";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 13. 确定最终格式与文件名
|
||||
String ext = needsConversion ? "flac" : getExtension(fileName);
|
||||
// 确定最终格式与文件名(后备 remux 统一输出 m4a 容器)
|
||||
String ext;
|
||||
if (needsConversion) {
|
||||
ext = "flac";
|
||||
} else if (usedFallback) {
|
||||
ext = "m4a";
|
||||
} else {
|
||||
ext = getExtension(fileName);
|
||||
if (ext == null) ext = "flac";
|
||||
}
|
||||
|
||||
// 12. 构建目标路径
|
||||
String effectiveArtist = !albumArtist.isEmpty()
|
||||
@@ -491,40 +573,39 @@ public class IngestService {
|
||||
String destFileName = trackStr + " - " + safeTitle + "." + ext;
|
||||
Path targetFile = resolveUniqueFile(targetDir, destFileName);
|
||||
|
||||
// 13. 移动/复制到目标位置
|
||||
// 8. 移动/复制到目标位置
|
||||
try {
|
||||
FileTransferUtils.moveWithFallback(effectiveFile, targetFile);
|
||||
} catch (IOException e) {
|
||||
otherRejected.incrementAndGet();
|
||||
// 如果经过转换,删除临时 FLAC 并隔离源文件
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
// 如果经过转换/remux,删除派生文件并隔离源文件
|
||||
if (derivedFile) {
|
||||
deleteIfExists(effectiveFile);
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
} else {
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
}
|
||||
moveToRejected(srcFile, rejectedPath, "Other", fileName);
|
||||
log.warn("移动文件到 Library 失败: {} - {}", fileName, e.getMessage());
|
||||
return "rejected:move-failed";
|
||||
}
|
||||
|
||||
// 14. 如果进行了格式转换且成功,删除原文件
|
||||
if (needsConversion && !effectiveFile.equals(srcFile)) {
|
||||
// 9. 如果生成了派生文件(转换或后备 remux)且成功,删除原文件
|
||||
if (derivedFile) {
|
||||
deleteIfExists(srcFile);
|
||||
}
|
||||
|
||||
// 15. 更新身份标识集合
|
||||
// 10. 更新身份标识集合
|
||||
libraryIdentities.add(identity);
|
||||
batchIdentities.add(identity);
|
||||
ingested.incrementAndGet();
|
||||
|
||||
// 16. 处理关联 LRC 文件(可选)
|
||||
// 11. 处理关联 LRC 文件(可选)
|
||||
handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
|
||||
|
||||
// 17. 提取嵌入式歌词(可选)写入 LRC
|
||||
// 12. 提取嵌入式歌词与封面(可选,仅 jaudiotagger 路径有 tag 对象;
|
||||
// 后备 remux 已保留原始嵌入式封面/歌词于输出容器中)
|
||||
if (tag != null) {
|
||||
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
|
||||
|
||||
// 18. 提取封面(可选)
|
||||
extractCover(tag, targetDir);
|
||||
}
|
||||
|
||||
return "ingested";
|
||||
}
|
||||
@@ -732,30 +813,19 @@ public class IngestService {
|
||||
Path output = outputDir.resolve(baseName + ".flac");
|
||||
output = resolveUniqueFile(outputDir, baseName + ".flac");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
List<String> cmd = new ArrayList<>(Arrays.asList(
|
||||
getFfmpegCommand(),
|
||||
"-y",
|
||||
"-i", input.toAbsolutePath().toString(),
|
||||
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
|
||||
output.toAbsolutePath().toString()
|
||||
);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
boolean finished = p.waitFor(FFMPEG_CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
p.destroyForcibly();
|
||||
deleteIfExists(output);
|
||||
throw new RuntimeException("ffmpeg 转码超时(" + FFMPEG_CONVERT_TIMEOUT_SECONDS + "s)");
|
||||
}
|
||||
int exit = p.exitValue();
|
||||
if (exit != 0) {
|
||||
deleteIfExists(output);
|
||||
throw new RuntimeException("ffmpeg 退出码: " + exit);
|
||||
}
|
||||
));
|
||||
runFfmpegDrained(cmd, FFMPEG_CONVERT_TIMEOUT_SECONDS, output, "转码");
|
||||
return output;
|
||||
}
|
||||
|
||||
private void deleteIfExists(Path path) {
|
||||
if (path == null) return;
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
@@ -763,6 +833,159 @@ public class IngestService {
|
||||
}
|
||||
}
|
||||
|
||||
/** FFprobe 后备读取仅对这些容器格式启用(jaudiotagger 常拒绝的合法 AAC/M4A/MP4) */
|
||||
private static final Set<String> FALLBACK_ELIGIBLE_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"m4a", "mp4", "aac", "m4b", "m4p"
|
||||
));
|
||||
|
||||
/**
|
||||
* 判断文件是否适用 FFprobe 元数据后备读取路径。
|
||||
* 仅限受支持的 M4A/MP4/AAC 容器,避免对其他格式做无意义的后备。
|
||||
*/
|
||||
private boolean isFallbackEligible(Path file) {
|
||||
String ext = getExtension(file.getFileName().toString());
|
||||
return ext != null && FALLBACK_ELIGIBLE_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 FFmpeg 无损 remux({@code -c copy})生成保留音频质量与原有元数据的
|
||||
* Navidrome 兼容 M4A 输出,并写入简体 Title/Artist/Album(及可选 Album Artist)。
|
||||
* <p>用于 jaudiotagger 无法安全写回该容器标签、且不能修改原文件的场景,
|
||||
* 避免因写回失败而误拒绝合法音频。不伪造缺失的必要元数据。</p>
|
||||
* <p>输出为独立临时文件;调用方负责在失败或验证不通过时清理。</p>
|
||||
*
|
||||
* @return 生成的 M4A 输出路径
|
||||
*/
|
||||
List<String> buildRemuxCommand(Path input, Path output, String title, String artist,
|
||||
String album, String albumArtist) {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(getFfmpegCommand());
|
||||
cmd.add("-y");
|
||||
cmd.add("-i");
|
||||
cmd.add(input.toAbsolutePath().toString());
|
||||
// 仅映射预期的第一个音频流,避免把普通视频/字幕/数据等流带入生成的 M4A,
|
||||
// 确保输出是干净的 Navidrome 兼容音乐资产。
|
||||
cmd.add("-map");
|
||||
cmd.add("0:a:0");
|
||||
cmd.add("-c");
|
||||
cmd.add("copy");
|
||||
// 保留原有 format 级元数据(不含未映射的流),随后覆盖简体标签
|
||||
cmd.add("-map_metadata");
|
||||
cmd.add("0");
|
||||
// 覆盖简体标签(不伪造缺失字段:调用方已确保三项必需字段非空)
|
||||
cmd.add("-metadata");
|
||||
cmd.add("title=" + title);
|
||||
cmd.add("-metadata");
|
||||
cmd.add("artist=" + artist);
|
||||
cmd.add("-metadata");
|
||||
cmd.add("album=" + album);
|
||||
if (albumArtist != null && !albumArtist.isEmpty()) {
|
||||
cmd.add("-metadata");
|
||||
cmd.add("album_artist=" + albumArtist);
|
||||
}
|
||||
cmd.add(output.toAbsolutePath().toString());
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private Path remuxWithSimplifiedTags(Path input, String title, String artist,
|
||||
String album, String albumArtist)
|
||||
throws IOException, InterruptedException {
|
||||
String baseName = getBaseName(input.getFileName().toString());
|
||||
Path output = resolveUniqueFile(input.getParent(), baseName + ".fallback.m4a");
|
||||
|
||||
List<String> cmd = buildRemuxCommand(input, output, title, artist, album, albumArtist);
|
||||
runFfmpegDrained(cmd, FFMPEG_CONVERT_TIMEOUT_SECONDS, output, "remux");
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行一个 FFmpeg 子进程,并发排空其合并后的标准输出,避免管道写满导致死锁;
|
||||
* 超时或中断时强制终止并有界回收,失败时清理输出文件。
|
||||
* <p>合并 stderr→stdout 后由守护线程持续读取(仅保留前 {@value #FFMPEG_DIAGNOSTIC_CAP}
|
||||
* 字节作诊断),超出部分丢弃以排空管道。非零退出或超时抛出 {@link RuntimeException}。</p>
|
||||
*
|
||||
* @param command 完整命令行
|
||||
* @param timeoutSeconds 超时秒数
|
||||
* @param outputToClean 失败/超时时需要删除的输出文件(可为 null)
|
||||
* @param label 日志/异常用的操作标签(如 "remux" / "转码")
|
||||
*/
|
||||
private void runFfmpegDrained(List<String> command, int timeoutSeconds,
|
||||
Path outputToClean, String label)
|
||||
throws IOException, InterruptedException {
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
|
||||
final StringBuilder diag = new StringBuilder();
|
||||
Thread drainer = new Thread(() -> {
|
||||
try (InputStream is = p.getInputStream()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = is.read(buf)) != -1) {
|
||||
synchronized (diag) {
|
||||
int remaining = FFMPEG_DIAGNOSTIC_CAP - diag.length();
|
||||
if (remaining > 0) {
|
||||
diag.append(new String(buf, 0, Math.min(n, remaining),
|
||||
StandardCharsets.UTF_8));
|
||||
}
|
||||
// 已达上限后继续读取以排空管道,丢弃多余数据
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// 进程终止后流关闭属正常情况
|
||||
}
|
||||
}, "ffmpeg-" + label + "-drainer");
|
||||
drainer.setDaemon(true);
|
||||
drainer.start();
|
||||
|
||||
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(FFMPEG_CLEANUP_WAIT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 先 join drainer 确保输出读尽,再判定结果
|
||||
try {
|
||||
drainer.join(TimeUnit.SECONDS.toMillis(FFMPEG_CLEANUP_WAIT_SECONDS));
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
|
||||
if (interrupted) {
|
||||
deleteIfExists(outputToClean);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new InterruptedException("ffmpeg " + label + " 被中断");
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
deleteIfExists(outputToClean);
|
||||
throw new RuntimeException("ffmpeg " + label + " 超时(" + timeoutSeconds + "s)");
|
||||
}
|
||||
|
||||
int exit = p.exitValue();
|
||||
if (exit != 0) {
|
||||
String tail;
|
||||
synchronized (diag) {
|
||||
tail = diag.toString().trim();
|
||||
}
|
||||
deleteIfExists(outputToClean);
|
||||
throw new RuntimeException("ffmpeg " + label + " 退出码: " + exit
|
||||
+ (tail.isEmpty() ? "" : " - " + tail));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 Library 目录中已有的音频文件,构建身份标识集合
|
||||
* (所有文本字段经过 t2s + lowercase 归一化,与 incoming 文件一致)
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.music.dto.ProgressMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import com.music.config.AsyncConfig;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -42,7 +43,7 @@ public class LibraryMergeService {
|
||||
this.progressStore = progressStore;
|
||||
}
|
||||
|
||||
@Async
|
||||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||||
public void merge(String taskId, String srcDir, String dstDir, boolean smartUpgrade, boolean keepBackup) {
|
||||
Path srcPath = Paths.get(srcDir);
|
||||
Path dstPath = Paths.get(dstDir);
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.jaudiotagger.tag.flac.FlacTag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import com.music.config.AsyncConfig;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -66,7 +67,7 @@ public class OrganizeService {
|
||||
this.pinyinFormat.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
|
||||
}
|
||||
|
||||
@Async
|
||||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||||
public void organize(String taskId, String srcDir, String dstDir, String mode,
|
||||
boolean extractCover, boolean extractLyrics, boolean generateReport) {
|
||||
Path srcPath = Paths.get(srcDir);
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.jaudiotagger.tag.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import com.music.config.AsyncConfig;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -64,7 +65,7 @@ public class ZhConvertService {
|
||||
* @param mode preview / execute
|
||||
* @param thresholdRatio 触发阈值(0.0-1.0)
|
||||
*/
|
||||
@Async
|
||||
@Async(AsyncConfig.TASK_EXECUTOR_BEAN_NAME)
|
||||
public void process(String taskId,
|
||||
String scanDir,
|
||||
String outputDir,
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 合法 M4A(jaudiotagger 无法读,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),
|
||||
|
||||
Reference in New Issue
Block a user