Template
feat: productize ingest lifecycle and library health
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user