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();