feat: productize ingest lifecycle and library health

This commit is contained in:
2026-07-21 08:56:38 +08:00
parent f5033c7020
commit f4f4ab092f
23 changed files with 2556 additions and 38 deletions
@@ -93,6 +93,49 @@ public class IngestService {
@Autowired(required = false)
private LyricsService lyricsService;
/** 任务生命周期持久化与取消(可选注入;测试构造器不设置时为 null,行为不变)。 */
@Autowired(required = false)
private IngestTaskStore taskStore;
/**
* 当前 ingest 运行的歌词统计收集器;仅由 {@link #ingest} 在循环前设置、结束后清空。
* <p>直接调用 {@link #processSingleFile}(测试)时为 null,此时不记录(不影响返回值/行为)。
* ingest 由 running 锁保证同一时刻仅一个任务,故单实例字段安全。</p>
*/
private volatile LyricRun lyricRun;
/** 歌词来源分类(用于报告观测)。 */
static final String LYRIC_SOURCE_SIDECAR = "sidecar";
static final String LYRIC_SOURCE_EMBEDDED = "embedded";
static final String LYRIC_SOURCE_REMOTE = "remote";
static final String LYRIC_SOURCE_NONE = "none";
/** 歌词状态分类:found/missing/failed。 */
static final String LYRIC_STATUS_FOUND = "found";
static final String LYRIC_STATUS_MISSING = "missing";
static final String LYRIC_STATUS_FAILED = "failed";
/**
* 单次 ingest 运行的歌词聚合。歌词失败绝不影响音频入库,此处仅作观测统计。
* 循环单线程执行,{@code lastSource/lastStatus} 在每个文件处理前后读取一次,无并发问题。
*/
static final class LyricRun {
final AtomicInteger found = new AtomicInteger();
final AtomicInteger missing = new AtomicInteger();
final AtomicInteger failed = new AtomicInteger();
volatile String lastSource;
volatile String lastStatus;
void reset() { lastSource = null; lastStatus = null; }
void record(String source, String status) {
lastSource = source;
lastStatus = status;
if (LYRIC_STATUS_FOUND.equals(status)) found.incrementAndGet();
else if (LYRIC_STATUS_FAILED.equals(status)) failed.incrementAndGet();
else missing.incrementAndGet();
}
}
/**
* Spring DI 构造器。
* <p>包级 4 参数构造器保留给测试,此时 {@code audioValidationService} 为 null
@@ -215,17 +258,33 @@ public class IngestService {
AtomicInteger missingCover = new AtomicInteger(0);
AtomicInteger processed = new AtomicInteger(0);
// 歌词统计收集器(歌词失败不影响入库,仅用于报告/进度观测)
LyricRun run = new LyricRun();
this.lyricRun = run;
// 登记任务生命周期(持久化,供重启恢复与取消)
if (taskStore != null) {
taskStore.begin(taskId, total);
}
// 批内重复检测集合
Set<IdentityKey> batchIdentities = new HashSet<>();
// 文件结果映射(用于报告)
LinkedHashMap<String, String> fileOutcomes = new LinkedHashMap<>();
// 文件结果映射(用于报告)outcome + 歌词来源/状态
LinkedHashMap<String, FileReport> fileOutcomes = new LinkedHashMap<>();
boolean cancelled = false;
for (Path srcFile : audioFiles) {
// 取消检查:仅停止后续文件处理,已完成(已移动)的文件保持不动
if (taskStore != null && taskStore.isCancelRequested(taskId)) {
cancelled = true;
break;
}
String fileName = srcFile.getFileName().toString();
// 使用相对于 Input 的路径作为唯一跟踪键(防止不同子目录中的同名文件互相覆盖)
String relativeKey = inputPath.relativize(srcFile).toString();
String outcome = "unknown";
run.reset();
try {
outcome = processSingleFile(srcFile, libraryPath, rejectedPath,
libraryIdentities, batchIdentities,
@@ -237,36 +296,71 @@ public class IngestService {
outcome = "rejected:exception";
log.warn("处理文件异常: {} - {}", relativeKey, e.getMessage());
}
fileOutcomes.put(relativeKey, outcome);
fileOutcomes.put(relativeKey, new FileReport(outcome, run.lastSource, run.lastStatus));
if (taskStore != null) {
taskStore.recordProcessed(taskId, relativeKey, outcome, run.lastSource, run.lastStatus,
new IngestTaskStore.Counters(
ingested.get(), duplicates.get(), missingMeta.get(),
unreadable.get(), convFailed.get(), otherRejected.get(),
missingCover.get(),
run.found.get(), run.missing.get(), run.failed.get()));
}
int p = processed.incrementAndGet();
sendProgress(taskId, total, p, ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(), relativeKey,
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(),
relativeKey,
String.format("已处理 (%d/%d): %s", p, total, relativeKey),
false);
}
// 写入结构化报告
// 写入结构化报告(无论是否取消,都记录已处理部分)
writeReport(taskId, fileOutcomes, rejectedPath, ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(), otherRejected.get(),
missingCover.get(), cleanupRemoved);
missingCover.get(), cleanupRemoved,
run.found.get(), run.missing.get(), run.failed.get());
if (cancelled) {
if (taskStore != null) {
taskStore.markCancelled(taskId, "任务已取消");
}
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(), null,
String.format("任务已取消:已处理 %d/%d,已入库 %d(已完成文件保持不动)",
processed.get(), total, ingested.get()),
true);
return;
}
// 完成
if (taskStore != null) {
taskStore.complete(taskId, "导入完成");
}
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(), null,
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 缺封面: %d, 不可读: %d, 转码失败: %d, 其他: %d, 清理: %d",
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(), null,
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 缺封面: %d, 不可读: %d, 转码失败: %d, 其他: %d, 清理: %d, 歌词(有/无/失败): %d/%d/%d",
ingested.get(), duplicates.get(), missingMeta.get(), missingCover.get(),
unreadable.get(), convFailed.get(), otherRejected.get(), cleanupRemoved),
unreadable.get(), convFailed.get(), otherRejected.get(), cleanupRemoved,
run.found.get(), run.missing.get(), run.failed.get()),
true);
} catch (Exception e) {
log.error("导入任务执行失败", e);
// 持久化任务标记为 failed(若已 begin),避免重启后残留 running 状态
if (taskStore != null) {
taskStore.markFailed(taskId, "任务内部错误: " + e.getMessage());
}
// 发送 terminal 进度消息,确保前端停止轮询并显示错误
sendProgress(taskId, 0, 0, 0, 0, 0, 0, 0, 0,
null, "任务内部错误: " + e.getMessage(), true);
} finally {
this.lyricRun = null;
if (runningLock != null) {
runningLock.set(false);
}
@@ -644,14 +738,50 @@ public class IngestService {
ingested.incrementAndGet();
// 处理关联 LRC 文件(可选)
handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
boolean sidecarLrc = handleAssociatedLrc(srcFile, targetDir, baseName, destFileName);
// 提取嵌入式歌词(可选,仅 jaudiotagger 路径有 tag 对象)
boolean embeddedLrc = false;
if (tag != null) {
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
embeddedLrc = extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
}
if (lyricsService != null) {
lyricsService.fetchIfMissing(targetDir.resolve(destFileName), title, effectiveArtist);
// 远程歌词兜底(可选)。任何失败均非致命,仅记录为 lyricsFailed,绝不改变入库结果。
String lyricSource;
String lyricStatus;
if (sidecarLrc) {
lyricSource = LYRIC_SOURCE_SIDECAR;
lyricStatus = LYRIC_STATUS_FOUND;
} else if (embeddedLrc) {
lyricSource = LYRIC_SOURCE_EMBEDDED;
lyricStatus = LYRIC_STATUS_FOUND;
} else if (lyricsService != null) {
LyricsService.RemoteOutcome ro =
lyricsService.resolveRemote(targetDir.resolve(destFileName), title, effectiveArtist);
switch (ro) {
case FETCHED:
lyricSource = LYRIC_SOURCE_REMOTE;
lyricStatus = LYRIC_STATUS_FOUND;
break;
case EXISTS:
lyricSource = LYRIC_SOURCE_SIDECAR;
lyricStatus = LYRIC_STATUS_FOUND;
break;
case FAILED:
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_FAILED;
break;
default: // MISSING / DISABLED
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_MISSING;
break;
}
} else {
lyricSource = LYRIC_SOURCE_NONE;
lyricStatus = LYRIC_STATUS_MISSING;
}
if (lyricRun != null) {
lyricRun.record(lyricSource, lyricStatus);
}
return "ingested";
@@ -752,14 +882,15 @@ public class IngestService {
// ========== 关联文件处理 ==========
/**
* 将同名 .lrc 文件随音频一起移入目标目录
* 将同名 .lrc 文件随音频一起移入目标目录
* @return true 表示确实移动了一个侧车 .lrc
*/
private void handleAssociatedLrc(Path srcFile, Path targetDir, String baseName, String destFileName) {
private boolean handleAssociatedLrc(Path srcFile, Path targetDir, String baseName, String destFileName) {
Path lrcSource = srcFile.resolveSibling(baseName + ".lrc");
if (!Files.exists(lrcSource)) {
// 也检查小写扩展名
lrcSource = srcFile.resolveSibling(baseName + ".LRC");
if (!Files.exists(lrcSource)) return;
if (!Files.exists(lrcSource)) return false;
}
try {
@@ -767,19 +898,22 @@ public class IngestService {
Path lrcTarget = resolveUniqueFile(targetDir, lrcDestName);
FileTransferUtils.moveWithFallback(lrcSource, lrcTarget);
log.info("已移动关联 LRC 文件: {} -> {}", lrcSource.getFileName(), lrcTarget);
return true;
} catch (IOException e) {
log.warn("移动 LRC 文件失败: {} - {}", lrcSource, e.getMessage());
return false;
}
}
/**
* 从标签中提取嵌入式歌词(可选),写入 LRC 文件
* 从标签中提取嵌入式歌词(可选),写入 LRC 文件
* @return true 表示确实写出了一个内嵌歌词 .lrc
*/
private void extractEmbeddedLyrics(Tag tag, Path targetDir, String trackStr,
private boolean extractEmbeddedLyrics(Tag tag, Path targetDir, String trackStr,
String safeTitle, String originalTitle, String artist) {
try {
String lyrics = tag.getFirst(FieldKey.LYRICS);
if (lyrics == null || lyrics.trim().isEmpty()) return;
if (lyrics == null || lyrics.trim().isEmpty()) return false;
// 构建简易 LRC 内容(无时间戳,仅作为歌词文本)
StringBuilder lrcContent = new StringBuilder();
@@ -792,8 +926,10 @@ public class IngestService {
Path lrcFile = resolveUniqueFile(targetDir, lrcFileName);
Files.write(lrcFile, lrcContent.toString().getBytes(StandardCharsets.UTF_8));
log.info("已提取嵌入式歌词到: {}", lrcFile);
return true;
} catch (Exception e) {
log.debug("提取嵌入式歌词失败(可选,忽略): {}", e.getMessage());
return false;
}
}
@@ -802,11 +938,25 @@ public class IngestService {
/**
* 写入 JSON 格式的结构化报告
*/
private void writeReport(String taskId, LinkedHashMap<String, String> fileOutcomes,
/** 单文件报告条目:处理结果 + 歌词来源/状态(歌词字段可为 null,仅入库文件有值)。 */
static final class FileReport {
final String outcome;
final String lyricSource;
final String lyricStatus;
FileReport(String outcome, String lyricSource, String lyricStatus) {
this.outcome = outcome;
this.lyricSource = lyricSource;
this.lyricStatus = lyricStatus;
}
}
private void writeReport(String taskId, LinkedHashMap<String, FileReport> fileOutcomes,
Path rejectedPath, int ingestedCount, int duplicateCount,
int missingMetaCount, int unreadableCount,
int convFailedCount, int otherRejectedCount,
int missingCoverCount, int cleanupRemovedCount) {
int missingCoverCount, int cleanupRemovedCount,
int lyricsFoundCount, int lyricsMissingCount, int lyricsFailedCount) {
try {
Path reportsDir = rejectedPath.resolve(REPORT_SUBDIR);
Files.createDirectories(reportsDir);
@@ -829,15 +979,25 @@ public class IngestService {
json.append(" \"otherRejected\": ").append(otherRejectedCount).append(",\n");
// 新增字段:追加在已有字段之后,保持既有消费者向后兼容
json.append(" \"missingCover\": ").append(missingCoverCount).append(",\n");
json.append(" \"libraryCleanupRemoved\": ").append(cleanupRemovedCount).append("\n");
json.append(" \"libraryCleanupRemoved\": ").append(cleanupRemovedCount).append(",\n");
json.append(" \"lyricsFound\": ").append(lyricsFoundCount).append(",\n");
json.append(" \"lyricsMissing\": ").append(lyricsMissingCount).append(",\n");
json.append(" \"lyricsFailed\": ").append(lyricsFailedCount).append("\n");
json.append(" },\n");
json.append(" \"files\": [\n");
int idx = 0;
int size = fileOutcomes.size();
for (Map.Entry<String, String> entry : fileOutcomes.entrySet()) {
for (Map.Entry<String, FileReport> entry : fileOutcomes.entrySet()) {
FileReport fr = entry.getValue();
json.append(" {\"file\": \"").append(escapeJson(entry.getKey()));
json.append("\", \"outcome\": \"").append(escapeJson(entry.getValue())).append("\"}");
json.append("\", \"outcome\": \"").append(escapeJson(fr.outcome)).append("\"");
// 歌词字段仅对入库文件有值;追加在已有字段之后,保持向后兼容
if (fr.lyricStatus != null) {
json.append(", \"lyricStatus\": \"").append(escapeJson(fr.lyricStatus)).append("\"");
json.append(", \"lyricSource\": \"").append(escapeJson(fr.lyricSource)).append("\"");
}
json.append("}");
if (++idx < size) json.append(",");
json.append("\n");
}
@@ -1449,6 +1609,17 @@ public class IngestService {
int missingMetadataFiles, int unreadableFiles,
int conversionFailedFiles, int otherRejectedFiles,
String currentFile, String message, boolean completed) {
sendProgress(taskId, total, processed, ingestedFiles, duplicateFiles,
missingMetadataFiles, unreadableFiles, conversionFailedFiles, otherRejectedFiles,
null, null, null, currentFile, message, completed);
}
private void sendProgress(String taskId, int total, int processed,
int ingestedFiles, int duplicateFiles,
int missingMetadataFiles, int unreadableFiles,
int conversionFailedFiles, int otherRejectedFiles,
Integer lyricsFound, Integer lyricsMissing, Integer lyricsFailed,
String currentFile, String message, boolean completed) {
try {
ProgressMessage pm = new ProgressMessage();
pm.setTaskId(taskId);
@@ -1463,6 +1634,9 @@ public class IngestService {
pm.setUnreadableFiles(unreadableFiles);
pm.setConversionFailedFiles(conversionFailedFiles);
pm.setOtherRejectedFiles(otherRejectedFiles);
pm.setLyricsFound(lyricsFound);
pm.setLyricsMissing(lyricsMissing);
pm.setLyricsFailed(lyricsFailed);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);