feat: fetch and backfill cover art

This commit is contained in:
2026-07-21 14:23:07 +08:00
parent f4f4ab092f
commit b21a5d0ed1
21 changed files with 1156 additions and 46 deletions
@@ -92,6 +92,25 @@ public class IngestService {
private final AudioValidationService audioValidationService;
@Autowired(required = false)
private LyricsService lyricsService;
@org.springframework.beans.factory.annotation.Autowired(required = false)
private CoverArtService coverArtService;
private volatile CoverRun coverRun;
static final class CoverRun {
final java.util.concurrent.atomic.AtomicInteger found = new java.util.concurrent.atomic.AtomicInteger();
final java.util.concurrent.atomic.AtomicInteger missing = new java.util.concurrent.atomic.AtomicInteger();
final java.util.concurrent.atomic.AtomicInteger failed = new java.util.concurrent.atomic.AtomicInteger();
volatile String lastSource;
volatile String lastStatus;
void reset() { lastSource = null; lastStatus = null; }
void record(String source, String status) {
lastSource = source;
lastStatus = status;
if ("found".equals(status)) found.incrementAndGet();
else if ("failed".equals(status)) failed.incrementAndGet();
else missing.incrementAndGet();
}
}
/** 任务生命周期持久化与取消(可选注入;测试构造器不设置时为 null,行为不变)。 */
@Autowired(required = false)
@@ -258,10 +277,12 @@ public class IngestService {
AtomicInteger missingCover = new AtomicInteger(0);
AtomicInteger processed = new AtomicInteger(0);
// 歌词统计收集器(歌词失败不影响入库,仅用于报告/进度观测)
LyricRun run = new LyricRun();
this.lyricRun = run;
CoverRun cr = new CoverRun();
this.coverRun = cr;
// 登记任务生命周期(持久化,供重启恢复与取消)
if (taskStore != null) {
taskStore.begin(taskId, total);
@@ -281,10 +302,10 @@ public class IngestService {
break;
}
String fileName = srcFile.getFileName().toString();
// 使用相对于 Input 的路径作为唯一跟踪键(防止不同子目录中的同名文件互相覆盖)
String relativeKey = inputPath.relativize(srcFile).toString();
String outcome = "unknown";
run.reset();
if (coverRun != null) coverRun.reset();
try {
outcome = processSingleFile(srcFile, libraryPath, rejectedPath,
libraryIdentities, batchIdentities,
@@ -296,14 +317,16 @@ public class IngestService {
outcome = "rejected:exception";
log.warn("处理文件异常: {} - {}", relativeKey, e.getMessage());
}
fileOutcomes.put(relativeKey, new FileReport(outcome, run.lastSource, run.lastStatus));
fileOutcomes.put(relativeKey, new FileReport(outcome, run.lastSource, run.lastStatus, cr.lastSource, cr.lastStatus));
if (taskStore != null) {
taskStore.recordProcessed(taskId, relativeKey, outcome, run.lastSource, run.lastStatus,
cr.lastSource, cr.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()));
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get()));
}
int p = processed.incrementAndGet();
@@ -311,6 +334,7 @@ public class IngestService {
missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get(),
relativeKey,
String.format("已处理 (%d/%d): %s", p, total, relativeKey),
false);
@@ -320,7 +344,8 @@ public class IngestService {
writeReport(taskId, fileOutcomes, rejectedPath, ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(), otherRejected.get(),
missingCover.get(), cleanupRemoved,
run.found.get(), run.missing.get(), run.failed.get());
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get());
if (cancelled) {
if (taskStore != null) {
@@ -329,7 +354,8 @@ public class IngestService {
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,
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get(), null,
String.format("任务已取消:已处理 %d/%d,已入库 %d(已完成文件保持不动)",
processed.get(), total, ingested.get()),
true);
@@ -343,11 +369,13 @@ public class IngestService {
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, 缺封面: %d, 不可读: %d, 转码失败: %d, 其他: %d, 清理: %d, 歌词(有/无/失败): %d/%d/%d",
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get(), null,
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 缺封面: %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,
run.found.get(), run.missing.get(), run.failed.get()),
run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get()),
true);
} catch (Exception e) {
@@ -361,6 +389,7 @@ public class IngestService {
null, "任务内部错误: " + e.getMessage(), true);
} finally {
this.lyricRun = null;
coverRun = null;
if (runningLock != null) {
runningLock.set(false);
}
@@ -695,10 +724,40 @@ public class IngestService {
// (后备 remux 使用 -map 0:a:0 会丢弃 attached picture,故须从原始 srcFile 提取)。
boolean albumAlreadyCovered = hasExistingCover(targetDir);
Path writtenCover = null;
String coverSource = "none";
String coverStatus = "missing";
if (!albumAlreadyCovered) {
writtenCover = acquireCover(srcFile, tag, targetDir);
if (writtenCover != null) {
coverSource = "embedded";
coverStatus = "found";
} else if (coverArtService != null) {
CoverArtService.RemoteOutcome ro = coverArtService.resolveRemote(targetDir, effectiveAlbum, effectiveArtist, year);
if (ro == CoverArtService.RemoteOutcome.FETCHED) {
Path existing = findExistingCover(targetDir);
try {
if (existing != null && Files.size(existing) > 0) {
writtenCover = existing;
coverSource = "remote";
coverStatus = "found";
} else {
writtenCover = null;
coverStatus = "failed";
}
} catch (IOException e) {
writtenCover = null;
coverStatus = "failed";
}
} else if (ro == CoverArtService.RemoteOutcome.FAILED) {
coverStatus = "failed";
}
}
if (writtenCover == null) {
// 无既有封面且无法提取内嵌封面 → MissingCover;清理派生临时文件,隔离源文件
if (coverRun != null) coverRun.record(coverSource, coverStatus);
if (derivedFile) {
deleteIfExists(effectiveFile);
}
@@ -707,8 +766,13 @@ public class IngestService {
log.info("缺少封面(无既有 cover 且无内嵌封面): {}", fileName);
return "rejected:missing-cover";
}
} else {
coverSource = "existing";
coverStatus = "found";
}
if (coverRun != null) coverRun.record(coverSource, coverStatus);
// 移动/复制到目标位置
try {
FileTransferUtils.moveWithFallback(effectiveFile, targetFile);
@@ -943,11 +1007,15 @@ public class IngestService {
final String outcome;
final String lyricSource;
final String lyricStatus;
final String coverSource;
final String coverStatus;
FileReport(String outcome, String lyricSource, String lyricStatus) {
FileReport(String outcome, String lyricSource, String lyricStatus, String coverSource, String coverStatus) {
this.outcome = outcome;
this.lyricSource = lyricSource;
this.lyricStatus = lyricStatus;
this.coverSource = coverSource;
this.coverStatus = coverStatus;
}
}
@@ -956,7 +1024,8 @@ public class IngestService {
int missingMetaCount, int unreadableCount,
int convFailedCount, int otherRejectedCount,
int missingCoverCount, int cleanupRemovedCount,
int lyricsFoundCount, int lyricsMissingCount, int lyricsFailedCount) {
int lyricsFoundCount, int lyricsMissingCount, int lyricsFailedCount,
int coversFoundCount, int coversMissingCount, int coversFailedCount) {
try {
Path reportsDir = rejectedPath.resolve(REPORT_SUBDIR);
Files.createDirectories(reportsDir);
@@ -982,7 +1051,10 @@ public class IngestService {
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(" \"lyricsFailed\": ").append(lyricsFailedCount).append(",\n");
json.append(" \"coversFound\": ").append(coversFoundCount).append(",\n");
json.append(" \"coversMissing\": ").append(coversMissingCount).append(",\n");
json.append(" \"coversFailed\": ").append(coversFailedCount).append("\n");
json.append(" },\n");
json.append(" \"files\": [\n");
@@ -997,6 +1069,10 @@ public class IngestService {
json.append(", \"lyricStatus\": \"").append(escapeJson(fr.lyricStatus)).append("\"");
json.append(", \"lyricSource\": \"").append(escapeJson(fr.lyricSource)).append("\"");
}
if (fr.coverStatus != null) {
json.append(", \"coverStatus\": \"").append(escapeJson(fr.coverStatus)).append("\"");
json.append(", \"coverSource\": \"").append(escapeJson(fr.coverSource)).append("\"");
}
json.append("}");
if (++idx < size) json.append(",");
json.append("\n");
@@ -1611,7 +1687,7 @@ public class IngestService {
String currentFile, String message, boolean completed) {
sendProgress(taskId, total, processed, ingestedFiles, duplicateFiles,
missingMetadataFiles, unreadableFiles, conversionFailedFiles, otherRejectedFiles,
null, null, null, currentFile, message, completed);
null, null, null, null, null, null, currentFile, message, completed);
}
private void sendProgress(String taskId, int total, int processed,
@@ -1619,6 +1695,7 @@ public class IngestService {
int missingMetadataFiles, int unreadableFiles,
int conversionFailedFiles, int otherRejectedFiles,
Integer lyricsFound, Integer lyricsMissing, Integer lyricsFailed,
Integer coversFound, Integer coversMissing, Integer coversFailed,
String currentFile, String message, boolean completed) {
try {
ProgressMessage pm = new ProgressMessage();
@@ -1637,6 +1714,9 @@ public class IngestService {
pm.setLyricsFound(lyricsFound);
pm.setLyricsMissing(lyricsMissing);
pm.setLyricsFailed(lyricsFailed);
pm.setCoversFound(coversFound);
pm.setCoversMissing(coversMissing);
pm.setCoversFailed(coversFailed);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);