From b21a5d0ed19578b18a258d14bb6b735e5a1edc5c Mon Sep 17 00:00:00 2001
From: mangmang <362165265@qq.com>
Date: Tue, 21 Jul 2026 14:23:07 +0800
Subject: [PATCH] feat: fetch and backfill cover art
---
README.md | 15 +-
.../music/controller/IngestController.java | 8 +-
.../com/music/dto/IngestStatusResponse.java | 3 +
.../music/dto/LibraryHealthRepairRequest.java | 1 +
.../com/music/dto/LibraryHealthResult.java | 3 +
.../java/com/music/dto/ProgressMessage.java | 3 +
.../com/music/service/CoverArtService.java | 262 +++++++++++++++
.../java/com/music/service/IngestService.java | 108 ++++++-
.../com/music/service/IngestTaskStore.java | 27 +-
.../music/service/LibraryHealthService.java | 65 ++++
.../music/service/CoverArtServiceTest.java | 301 ++++++++++++++++++
.../music/service/IngestServiceE2ETest.java | 3 +-
.../com/music/service/IngestServiceTest.java | 182 +++++++++++
.../music/service/IngestTaskStoreTest.java | 16 +-
.../service/LibraryHealthServiceTest.java | 124 +++++++-
.../service/ProgressMessageMappingTest.java | 8 +-
docker/README.md | 17 +-
docker/docker-compose.yml | 4 +
frontend/src/api/ingest.ts | 3 +
frontend/src/api/progress.ts | 6 +
frontend/src/components/IngestTab.vue | 43 ++-
21 files changed, 1156 insertions(+), 46 deletions(-)
create mode 100644 backend/src/main/java/com/music/service/CoverArtService.java
create mode 100644 backend/src/test/java/com/music/service/CoverArtServiceTest.java
create mode 100644 backend/src/test/java/com/music/service/IngestServiceTest.java
diff --git a/README.md b/README.md
index fbfc15c..28f9ee6 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,13 @@ BasePath/
## Library 健康检查(只读,修复需确认)
- **只读扫描**:`POST /api/library/health/scan`(可选 `?checkDecode=true` 执行完整解码校验)统计缺元数据、缺封面、缺歌词、孤立侧车(无对应音频的 `.lrc`)与重复曲目,**绝不修改任何文件**。
-- **确认后修复**:`POST /api/library/health/repair` 需请求体 `confirm=true` 才会执行(歌词回填、删除孤立侧车、可选删除解码失败音频);`confirm=false` 仅演练,不改动磁盘。匹配到音频的侧车永不删除。
+- **确认后修复**:`POST /api/library/health/repair` 需请求体 `confirm=true` 才会执行(歌词回填、封面回填、删除孤立侧车、可选删除解码失败音频);`confirm=false` 仅演练,不改动磁盘。匹配到音频的侧车永不删除。
+
+**修复示例(含封面回填)**:
+```bash
+# 默认配置下不修改磁盘。加上 confirm=true 与 backfillCovers=true 即可安全回填封面与修复曲库
+curl -X POST -H "Content-Type: application/json" -d '{"confirm": true, "backfillCovers": true}' http://localhost:8080/api/library/health/repair
+```
## 备份与恢复
@@ -98,3 +104,10 @@ cd docker && docker compose up -d --build
- 音频元数据:jaudiotagger
- 繁简转换:opencc4j
- 格式转换:FFmpeg
+
+
+## Cover Art Settings
+- `MANGTOOL_COVER_ENABLED` (default: false) - Enable Cover Art Fetch
+- `MANGTOOL_MB_BASE_URL` (default: https://musicbrainz.org/ws/2)
+- `MANGTOOL_CAA_BASE_URL` (default: https://coverartarchive.org)
+- `MANGTOOL_MB_USER_AGENT` (default: MangTool/1.0 (https://gitea.mangmang.fun/LiuMangMang/MyTool)) - User-Agent for MusicBrainz API requests
diff --git a/backend/src/main/java/com/music/controller/IngestController.java b/backend/src/main/java/com/music/controller/IngestController.java
index 62e5ebf..294890c 100644
--- a/backend/src/main/java/com/music/controller/IngestController.java
+++ b/backend/src/main/java/com/music/controller/IngestController.java
@@ -95,7 +95,7 @@ public class IngestController {
}
if (tid == null) {
return Result.success(new IngestStatusResponse(null, false, false,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "暂无任务记录"));
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "暂无任务记录"));
}
return statusById(tid);
}
@@ -118,11 +118,12 @@ public class IngestController {
st.ingested, st.duplicates, st.missingMetadata, st.unreadable,
st.conversionFailed, st.otherRejected,
st.lyricsFound, st.lyricsMissing, st.lyricsFailed,
+ st.coversFound, st.coversMissing, st.coversFailed,
msg));
}
boolean isRunning = running.get();
return Result.success(new IngestStatusResponse(taskId, isRunning, !isRunning,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "未找到任务或已过期"));
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, "未找到任务或已过期"));
}
IngestStatusResponse resp = new IngestStatusResponse(
@@ -137,6 +138,9 @@ public class IngestController {
optInt(pm.getLyricsFound()),
optInt(pm.getLyricsMissing()),
optInt(pm.getLyricsFailed()),
+ optInt(pm.getCoversFound()),
+ optInt(pm.getCoversMissing()),
+ optInt(pm.getCoversFailed()),
pm.getMessage()
);
diff --git a/backend/src/main/java/com/music/dto/IngestStatusResponse.java b/backend/src/main/java/com/music/dto/IngestStatusResponse.java
index 55b79ef..3be3ab2 100644
--- a/backend/src/main/java/com/music/dto/IngestStatusResponse.java
+++ b/backend/src/main/java/com/music/dto/IngestStatusResponse.java
@@ -25,5 +25,8 @@ public class IngestStatusResponse {
private int lyricsFound;
private int lyricsMissing;
private int lyricsFailed;
+ private int coversFound;
+ private int coversMissing;
+ private int coversFailed;
private String message;
}
diff --git a/backend/src/main/java/com/music/dto/LibraryHealthRepairRequest.java b/backend/src/main/java/com/music/dto/LibraryHealthRepairRequest.java
index 5965312..2725442 100644
--- a/backend/src/main/java/com/music/dto/LibraryHealthRepairRequest.java
+++ b/backend/src/main/java/com/music/dto/LibraryHealthRepairRequest.java
@@ -14,6 +14,7 @@ public class LibraryHealthRepairRequest {
/** 为缺歌词的曲目远程回填歌词(非致命,失败仅统计)。 */
private boolean backfillLyrics;
+ private boolean backfillCovers;
/** 删除孤立侧车(无对应音频的 .lrc)。仅删除确为孤立的侧车。 */
private boolean removeOrphanSidecars;
diff --git a/backend/src/main/java/com/music/dto/LibraryHealthResult.java b/backend/src/main/java/com/music/dto/LibraryHealthResult.java
index 49858b9..68356bb 100644
--- a/backend/src/main/java/com/music/dto/LibraryHealthResult.java
+++ b/backend/src/main/java/com/music/dto/LibraryHealthResult.java
@@ -15,6 +15,9 @@ public class LibraryHealthResult {
private boolean applied;
private int lyricsBackfilled;
+ private int coversBackfilled;
+ private int coversMissing;
+ private int coverFailures;
private int orphanSidecarsRemoved;
private int decodeInvalidRemoved;
diff --git a/backend/src/main/java/com/music/dto/ProgressMessage.java b/backend/src/main/java/com/music/dto/ProgressMessage.java
index 104771e..76bd0f1 100644
--- a/backend/src/main/java/com/music/dto/ProgressMessage.java
+++ b/backend/src/main/java/com/music/dto/ProgressMessage.java
@@ -42,4 +42,7 @@ public class ProgressMessage {
private Integer lyricsFound; // ingest: 已获得歌词的入库文件数(侧车/内嵌/远程)
private Integer lyricsMissing; // ingest: 无歌词可用的入库文件数
private Integer lyricsFailed; // ingest: 歌词查询/写入出错的入库文件数
+ private Integer coversFound;
+ private Integer coversMissing;
+ private Integer coversFailed;
}
diff --git a/backend/src/main/java/com/music/service/CoverArtService.java b/backend/src/main/java/com/music/service/CoverArtService.java
new file mode 100644
index 0000000..0f4e438
--- /dev/null
+++ b/backend/src/main/java/com/music/service/CoverArtService.java
@@ -0,0 +1,262 @@
+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.Service;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.Locale;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.stream.ImageInputStream;
+import java.awt.image.BufferedImage;
+import java.util.Iterator;
+import java.util.ArrayList;
+import java.util.List;
+import java.nio.file.FileAlreadyExistsException;
+
+/** Optional cover art lookup from MusicBrainz and Cover Art Archive. */
+@Service
+public class CoverArtService {
+ private static final Logger log = LoggerFactory.getLogger(CoverArtService.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final int MAX_BYTES = 10 * 1024 * 1024; // 10MB max for cover
+ private static long lastMbRequestTime = 0;
+
+ private final ConfigService configService;
+
+ private static class CacheEntry {
+ public String mbid;
+ public long expireAt;
+ }
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ public enum RemoteOutcome { FETCHED, MISSING, FAILED, DISABLED }
+
+ public CoverArtService(ConfigService configService) {
+ this.configService = configService;
+ loadCache();
+ }
+
+ private String getCacheFile() {
+ String base = configService.getBasePath();
+ if (base == null || base.isEmpty()) {
+ return System.getProperty("java.io.tmpdir") + "/.mangtool/cover_cache.json";
+ }
+ return base + "/.mangtool/cover_cache.json";
+ }
+
+ public synchronized RemoteOutcome resolveRemote(Path targetDir, String album, String artist, String year) {
+ if (!enabled()) return RemoteOutcome.DISABLED;
+ if (targetDir == null || album == null || artist == null) return RemoteOutcome.MISSING;
+ String safeYear = year == null ? "" : year;
+ String key = norm(artist) + "|" + norm(album) + "|" + safeYear;
+
+ CacheEntry ce = cache.get(key);
+ if (ce != null) {
+ if (System.currentTimeMillis() > ce.expireAt) {
+ cache.remove(key);
+ } else {
+ if (ce.mbid.isEmpty()) return RemoteOutcome.MISSING; // Negative cache
+ return downloadFromCaa(ce.mbid, targetDir) ? RemoteOutcome.FETCHED : RemoteOutcome.FAILED;
+ }
+ }
+
+ try {
+ String mbid = lookupMbid(album, artist, safeYear);
+ if (mbid == null) {
+ putCache(key, "");
+ return RemoteOutcome.MISSING;
+ }
+ putCache(key, mbid);
+ return downloadFromCaa(mbid, targetDir) ? RemoteOutcome.FETCHED : RemoteOutcome.FAILED;
+ } catch (Exception e) {
+ log.warn("封面获取失败(不影响入库): {} - {} - {}", artist, album, e.getMessage());
+ return RemoteOutcome.FAILED;
+ }
+ }
+
+ private synchronized String lookupMbid(String album, String artist, String year) throws Exception {
+ String base = value("MANGTOOL_MB_BASE_URL", "mangtool.mb.base-url", "https://musicbrainz.org/ws/2");
+ String query = "artist:\"" + artist + "\" AND release:\"" + album + "\"";
+ String url = base.replaceAll("/$", "") + "/release/?query=" + URLEncoder.encode(query, "UTF-8") + "&fmt=json";
+
+ rateLimitMb();
+
+ HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
+ c.setConnectTimeout(5000); c.setReadTimeout(10000); c.setRequestMethod("GET");
+ String ua = value("MANGTOOL_MB_USER_AGENT", "mangtool.mb.user-agent", "MangTool/1.0 (https://gitea.mangmang.fun/LiuMangMang/MyTool)");
+ c.setRequestProperty("User-Agent", ua);
+
+ if (c.getResponseCode() / 100 != 2) throw new IOException("HTTP Error " + c.getResponseCode());
+ JsonNode root = MAPPER.readTree(readLimited(c.getInputStream()));
+ JsonNode releases = root.path("releases");
+
+ List candidates = new ArrayList<>();
+ if (releases.isArray()) {
+ for (JsonNode r : releases) {
+ int score = r.path("score").asInt(0);
+ String rTitle = r.path("title").asText("");
+ String rYear = r.path("date").asText("").split("-")[0];
+ JsonNode ac = r.path("artist-credit");
+ StringBuilder rArtist = new StringBuilder();
+ if (ac.isArray()) {
+ for (JsonNode a : ac) {
+ rArtist.append(a.path("name").asText(""));
+ rArtist.append(a.path("joinphrase").asText(""));
+ }
+ }
+
+ if (score >= 90 && norm(rTitle).equals(norm(album)) && norm(rArtist.toString()).equals(norm(artist))) {
+ if (year == null || year.isEmpty() || rYear.isEmpty() || year.equals(rYear)) {
+ candidates.add(r.path("id").asText());
+ }
+ }
+ }
+ }
+ if (candidates.size() == 1) return candidates.get(0);
+ return null;
+ }
+
+ private boolean downloadFromCaa(String mbid, Path targetDir) {
+ String base = value("MANGTOOL_CAA_BASE_URL", "mangtool.caa.base-url", "https://coverartarchive.org");
+ String url = base.replaceAll("/$", "") + "/release/" + mbid + "/front";
+ Path tmp = null;
+ try {
+ HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
+ c.setInstanceFollowRedirects(true);
+ c.setConnectTimeout(5000); c.setReadTimeout(10000); c.setRequestMethod("GET");
+ String ua = value("MANGTOOL_MB_USER_AGENT", "mangtool.mb.user-agent", "MangTool/1.0 (https://gitea.mangmang.fun/LiuMangMang/MyTool)");
+ c.setRequestProperty("User-Agent", ua);
+
+ if (c.getResponseCode() / 100 != 2) return false;
+
+ byte[] body = readLimited(c.getInputStream());
+
+ try (ByteArrayInputStream bis = new ByteArrayInputStream(body);
+ ImageInputStream iis = ImageIO.createImageInputStream(bis)) {
+ Iterator readers = ImageIO.getImageReaders(iis);
+ if (!readers.hasNext()) return false;
+ ImageReader reader = readers.next();
+ try {
+ reader.setInput(iis);
+ String format = reader.getFormatName().toLowerCase(Locale.ROOT);
+ if (!format.equals("jpeg") && !format.equals("jpg") && !format.equals("png")) return false;
+
+ int w = reader.getWidth(0);
+ int h = reader.getHeight(0);
+ if ((long) w * h > 10000L * 10000L) {
+ return false; // Enforce dimensions
+ }
+
+ BufferedImage img = reader.read(0);
+ if (img == null) return false;
+
+ String ext = format.equals("png") ? "png" : "jpg";
+ tmp = targetDir.resolve("cover." + ext + ".tmp");
+ Path target = targetDir.resolve("cover." + ext);
+ Files.write(tmp, body);
+ try {
+ Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
+ log.info("已获取远程封面: {}", target);
+ return true;
+ } catch (FileAlreadyExistsException e) {
+ return true; // Already exists, consider fetched
+ } catch (Exception e) {
+ return false;
+ }
+ } finally {
+ reader.dispose();
+ }
+ }
+ } catch (Exception e) {
+ log.warn("CAA 下载封面失败: {} - {}", mbid, e.getMessage());
+ return false;
+ } finally {
+ if (tmp != null) {
+ try { Files.deleteIfExists(tmp); } catch (Exception ignored) {}
+ }
+ }
+ }
+
+ private synchronized void rateLimitMb() throws InterruptedException {
+ long now = System.currentTimeMillis();
+ long diff = now - lastMbRequestTime;
+ if (diff < 1000) {
+ Thread.sleep(1000 - diff);
+ }
+ lastMbRequestTime = System.currentTimeMillis();
+ }
+
+ private void loadCache() {
+ try {
+ File f = new File(getCacheFile());
+ if (f.exists()) {
+ JsonNode root = MAPPER.readTree(f);
+ root.fields().forEachRemaining(e -> {
+ CacheEntry ce = new CacheEntry();
+ ce.mbid = e.getValue().path("mbid").asText("");
+ ce.expireAt = e.getValue().path("expireAt").asLong(0);
+ cache.put(e.getKey(), ce);
+ });
+ }
+ } catch (Exception e) {
+ log.warn("读取封面缓存失败", e);
+ }
+ }
+
+ private synchronized void putCache(String key, String value) {
+ if (cache.size() >= 5000) cache.clear(); // Bounded cache simple eviction
+ CacheEntry ce = new CacheEntry();
+ ce.mbid = value;
+ // Success TTL 30 days, negative TTL 7 days
+ long ttl = value.isEmpty() ? 7L * 24 * 3600 * 1000 : 30L * 24 * 3600 * 1000;
+ ce.expireAt = System.currentTimeMillis() + ttl;
+ cache.put(key, ce);
+ try {
+ File f = new File(getCacheFile());
+ f.getParentFile().mkdirs();
+ MAPPER.writeValue(f, cache);
+ } catch (Exception e) {
+ log.warn("写入封面缓存失败", e);
+ }
+ }
+
+ private static boolean enabled() {
+ return Boolean.parseBoolean(value("MANGTOOL_COVER_ENABLED", "mangtool.cover.enabled", "false"));
+ }
+
+ private static String norm(String s) { return s == null ? "" : s.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]", ""); }
+
+ private static byte[] readLimited(InputStream in) throws Exception {
+ try (InputStream x = in; ByteArrayOutputStream b = new ByteArrayOutputStream()) {
+ byte[] buf = new byte[8192]; int n, total = 0;
+ while ((n = x.read(buf)) >= 0) {
+ total += n; if (total > MAX_BYTES) throw new IllegalArgumentException("封面响应过大");
+ b.write(buf, 0, n);
+ }
+ return b.toByteArray();
+ }
+ }
+
+ private static String value(String env, String prop, String def) {
+ String v = System.getenv(env); if (v == null || v.isEmpty()) v = System.getProperty(prop);
+ return v == null || v.isEmpty() ? def : v;
+ }
+}
diff --git a/backend/src/main/java/com/music/service/IngestService.java b/backend/src/main/java/com/music/service/IngestService.java
index d1c2492..f93cf4e 100644
--- a/backend/src/main/java/com/music/service/IngestService.java
+++ b/backend/src/main/java/com/music/service/IngestService.java
@@ -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);
diff --git a/backend/src/main/java/com/music/service/IngestTaskStore.java b/backend/src/main/java/com/music/service/IngestTaskStore.java
index f8c2761..1f3df32 100644
--- a/backend/src/main/java/com/music/service/IngestTaskStore.java
+++ b/backend/src/main/java/com/music/service/IngestTaskStore.java
@@ -60,15 +60,19 @@ public class IngestTaskStore {
public String outcome;
public String lyricSource;
public String lyricStatus;
+ public String coverSource;
+ public String coverStatus;
public FileOutcome() {
}
- public FileOutcome(String file, String outcome, String lyricSource, String lyricStatus) {
+ public FileOutcome(String file, String outcome, String lyricSource, String lyricStatus, String coverSource, String coverStatus) {
this.file = file;
this.outcome = outcome;
this.lyricSource = lyricSource;
this.lyricStatus = lyricStatus;
+ this.coverSource = coverSource;
+ this.coverStatus = coverStatus;
}
}
@@ -84,10 +88,14 @@ public class IngestTaskStore {
public final int lyricsFound;
public final int lyricsMissing;
public final int lyricsFailed;
+ public final int coversFound;
+ public final int coversMissing;
+ public final int coversFailed;
public Counters(int ingested, int duplicates, int missingMetadata, int unreadable,
int conversionFailed, int otherRejected, int missingCover,
- int lyricsFound, int lyricsMissing, int lyricsFailed) {
+ int lyricsFound, int lyricsMissing, int lyricsFailed,
+ int coversFound, int coversMissing, int coversFailed) {
this.ingested = ingested;
this.duplicates = duplicates;
this.missingMetadata = missingMetadata;
@@ -98,6 +106,9 @@ public class IngestTaskStore {
this.lyricsFound = lyricsFound;
this.lyricsMissing = lyricsMissing;
this.lyricsFailed = lyricsFailed;
+ this.coversFound = coversFound;
+ this.coversMissing = coversMissing;
+ this.coversFailed = coversFailed;
}
}
@@ -122,6 +133,9 @@ public class IngestTaskStore {
public int lyricsFound;
public int lyricsMissing;
public int lyricsFailed;
+ public int coversFound;
+ public int coversMissing;
+ public int coversFailed;
/** 逐文件结构化结果(有界滚动窗口)。 */
public List files = new ArrayList<>();
@@ -173,12 +187,12 @@ public class IngestTaskStore {
* 汇总计数始终为最新值,二者共同保证重启后可经 /api/ingest/status 恢复观测数据。
*/
public synchronized void recordProcessed(String taskId, String fileKey, String outcome,
- String lyricSource, String lyricStatus, Counters counters) {
+ String lyricSource, String lyricStatus, String coverSource, String coverStatus, Counters counters) {
TaskState s = tasks.get(taskId);
if (s == null) return;
s.processed++;
if (fileKey != null) {
- s.files.add(new FileOutcome(fileKey, outcome, lyricSource, lyricStatus));
+ s.files.add(new FileOutcome(fileKey, outcome, lyricSource, lyricStatus, coverSource, coverStatus));
// 滚动窗口:仅保留最近 MAX_FILES_PER_TASK 条,防止单次导入写盘二次方膨胀
int overflow = s.files.size() - MAX_FILES_PER_TASK;
if (overflow > 0) {
@@ -192,7 +206,7 @@ public class IngestTaskStore {
/** 便捷重载(仅记录文件键,无结构化结果/计数)。 */
public synchronized void recordProcessed(String taskId, String fileKey) {
- recordProcessed(taskId, fileKey, null, null, null, null);
+ recordProcessed(taskId, fileKey, null, null, null, null, null, null);
}
private static void applyCounters(TaskState s, Counters c) {
@@ -207,6 +221,9 @@ public class IngestTaskStore {
s.lyricsFound = c.lyricsFound;
s.lyricsMissing = c.lyricsMissing;
s.lyricsFailed = c.lyricsFailed;
+ s.coversFound = c.coversFound;
+ s.coversMissing = c.coversMissing;
+ s.coversFailed = c.coversFailed;
}
/** 压实历史:超过 MAX_TASKS 时按 updatedAt 丢弃最旧任务。 */
diff --git a/backend/src/main/java/com/music/service/LibraryHealthService.java b/backend/src/main/java/com/music/service/LibraryHealthService.java
index 9d8898b..dc7369e 100644
--- a/backend/src/main/java/com/music/service/LibraryHealthService.java
+++ b/backend/src/main/java/com/music/service/LibraryHealthService.java
@@ -52,6 +52,8 @@ public class LibraryHealthService {
private AudioValidationService audioValidationService;
@Autowired(required = false)
private LyricsService lyricsService;
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ private CoverArtService coverArtService;
public LibraryHealthService(ConfigService configService,
TraditionalFilterService traditionalFilterService) {
@@ -237,6 +239,69 @@ public class LibraryHealthService {
}
}
+ // 2.5 封面回填
+ if (request.isBackfillCovers() && coverArtService != null) {
+ Map> albumToAudio = new TreeMap<>();
+ for (Path audio : allAudio) {
+ albumToAudio.computeIfAbsent(audio.getParent(), k -> new ArrayList<>()).add(audio);
+ }
+
+ for (Map.Entry> entry : albumToAudio.entrySet()) {
+ Path albumDir = entry.getKey();
+ if (hasExistingCover(albumDir)) {
+ continue;
+ }
+
+ String[] meta = null;
+ Path representative = null;
+ for (Path audio : entry.getValue()) {
+ meta = readMeta(audio);
+ if (meta != null && !meta[1].isEmpty() && !meta[2].isEmpty()) {
+ representative = audio;
+ break;
+ }
+ }
+
+ if (meta == null || meta[1].isEmpty() || meta[2].isEmpty()) {
+ result.setCoversMissing(result.getCoversMissing() + 1);
+ continue;
+ }
+
+ String artist = meta[1];
+ String album = meta[2];
+ String yearStr = "";
+ try {
+ AudioFile af = AudioFileIO.read(representative.toFile());
+ Tag tag = af.getTag();
+ if (tag != null) {
+ yearStr = safeGetFirst(tag, FieldKey.YEAR);
+ }
+ } catch (Exception ignored) {}
+
+ // Extract 4-digit year
+ String year = "";
+ if (yearStr != null) {
+ for (int i = 0; i <= yearStr.length() - 4; i++) {
+ String y = yearStr.substring(i, i + 4);
+ if (y.matches("\\d{4}")) {
+ year = y;
+ break;
+ }
+ }
+ }
+
+ CoverArtService.RemoteOutcome ro = coverArtService.resolveRemote(albumDir, album, artist, year);
+ if (ro == CoverArtService.RemoteOutcome.FETCHED) {
+ result.setCoversBackfilled(result.getCoversBackfilled() + 1);
+ addExample(result.getChanged(), "cover+ " + rel(libraryPath, albumDir));
+ } else if (ro == CoverArtService.RemoteOutcome.FAILED) {
+ result.setCoverFailures(result.getCoverFailures() + 1);
+ } else {
+ result.setCoversMissing(result.getCoversMissing() + 1);
+ }
+ }
+ }
+
// 3. 删除解码校验失败的音频(危险,需开启解码校验);同时删除同 basename 侧车,避免遗留孤立 .lrc
if (request.isRemoveDecodeInvalid() && audioValidationService != null) {
for (Path audio : allAudio) {
diff --git a/backend/src/test/java/com/music/service/CoverArtServiceTest.java b/backend/src/test/java/com/music/service/CoverArtServiceTest.java
new file mode 100644
index 0000000..68f0e00
--- /dev/null
+++ b/backend/src/test/java/com/music/service/CoverArtServiceTest.java
@@ -0,0 +1,301 @@
+package com.music.service;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+import com.sun.net.httpserver.HttpServer;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpExchange;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class CoverArtServiceTest {
+ private CoverArtService service;
+ private HttpServer server;
+ private int port;
+ private ConfigService configService;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.start();
+ port = server.getAddress().getPort();
+
+ System.setProperty("mangtool.mb.base-url", "http://localhost:" + port + "/ws/2");
+ System.setProperty("mangtool.caa.base-url", "http://localhost:" + port);
+ System.setProperty("mangtool.cover.enabled", "true");
+ System.setProperty("mangtool.mb.user-agent", "TestAgent/1.0");
+
+ configService = mock(ConfigService.class);
+ when(configService.getBasePath()).thenReturn(System.getProperty("java.io.tmpdir"));
+
+ try { Files.deleteIfExists(Paths.get(System.getProperty("java.io.tmpdir"), ".mangtool", "cover_cache.json")); } catch (Exception ignored) {}
+
+ service = new CoverArtService(configService);
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.stop(0);
+ }
+ System.clearProperty("mangtool.mb.base-url");
+ System.clearProperty("mangtool.caa.base-url");
+ System.clearProperty("mangtool.cover.enabled");
+ System.clearProperty("mangtool.mb.user-agent");
+ }
+
+ @Test
+ void testResolveRemote_Success(@TempDir Path targetDir) throws Exception {
+ server.createContext("/ws/2/release/", exchange -> {
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(resp.getBytes());
+ }
+ });
+
+ server.createContext("/release/1234/front", exchange -> {
+ BufferedImage img = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ImageIO.write(img, "jpg", baos);
+ byte[] bytes = baos.toByteArray();
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ });
+
+ CoverArtService.RemoteOutcome outcome = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.FETCHED, outcome);
+ assertTrue(Files.exists(targetDir.resolve("cover.jpg")));
+ }
+
+ @Test
+ void testResolveRemote_Ambiguous(@TempDir Path targetDir) throws Exception {
+ server.createContext("/ws/2/release/", exchange -> {
+ String resp = "{\"releases\": [" +
+ "{\"id\": \"1234\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}," +
+ "{\"id\": \"5678\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}" +
+ "]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(resp.getBytes());
+ }
+ });
+
+ CoverArtService.RemoteOutcome outcome = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.MISSING, outcome);
+ }
+
+ @Test
+ void testResolveRemote_LowScore(@TempDir Path targetDir) throws Exception {
+ server.createContext("/ws/2/release/", exchange -> {
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 89, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(resp.getBytes());
+ }
+ });
+
+ CoverArtService.RemoteOutcome outcome = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.MISSING, outcome);
+ }
+
+ @Test
+ void testResolveRemote_HttpFailure_NoNegativeCache_ThenSuccess(@TempDir Path targetDir) throws Exception {
+ // First request: MB returns 500
+ server.createContext("/ws/2/release/", new HttpHandler() {
+ private int count = 0;
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ if (count == 0) {
+ count++;
+ exchange.sendResponseHeaders(500, -1);
+ } else {
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp.getBytes()); }
+ }
+ }
+ });
+
+ server.createContext("/release/1234/front", exchange -> {
+ BufferedImage img = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ImageIO.write(img, "jpg", baos);
+ byte[] bytes = baos.toByteArray();
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); }
+ });
+
+ // First call fails
+ CoverArtService.RemoteOutcome outcome1 = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.FAILED, outcome1);
+
+ // Second call succeeds because no negative cache was written
+ CoverArtService.RemoteOutcome outcome2 = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.FETCHED, outcome2);
+ }
+
+ @Test
+ void testResolveRemote_TruncatedImage(@TempDir Path targetDir) throws Exception {
+ server.createContext("/ws/2/release/", exchange -> {
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp.getBytes()); }
+ });
+
+ server.createContext("/release/1234/front", exchange -> {
+ BufferedImage img = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ImageIO.write(img, "jpg", baos);
+ byte[] bytes = baos.toByteArray();
+ // Truncate the image bytes
+ byte[] truncated = new byte[bytes.length / 2];
+ System.arraycopy(bytes, 0, truncated, 0, truncated.length);
+ exchange.sendResponseHeaders(200, truncated.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(truncated); }
+ });
+
+ CoverArtService.RemoteOutcome outcome = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.FAILED, outcome);
+ }
+
+ @Test
+ void testResolveRemote_ArtistMismatch(@TempDir Path targetDir) throws Exception {
+ server.createContext("/ws/2/release/", exchange -> {
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"wrong_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp.getBytes()); }
+ });
+
+ CoverArtService.RemoteOutcome outcome = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.MISSING, outcome);
+ }
+
+ @Test
+ void testResolveRemote_AlbumMismatch(@TempDir Path targetDir) throws Exception {
+ server.createContext("/ws/2/release/", exchange -> {
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 100, \"title\": \"wrong_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp.getBytes()); }
+ });
+
+ CoverArtService.RemoteOutcome outcome = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.MISSING, outcome);
+ }
+
+ @Test
+ void testResolveRemote_IncompatibleYear(@TempDir Path targetDir) throws Exception {
+ server.createContext("/ws/2/release/", exchange -> {
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2022-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp.getBytes()); }
+ });
+
+ CoverArtService.RemoteOutcome outcome = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.MISSING, outcome);
+ }
+
+ @Test
+ void testResolveRemote_PngOutput(@TempDir Path targetDir) throws Exception {
+ server.createContext("/ws/2/release/", exchange -> {
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp.getBytes()); }
+ });
+
+ server.createContext("/release/1234/front", exchange -> {
+ BufferedImage img = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ImageIO.write(img, "png", baos);
+ byte[] bytes = baos.toByteArray();
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); }
+ });
+
+ CoverArtService.RemoteOutcome outcome = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.FETCHED, outcome);
+ assertTrue(Files.exists(targetDir.resolve("cover.png")));
+ }
+
+ @Test
+ void testResolveRemote_PositiveCacheReuse(@TempDir Path targetDir) throws Exception {
+ // Mock MB once, fail on second call
+ server.createContext("/ws/2/release/", new HttpHandler() {
+ private int count = 0;
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ if (count == 0) {
+ count++;
+ String resp = "{\"releases\": [{\"id\": \"1234\", \"score\": 100, \"title\": \"test_album\", \"date\": \"2024-01-01\", \"artist-credit\": [{\"name\": \"test_artist\", \"joinphrase\": \"\"}]}]}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp.getBytes()); }
+ } else {
+ exchange.sendResponseHeaders(500, -1);
+ }
+ }
+ });
+
+ server.createContext("/release/1234/front", exchange -> {
+ BufferedImage img = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ImageIO.write(img, "jpg", baos);
+ byte[] bytes = baos.toByteArray();
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); }
+ });
+
+ // First call fetches from MB and caches MBID "1234"
+ CoverArtService.RemoteOutcome outcome1 = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.FETCHED, outcome1);
+
+ // Second call should reuse the MBID from cache and not hit MB (which would return 500 and FAILED)
+ CoverArtService.RemoteOutcome outcome2 = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.FETCHED, outcome2);
+ }
+
+ @Test
+ void testResolveRemote_NegativeCacheReuse(@TempDir Path targetDir) throws Exception {
+ // Mock MB once, fail on second call
+ server.createContext("/ws/2/release/", new HttpHandler() {
+ private int count = 0;
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ if (count == 0) {
+ count++;
+ // Zero results
+ String resp = "{\"releases\": []}";
+ exchange.sendResponseHeaders(200, resp.length());
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp.getBytes()); }
+ } else {
+ exchange.sendResponseHeaders(500, -1);
+ }
+ }
+ });
+
+ // First call writes negative cache
+ CoverArtService.RemoteOutcome outcome1 = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.MISSING, outcome1);
+
+ // Second call should read negative cache and return MISSING instead of hitting MB (which would fail)
+ CoverArtService.RemoteOutcome outcome2 = service.resolveRemote(targetDir, "test_album", "test_artist", "2024");
+ assertEquals(CoverArtService.RemoteOutcome.MISSING, outcome2);
+ }
+}
diff --git a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java
index 93fc40d..45363bd 100644
--- a/backend/src/test/java/com/music/service/IngestServiceE2ETest.java
+++ b/backend/src/test/java/com/music/service/IngestServiceE2ETest.java
@@ -787,8 +787,9 @@ class IngestServiceE2ETest {
@Override
public synchronized void recordProcessed(String taskId, String fileKey, String outcome,
String lyricSource, String lyricStatus,
+ String coverSource, String coverStatus,
Counters counters) {
- super.recordProcessed(taskId, fileKey, outcome, lyricSource, lyricStatus, counters);
+ super.recordProcessed(taskId, fileKey, outcome, lyricSource, lyricStatus, coverSource, coverStatus, counters);
requestCancel(taskId);
}
};
diff --git a/backend/src/test/java/com/music/service/IngestServiceTest.java b/backend/src/test/java/com/music/service/IngestServiceTest.java
new file mode 100644
index 0000000..d07f022
--- /dev/null
+++ b/backend/src/test/java/com/music/service/IngestServiceTest.java
@@ -0,0 +1,182 @@
+package com.music.service;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class IngestServiceTest {
+ private IngestService service;
+ private SimpMessagingTemplate messagingTemplate;
+ private ProgressStore progressStore;
+ private TraditionalFilterService traditionalFilterService;
+ private ConfigService configService;
+ private CoverArtService coverArtService;
+
+ @BeforeEach
+ void setUp() {
+ messagingTemplate = mock(SimpMessagingTemplate.class);
+ progressStore = mock(ProgressStore.class);
+ traditionalFilterService = new TraditionalFilterService();
+ configService = mock(ConfigService.class);
+ coverArtService = mock(CoverArtService.class);
+
+ service = new IngestService(messagingTemplate, progressStore, traditionalFilterService, configService, null);
+ try {
+ java.lang.reflect.Field f = IngestService.class.getDeclaredField("coverArtService");
+ f.setAccessible(true);
+ f.set(service, coverArtService);
+ } catch (Exception e) {}
+ }
+
+ private void createValidMp3WithMeta(Path out, String title, String artist, String album) throws Exception {
+ ProcessBuilder pb = new ProcessBuilder("ffmpeg", "-y",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=0.2",
+ "-metadata", "title=" + title, "-metadata", "artist=" + artist, "-metadata", "album=" + album,
+ "-c:a", "libmp3lame", out.toAbsolutePath().toString());
+ Process p = pb.start();
+ p.waitFor();
+ }
+
+ @Test
+ void ingest_existingCover_noCoverServiceInvocation(@TempDir Path root) throws Exception {
+ Path input = root.resolve("Input");
+ Path library = root.resolve("Library");
+ Path rejected = root.resolve("Rejected");
+ Files.createDirectories(input);
+ Files.createDirectories(library);
+ Files.createDirectories(rejected);
+
+ when(configService.getInputDir()).thenReturn(input.toString());
+ when(configService.getLibraryDir()).thenReturn(library.toString());
+ when(configService.getRejectedDir()).thenReturn(rejected.toString());
+
+ createValidMp3WithMeta(input.resolve("test.mp3"), "T", "Art", "Alb");
+
+ Path targetDir = library.resolve("Art").resolve("Alb");
+ Files.createDirectories(targetDir);
+ Files.write(targetDir.resolve("cover.jpg"), new byte[]{1,2,3});
+
+ service.ingest("task1", new AtomicBoolean(true));
+
+ verify(coverArtService, never()).resolveRemote(any(), any(), any(), any());
+ assertTrue(Files.exists(targetDir.resolve("01 - T.mp3")) || Files.exists(targetDir.resolve("00 - T.mp3")));
+ }
+
+ @Test
+ void ingest_remoteSuccess(@TempDir Path root) throws Exception {
+ Path input = root.resolve("Input");
+ Path library = root.resolve("Library");
+ Path rejected = root.resolve("Rejected");
+ Files.createDirectories(input);
+ Files.createDirectories(library);
+ Files.createDirectories(rejected);
+
+ when(configService.getInputDir()).thenReturn(input.toString());
+ when(configService.getLibraryDir()).thenReturn(library.toString());
+ when(configService.getRejectedDir()).thenReturn(rejected.toString());
+
+ createValidMp3WithMeta(input.resolve("test2.mp3"), "T2", "Art2", "Alb2");
+ when(coverArtService.resolveRemote(any(), eq("Alb2"), eq("Art2"), eq(""))).thenAnswer(inv -> {
+ Path targetDir = inv.getArgument(0);
+ Files.write(targetDir.resolve("cover.jpg"), new byte[]{1, 2, 3});
+ return CoverArtService.RemoteOutcome.FETCHED;
+ });
+
+ service.ingest("task2", new AtomicBoolean(true));
+
+ verify(coverArtService, times(1)).resolveRemote(any(), eq("Alb2"), eq("Art2"), eq(""));
+ Path targetDir = library.resolve("Art2").resolve("Alb2");
+ assertTrue(Files.exists(targetDir.resolve("01 - T2.mp3")) || Files.exists(targetDir.resolve("00 - T2.mp3")));
+ }
+
+ @Test
+ void ingest_remoteFailure(@TempDir Path root) throws Exception {
+ Path input = root.resolve("Input");
+ Path library = root.resolve("Library");
+ Path rejected = root.resolve("Rejected");
+ Files.createDirectories(input);
+ Files.createDirectories(library);
+ Files.createDirectories(rejected);
+
+ when(configService.getInputDir()).thenReturn(input.toString());
+ when(configService.getLibraryDir()).thenReturn(library.toString());
+ when(configService.getRejectedDir()).thenReturn(rejected.toString());
+
+ createValidMp3WithMeta(input.resolve("test3.mp3"), "T3", "Art3", "Alb3");
+ when(coverArtService.resolveRemote(any(), eq("Alb3"), eq("Art3"), eq(""))).thenReturn(CoverArtService.RemoteOutcome.FAILED);
+
+ service.ingest("task3", new AtomicBoolean(true));
+
+ verify(coverArtService, times(1)).resolveRemote(any(), eq("Alb3"), eq("Art3"), eq(""));
+ assertTrue(Files.exists(rejected.resolve("MissingCover").resolve("test3.mp3")));
+ }
+
+ @Test
+ void ingest_remoteFetchedWithoutFile(@TempDir Path root) throws Exception {
+ Path input = root.resolve("Input");
+ Path library = root.resolve("Library");
+ Path rejected = root.resolve("Rejected");
+ Files.createDirectories(input);
+ Files.createDirectories(library);
+ Files.createDirectories(rejected);
+
+ when(configService.getInputDir()).thenReturn(input.toString());
+ when(configService.getLibraryDir()).thenReturn(library.toString());
+ when(configService.getRejectedDir()).thenReturn(rejected.toString());
+
+ createValidMp3WithMeta(input.resolve("test_no_file.mp3"), "T", "Art", "Alb");
+ when(coverArtService.resolveRemote(any(), eq("Alb"), eq("Art"), eq(""))).thenReturn(CoverArtService.RemoteOutcome.FETCHED);
+
+ service.ingest("task_no_file", new AtomicBoolean(true));
+
+ assertTrue(Files.exists(rejected.resolve("MissingCover").resolve("test_no_file.mp3")));
+ }
+
+ @Test
+ void ingest_moveFailureCleanup(@TempDir Path root) throws Exception {
+ Path input = root.resolve("Input");
+ Path library = root.resolve("Library");
+ Path rejected = root.resolve("Rejected");
+ Files.createDirectories(input);
+ Files.createDirectories(library);
+ Files.createDirectories(rejected);
+
+ when(configService.getInputDir()).thenReturn(input.toString());
+ when(configService.getLibraryDir()).thenReturn(library.toString());
+ when(configService.getRejectedDir()).thenReturn(rejected.toString());
+
+ createValidMp3WithMeta(input.resolve("test4.mp3"), "T4", "Art4", "Alb4");
+
+ Path targetDir = library.resolve("Art4").resolve("Alb4");
+ Files.createDirectories(targetDir);
+
+ // Mock remote fetch to write cover.jpg, then make input dir readonly to fail the move
+ when(coverArtService.resolveRemote(any(), eq("Alb4"), eq("Art4"), eq(""))).thenAnswer(inv -> {
+ Path dir = inv.getArgument(0);
+ Files.write(dir.resolve("cover.jpg"), new byte[]{1});
+ input.toFile().setWritable(false);
+ return CoverArtService.RemoteOutcome.FETCHED;
+ });
+
+ service.ingest("task4", new AtomicBoolean(true));
+
+ // Restore permissions so we can clean up
+ input.toFile().setWritable(true);
+
+
+ // Verify it was moved to Other rejected dir due to move-failure, or remained in input if moving to rejected also failed
+ assertTrue(Files.exists(input.resolve("test4.mp3")));
+
+ // Verify cover.jpg was cleaned up
+ assertFalse(Files.exists(targetDir.resolve("cover.jpg")));
+ }
+}
diff --git a/backend/src/test/java/com/music/service/IngestTaskStoreTest.java b/backend/src/test/java/com/music/service/IngestTaskStoreTest.java
index 80de7c8..da64d88 100644
--- a/backend/src/test/java/com/music/service/IngestTaskStoreTest.java
+++ b/backend/src/test/java/com/music/service/IngestTaskStoreTest.java
@@ -83,17 +83,17 @@ class IngestTaskStoreTest {
s1.setStateFileOverride(stateFile);
s1.begin("mix", 4);
// 文件1:成功入库 + 侧车歌词
- s1.recordProcessed("mix", "a.mp3", "ingested", "sidecar", "found",
- new IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0));
+ s1.recordProcessed("mix", "a.mp3", "ingested", "sidecar", "found", "none", "none",
+ new IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
// 文件2:重复
- s1.recordProcessed("mix", "b.mp3", "rejected:duplicate", null, null,
- new IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0));
+ s1.recordProcessed("mix", "b.mp3", "rejected:duplicate", null, null, null, null,
+ new IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
// 文件3:缺元数据
- s1.recordProcessed("mix", "c.mp3", "rejected:missing-metadata", null, null,
- new IngestTaskStore.Counters(1, 1, 1, 0, 0, 0, 0, 1, 0, 0));
+ s1.recordProcessed("mix", "c.mp3", "rejected:missing-metadata", null, null, null, null,
+ new IngestTaskStore.Counters(1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
// 文件4:入库但歌词失败
- s1.recordProcessed("mix", "d.mp3", "ingested", "none", "failed",
- new IngestTaskStore.Counters(2, 1, 1, 0, 0, 0, 0, 1, 0, 1));
+ s1.recordProcessed("mix", "d.mp3", "ingested", "none", "failed", null, null,
+ new IngestTaskStore.Counters(2, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0));
// 未 complete → 模拟中断
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
diff --git a/backend/src/test/java/com/music/service/LibraryHealthServiceTest.java b/backend/src/test/java/com/music/service/LibraryHealthServiceTest.java
index e558f04..1a186b4 100644
--- a/backend/src/test/java/com/music/service/LibraryHealthServiceTest.java
+++ b/backend/src/test/java/com/music/service/LibraryHealthServiceTest.java
@@ -10,8 +10,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.*;
/**
* {@link LibraryHealthService} 只读扫描与确认后修复的侧车安全测试。
@@ -345,4 +344,125 @@ class LibraryHealthServiceTest {
}
} catch (Exception ignored) {}
}
+ @org.junit.jupiter.api.Test
+ void repair_backfillCovers_success() throws Exception {
+ Path root = Files.createTempDirectory("lib-health-cv-");
+ try {
+ Path album = root.resolve("A").resolve("B");
+ Files.createDirectories(album);
+ createValidMp3WithMeta(album.resolve("01.mp3"), "T", "Art", "Alb");
+
+ LibraryHealthService svc = newService(root);
+ CoverArtService coverArtService = mock(CoverArtService.class);
+ when(coverArtService.resolveRemote(any(), eq("Alb"), eq("Art"), eq(""))).thenReturn(CoverArtService.RemoteOutcome.FETCHED);
+
+ java.lang.reflect.Field f = LibraryHealthService.class.getDeclaredField("coverArtService");
+ f.setAccessible(true);
+ f.set(svc, coverArtService);
+
+ LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
+ req.setConfirm(true);
+ req.setBackfillCovers(true);
+
+ LibraryHealthResult res = svc.repair(req);
+ assertTrue(res.isApplied());
+ assertEquals(1, res.getCoversBackfilled());
+ assertEquals(0, res.getCoverFailures());
+ verify(coverArtService, times(1)).resolveRemote(any(), eq("Alb"), eq("Art"), eq(""));
+ } finally {
+ deleteDir(root);
+ }
+ }
+
+ @org.junit.jupiter.api.Test
+ void repair_backfillCovers_failureAccounting() throws Exception {
+ Path root = Files.createTempDirectory("lib-health-cv2-");
+ try {
+ Path album = root.resolve("A").resolve("B");
+ Files.createDirectories(album);
+ createValidMp3WithMeta(album.resolve("01.mp3"), "T", "Art", "Alb");
+
+ LibraryHealthService svc = newService(root);
+ CoverArtService coverArtService = mock(CoverArtService.class);
+ when(coverArtService.resolveRemote(any(), eq("Alb"), eq("Art"), eq(""))).thenReturn(CoverArtService.RemoteOutcome.FAILED);
+
+ java.lang.reflect.Field f = LibraryHealthService.class.getDeclaredField("coverArtService");
+ f.setAccessible(true);
+ f.set(svc, coverArtService);
+
+ LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
+ req.setConfirm(true);
+ req.setBackfillCovers(true);
+
+ LibraryHealthResult res = svc.repair(req);
+ assertTrue(res.isApplied());
+ assertEquals(0, res.getCoversBackfilled());
+ assertEquals(1, res.getCoverFailures());
+ } finally {
+ deleteDir(root);
+ }
+ }
+
+ @org.junit.jupiter.api.Test
+ void repair_backfillCovers_idempotency() throws Exception {
+ Path root = Files.createTempDirectory("lib-health-cv3-");
+ try {
+ Path album = root.resolve("A").resolve("B");
+ Files.createDirectories(album);
+ Files.write(album.resolve("cover.jpg"), new byte[]{1,2,3});
+ createValidMp3WithMeta(album.resolve("01.mp3"), "T", "Art", "Alb");
+
+ LibraryHealthService svc = newService(root);
+ CoverArtService coverArtService = mock(CoverArtService.class);
+
+ java.lang.reflect.Field f = LibraryHealthService.class.getDeclaredField("coverArtService");
+ f.setAccessible(true);
+ f.set(svc, coverArtService);
+
+ LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
+ req.setConfirm(true);
+ req.setBackfillCovers(true);
+
+ LibraryHealthResult res = svc.repair(req);
+ assertTrue(res.isApplied());
+ assertEquals(0, res.getCoversBackfilled());
+ verify(coverArtService, never()).resolveRemote(any(), any(), any(), any());
+ } finally {
+ deleteDir(root);
+ }
+ }
+
+ @org.junit.jupiter.api.Test
+ void repair_backfillCovers_confirmFalse_nonMutation() throws Exception {
+ Path root = Files.createTempDirectory("lib-health-cv4-");
+ try {
+ Path album = root.resolve("A").resolve("B");
+ Files.createDirectories(album);
+ createValidMp3WithMeta(album.resolve("01.mp3"), "T", "Art", "Alb");
+
+ LibraryHealthService svc = newService(root);
+ CoverArtService coverArtService = mock(CoverArtService.class);
+
+ java.lang.reflect.Field f = LibraryHealthService.class.getDeclaredField("coverArtService");
+ f.setAccessible(true);
+ f.set(svc, coverArtService);
+
+ LibraryHealthRepairRequest req = new LibraryHealthRepairRequest();
+ req.setConfirm(false);
+ req.setBackfillCovers(true);
+
+ LibraryHealthResult res = svc.repair(req);
+ assertFalse(res.isApplied());
+ verify(coverArtService, never()).resolveRemote(any(), any(), any(), any());
+ } finally {
+ deleteDir(root);
+ }
+ }
+
+ private static void createValidMp3WithMeta(Path out, String title, String artist, String album) throws Exception {
+ run(new ProcessBuilder("ffmpeg", "-y",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=0.2",
+ "-metadata", "title=" + title, "-metadata", "artist=" + artist, "-metadata", "album=" + album,
+ "-c:a", "libmp3lame", out.toAbsolutePath().toString()));
+ }
}
diff --git a/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java b/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java
index fe98a80..659788e 100644
--- a/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java
+++ b/backend/src/test/java/com/music/service/ProgressMessageMappingTest.java
@@ -190,10 +190,10 @@ class ProgressMessageMappingTest {
com.music.service.IngestTaskStore prev = new com.music.service.IngestTaskStore(cfgService);
prev.setStateFileOverride(stateFile);
prev.begin("restart-task", 5);
- prev.recordProcessed("restart-task", "done1.mp3", "ingested", "sidecar", "found",
- new com.music.service.IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0));
- prev.recordProcessed("restart-task", "done2.mp3", "rejected:duplicate", null, null,
- new com.music.service.IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0));
+ prev.recordProcessed("restart-task", "done1.mp3", "ingested", "sidecar", "found", "none", "none",
+ new com.music.service.IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
+ prev.recordProcessed("restart-task", "done2.mp3", "rejected:duplicate", null, null, null, null,
+ new com.music.service.IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
// 模拟“重启”:新 store 加载 + 恢复(running -> interrupted)
com.music.service.IngestTaskStore fresh = new com.music.service.IngestTaskStore(cfgService);
diff --git a/docker/README.md b/docker/README.md
index 4e13898..5be20f2 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -120,14 +120,20 @@ docker compose exec mangtool wget -q -O- http://localhost:8080/api/health/depend
- 取消:`POST /api/ingest/cancel`,只停止后续文件处理,已入库文件保留,未处理文件留在 `Input/`。
- 重启恢复:任务状态持久化于 `工作根目录/.mangtool/ingest-tasks.json`;容器重启后仍在运行的任务标记为 `interrupted`,重新导入只处理未完成文件(已完成文件已移出 `Input/`,不会重复搬运)。
-### Library 健康检查(只读)
+### Library 健康检查(只读,修复需确认)
```bash
# 只读扫描(不修改任何文件)
docker compose exec mangtool wget -q -O- --post-data='' http://localhost:8080/api/library/health/scan
```
-修复动作(歌词回填、删除孤立侧车等)必须在请求体显式 `confirm=true` 才会执行,否则仅演练。
+修复动作(歌词回填、封面回填、删除孤立侧车等)必须在请求体显式 `confirm=true` 才会执行,否则仅演练。
+
+**修复示例(含封面回填)**:
+```bash
+# 默认配置下不修改磁盘。加上 confirm=true 与 backfillCovers=true 即可安全回填封面与修复曲库
+docker compose exec mangtool wget -q -O- --header="Content-Type: application/json" --post-data='{"confirm":true,"backfillCovers":true}' http://localhost:8080/api/library/health/repair
+```
### 备份与恢复
@@ -173,3 +179,10 @@ docker compose build --progress=plain
# 查看运行日志
docker compose logs -f mangtool
```
+
+
+## Cover Art Settings
+- `MANGTOOL_COVER_ENABLED` (default: false) - Enable Cover Art Fetch
+- `MANGTOOL_MB_BASE_URL` (default: https://musicbrainz.org/ws/2)
+- `MANGTOOL_CAA_BASE_URL` (default: https://coverartarchive.org)
+- `MANGTOOL_MB_USER_AGENT` (default: MangTool/1.0 (https://gitea.mangmang.fun/LiuMangMang/MyTool)) - User-Agent for MusicBrainz API requests
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 844f7b3..7cbe524 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -13,6 +13,10 @@ services:
volumes:
- ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork
environment:
+ - MANGTOOL_COVER_ENABLED=${MANGTOOL_COVER_ENABLED:-false}
+ - MANGTOOL_MB_BASE_URL=${MANGTOOL_MB_BASE_URL:-https://musicbrainz.org/ws/2}
+ - MANGTOOL_CAA_BASE_URL=${MANGTOOL_CAA_BASE_URL:-https://coverartarchive.org}
+ - MANGTOOL_MB_USER_AGENT=${MANGTOOL_MB_USER_AGENT:-MangTool/1.0 (https://gitea.mangmang.fun/LiuMangMang/MyTool)}
- MANGTOOL_HOME=/home/mangtool
- MANGTOOL_LYRICS_ENABLED=${MANGTOOL_LYRICS_ENABLED:-false}
- MANGTOOL_LYRICS_BASE_URL=${MANGTOOL_LYRICS_BASE_URL:-http://lrcapi:1111}
diff --git a/frontend/src/api/ingest.ts b/frontend/src/api/ingest.ts
index 9831047..dff3ecb 100644
--- a/frontend/src/api/ingest.ts
+++ b/frontend/src/api/ingest.ts
@@ -19,6 +19,9 @@ export interface IngestStatusResponse {
lyricsFound: number;
lyricsMissing: number;
lyricsFailed: number;
+ coversFound: number;
+ coversMissing: number;
+ coversFailed: number;
message: string;
}
diff --git a/frontend/src/api/progress.ts b/frontend/src/api/progress.ts
index 4340449..a5e2068 100644
--- a/frontend/src/api/progress.ts
+++ b/frontend/src/api/progress.ts
@@ -27,6 +27,12 @@ export interface ProgressMessage {
unreadableFiles?: number;
conversionFailedFiles?: number;
otherRejectedFiles?: number;
+ lyricsFound?: number;
+ lyricsMissing?: number;
+ lyricsFailed?: number;
+ coversFound?: number;
+ coversMissing?: number;
+ coversFailed?: number;
}
export function getProgress(taskId: string): Promise {
diff --git a/frontend/src/components/IngestTab.vue b/frontend/src/components/IngestTab.vue
index caa5536..5280316 100644
--- a/frontend/src/components/IngestTab.vue
+++ b/frontend/src/components/IngestTab.vue
@@ -215,6 +215,12 @@
+
+
+
有封面{{ coverStats.found }}
+
无封面{{ coverStats.missing }}
+
封面失败{{ coverStats.failed }}
+
@@ -339,6 +345,19 @@ import { useWebSocket } from '../composables/useWebSocket';
import type { ProgressMessage } from '../composables/useWebSocket';
import { useAppState } from '../composables/useAppState';
+interface IngestProgressMessage extends ProgressMessage {
+ lyricsFound?: number;
+ lyricsMissing?: number;
+ lyricsFailed?: number;
+ coversFound?: number;
+ coversMissing?: number;
+ coversFailed?: number;
+}
+
+function isIngestMessage(msg: ProgressMessage): msg is IngestProgressMessage {
+ return msg.type === 'ingest' || 'coversFound' in msg || 'lyricsFound' in msg;
+}
+
const {
state: appState,
isConfigured,
@@ -380,22 +399,27 @@ const canStart = computed(() => isConfigured.value && !submitting.value);
* 歌词统计(独立于 ProgressMessage 类型,避免修改共享类型)。
* 歌词的有/无/失败绝不影响音频入库,仅作观测展示。
*/
+const coverStats = reactive<{ found: number; missing: number; failed: number }>({ found: 0, missing: 0, failed: 0 });
const lyricStats = reactive<{ found: number; missing: number; failed: number }>({
found: 0,
missing: 0,
failed: 0
});
-type LyricCarrier = { lyricsFound?: number; lyricsMissing?: number; lyricsFailed?: number };
+function applyLyricStats(src: ProgressMessage | null | undefined) {
+ if (!src || !isIngestMessage(src)) return;
-function applyLyricStats(src: LyricCarrier | null | undefined) {
- if (!src) return;
if (typeof src.lyricsFound === 'number') lyricStats.found = src.lyricsFound;
if (typeof src.lyricsMissing === 'number') lyricStats.missing = src.lyricsMissing;
if (typeof src.lyricsFailed === 'number') lyricStats.failed = src.lyricsFailed;
+
+ if (typeof src.coversFound === 'number') coverStats.found = src.coversFound;
+ if (typeof src.coversMissing === 'number') coverStats.missing = src.coversMissing;
+ if (typeof src.coversFailed === 'number') coverStats.failed = src.coversFailed;
}
function resetLyricStats() {
+ coverStats.found = 0; coverStats.missing = 0; coverStats.failed = 0;
lyricStats.found = 0;
lyricStats.missing = 0;
lyricStats.failed = 0;
@@ -600,7 +624,7 @@ async function scrollConsoleIfPinned() {
function handleProgressMessage(msg: ProgressMessage) {
Object.assign(progress, msg);
- applyLyricStats(msg as unknown as LyricCarrier);
+ applyLyricStats(msg);
recordActivity(msg);
if (typeof msg.running === 'boolean') {
@@ -619,7 +643,7 @@ const { connect: wsConnect, disconnect: wsDisconnect } = useWebSocket(taskId, ha
let pollTimer: ReturnType | null = null;
-function statusToProgress(resp: IngestStatusResponse): ProgressMessage {
+function statusToProgress(resp: IngestStatusResponse): IngestProgressMessage {
return {
taskId: resp.taskId,
type: 'ingest',
@@ -636,7 +660,13 @@ function statusToProgress(resp: IngestStatusResponse): ProgressMessage {
missingMetadataFiles: resp.missingMetadataFiles,
unreadableFiles: resp.unreadableFiles,
conversionFailedFiles: resp.conversionFailedFiles,
- otherRejectedFiles: resp.otherRejectedFiles
+ otherRejectedFiles: resp.otherRejectedFiles,
+ lyricsFound: resp.lyricsFound,
+ lyricsMissing: resp.lyricsMissing,
+ lyricsFailed: resp.lyricsFailed,
+ coversFound: resp.coversFound,
+ coversMissing: resp.coversMissing,
+ coversFailed: resp.coversFailed
};
}
@@ -647,7 +677,6 @@ function startPolling() {
try {
const resp = await getIngestStatus(taskId.value);
if (resp) {
- applyLyricStats(resp);
handleProgressMessage(statusToProgress(resp));
if (resp.completed || !resp.running) {
stopPolling();