feat: fetch and backfill cover art

This commit is contained in:
2026-07-21 14:23:07 +08:00
parent f4f4ab092f
commit b21a5d0ed1
21 changed files with 1156 additions and 46 deletions
+14 -1
View File
@@ -46,7 +46,13 @@ BasePath/
## Library 健康检查(只读,修复需确认) ## Library 健康检查(只读,修复需确认)
- **只读扫描**`POST /api/library/health/scan`(可选 `?checkDecode=true` 执行完整解码校验)统计缺元数据、缺封面、缺歌词、孤立侧车(无对应音频的 `.lrc`)与重复曲目,**绝不修改任何文件**。 - **只读扫描**`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 - 音频元数据:jaudiotagger
- 繁简转换:opencc4j - 繁简转换:opencc4j
- 格式转换:FFmpeg - 格式转换: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
@@ -95,7 +95,7 @@ public class IngestController {
} }
if (tid == null) { if (tid == null) {
return Result.success(new IngestStatusResponse(null, false, false, 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); return statusById(tid);
} }
@@ -118,11 +118,12 @@ public class IngestController {
st.ingested, st.duplicates, st.missingMetadata, st.unreadable, st.ingested, st.duplicates, st.missingMetadata, st.unreadable,
st.conversionFailed, st.otherRejected, st.conversionFailed, st.otherRejected,
st.lyricsFound, st.lyricsMissing, st.lyricsFailed, st.lyricsFound, st.lyricsMissing, st.lyricsFailed,
st.coversFound, st.coversMissing, st.coversFailed,
msg)); msg));
} }
boolean isRunning = running.get(); boolean isRunning = running.get();
return Result.success(new IngestStatusResponse(taskId, isRunning, !isRunning, 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( IngestStatusResponse resp = new IngestStatusResponse(
@@ -137,6 +138,9 @@ public class IngestController {
optInt(pm.getLyricsFound()), optInt(pm.getLyricsFound()),
optInt(pm.getLyricsMissing()), optInt(pm.getLyricsMissing()),
optInt(pm.getLyricsFailed()), optInt(pm.getLyricsFailed()),
optInt(pm.getCoversFound()),
optInt(pm.getCoversMissing()),
optInt(pm.getCoversFailed()),
pm.getMessage() pm.getMessage()
); );
@@ -25,5 +25,8 @@ public class IngestStatusResponse {
private int lyricsFound; private int lyricsFound;
private int lyricsMissing; private int lyricsMissing;
private int lyricsFailed; private int lyricsFailed;
private int coversFound;
private int coversMissing;
private int coversFailed;
private String message; private String message;
} }
@@ -14,6 +14,7 @@ public class LibraryHealthRepairRequest {
/** 为缺歌词的曲目远程回填歌词(非致命,失败仅统计)。 */ /** 为缺歌词的曲目远程回填歌词(非致命,失败仅统计)。 */
private boolean backfillLyrics; private boolean backfillLyrics;
private boolean backfillCovers;
/** 删除孤立侧车(无对应音频的 .lrc)。仅删除确为孤立的侧车。 */ /** 删除孤立侧车(无对应音频的 .lrc)。仅删除确为孤立的侧车。 */
private boolean removeOrphanSidecars; private boolean removeOrphanSidecars;
@@ -15,6 +15,9 @@ public class LibraryHealthResult {
private boolean applied; private boolean applied;
private int lyricsBackfilled; private int lyricsBackfilled;
private int coversBackfilled;
private int coversMissing;
private int coverFailures;
private int orphanSidecarsRemoved; private int orphanSidecarsRemoved;
private int decodeInvalidRemoved; private int decodeInvalidRemoved;
@@ -42,4 +42,7 @@ public class ProgressMessage {
private Integer lyricsFound; // ingest: 已获得歌词的入库文件数(侧车/内嵌/远程) private Integer lyricsFound; // ingest: 已获得歌词的入库文件数(侧车/内嵌/远程)
private Integer lyricsMissing; // ingest: 无歌词可用的入库文件数 private Integer lyricsMissing; // ingest: 无歌词可用的入库文件数
private Integer lyricsFailed; // ingest: 歌词查询/写入出错的入库文件数 private Integer lyricsFailed; // ingest: 歌词查询/写入出错的入库文件数
private Integer coversFound;
private Integer coversMissing;
private Integer coversFailed;
} }
@@ -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<String, CacheEntry> 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<String> 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<ImageReader> 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;
}
}
@@ -92,6 +92,25 @@ public class IngestService {
private final AudioValidationService audioValidationService; private final AudioValidationService audioValidationService;
@Autowired(required = false) @Autowired(required = false)
private LyricsService lyricsService; 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,行为不变)。 */ /** 任务生命周期持久化与取消(可选注入;测试构造器不设置时为 null,行为不变)。 */
@Autowired(required = false) @Autowired(required = false)
@@ -258,10 +277,12 @@ public class IngestService {
AtomicInteger missingCover = new AtomicInteger(0); AtomicInteger missingCover = new AtomicInteger(0);
AtomicInteger processed = new AtomicInteger(0); AtomicInteger processed = new AtomicInteger(0);
// 歌词统计收集器(歌词失败不影响入库,仅用于报告/进度观测)
LyricRun run = new LyricRun(); LyricRun run = new LyricRun();
this.lyricRun = run; this.lyricRun = run;
CoverRun cr = new CoverRun();
this.coverRun = cr;
// 登记任务生命周期(持久化,供重启恢复与取消) // 登记任务生命周期(持久化,供重启恢复与取消)
if (taskStore != null) { if (taskStore != null) {
taskStore.begin(taskId, total); taskStore.begin(taskId, total);
@@ -281,10 +302,10 @@ public class IngestService {
break; break;
} }
String fileName = srcFile.getFileName().toString(); String fileName = srcFile.getFileName().toString();
// 使用相对于 Input 的路径作为唯一跟踪键(防止不同子目录中的同名文件互相覆盖)
String relativeKey = inputPath.relativize(srcFile).toString(); String relativeKey = inputPath.relativize(srcFile).toString();
String outcome = "unknown"; String outcome = "unknown";
run.reset(); run.reset();
if (coverRun != null) coverRun.reset();
try { try {
outcome = processSingleFile(srcFile, libraryPath, rejectedPath, outcome = processSingleFile(srcFile, libraryPath, rejectedPath,
libraryIdentities, batchIdentities, libraryIdentities, batchIdentities,
@@ -296,14 +317,16 @@ public class IngestService {
outcome = "rejected:exception"; outcome = "rejected:exception";
log.warn("处理文件异常: {} - {}", relativeKey, e.getMessage()); 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) { if (taskStore != null) {
taskStore.recordProcessed(taskId, relativeKey, outcome, run.lastSource, run.lastStatus, taskStore.recordProcessed(taskId, relativeKey, outcome, run.lastSource, run.lastStatus,
cr.lastSource, cr.lastStatus,
new IngestTaskStore.Counters( new IngestTaskStore.Counters(
ingested.get(), duplicates.get(), missingMeta.get(), ingested.get(), duplicates.get(), missingMeta.get(),
unreadable.get(), convFailed.get(), otherRejected.get(), unreadable.get(), convFailed.get(), otherRejected.get(),
missingCover.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(); int p = processed.incrementAndGet();
@@ -311,6 +334,7 @@ public class IngestService {
missingMeta.get(), unreadable.get(), convFailed.get(), missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(), otherRejected.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(),
relativeKey, relativeKey,
String.format("已处理 (%d/%d): %s", p, total, relativeKey), String.format("已处理 (%d/%d): %s", p, total, relativeKey),
false); false);
@@ -320,7 +344,8 @@ public class IngestService {
writeReport(taskId, fileOutcomes, rejectedPath, ingested.get(), duplicates.get(), writeReport(taskId, fileOutcomes, rejectedPath, ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(), otherRejected.get(), missingMeta.get(), unreadable.get(), convFailed.get(), otherRejected.get(),
missingCover.get(), cleanupRemoved, 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 (cancelled) {
if (taskStore != null) { if (taskStore != null) {
@@ -329,7 +354,8 @@ public class IngestService {
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(), sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(), missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.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(已完成文件保持不动)", String.format("任务已取消:已处理 %d/%d,已入库 %d(已完成文件保持不动)",
processed.get(), total, ingested.get()), processed.get(), total, ingested.get()),
true); true);
@@ -343,11 +369,13 @@ public class IngestService {
sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(), sendProgress(taskId, total, processed.get(), ingested.get(), duplicates.get(),
missingMeta.get(), unreadable.get(), convFailed.get(), missingMeta.get(), unreadable.get(), convFailed.get(),
otherRejected.get(), otherRejected.get(),
run.found.get(), run.missing.get(), run.failed.get(), null, run.found.get(), run.missing.get(), run.failed.get(),
String.format("导入完成!成功: %d, 重复: %d, 缺元数据: %d, 缺封面: %d, 不可读: %d, 转码失败: %d, 其他: %d, 清理: %d, 歌词(有/无/失败): %d/%d/%d", 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(), ingested.get(), duplicates.get(), missingMeta.get(), missingCover.get(),
unreadable.get(), convFailed.get(), otherRejected.get(), cleanupRemoved, unreadable.get(), convFailed.get(), otherRejected.get(), cleanupRemoved,
run.found.get(), run.missing.get(), run.failed.get()), run.found.get(), run.missing.get(), run.failed.get(),
cr.found.get(), cr.missing.get(), cr.failed.get()),
true); true);
} catch (Exception e) { } catch (Exception e) {
@@ -361,6 +389,7 @@ public class IngestService {
null, "任务内部错误: " + e.getMessage(), true); null, "任务内部错误: " + e.getMessage(), true);
} finally { } finally {
this.lyricRun = null; this.lyricRun = null;
coverRun = null;
if (runningLock != null) { if (runningLock != null) {
runningLock.set(false); runningLock.set(false);
} }
@@ -695,10 +724,40 @@ public class IngestService {
// (后备 remux 使用 -map 0:a:0 会丢弃 attached picture,故须从原始 srcFile 提取)。 // (后备 remux 使用 -map 0:a:0 会丢弃 attached picture,故须从原始 srcFile 提取)。
boolean albumAlreadyCovered = hasExistingCover(targetDir); boolean albumAlreadyCovered = hasExistingCover(targetDir);
Path writtenCover = null; Path writtenCover = null;
String coverSource = "none";
String coverStatus = "missing";
if (!albumAlreadyCovered) { if (!albumAlreadyCovered) {
writtenCover = acquireCover(srcFile, tag, targetDir); 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) { if (writtenCover == null) {
// 无既有封面且无法提取内嵌封面 → MissingCover;清理派生临时文件,隔离源文件 if (coverRun != null) coverRun.record(coverSource, coverStatus);
if (derivedFile) { if (derivedFile) {
deleteIfExists(effectiveFile); deleteIfExists(effectiveFile);
} }
@@ -707,8 +766,13 @@ public class IngestService {
log.info("缺少封面(无既有 cover 且无内嵌封面): {}", fileName); log.info("缺少封面(无既有 cover 且无内嵌封面): {}", fileName);
return "rejected:missing-cover"; return "rejected:missing-cover";
} }
} else {
coverSource = "existing";
coverStatus = "found";
} }
if (coverRun != null) coverRun.record(coverSource, coverStatus);
// 移动/复制到目标位置 // 移动/复制到目标位置
try { try {
FileTransferUtils.moveWithFallback(effectiveFile, targetFile); FileTransferUtils.moveWithFallback(effectiveFile, targetFile);
@@ -943,11 +1007,15 @@ public class IngestService {
final String outcome; final String outcome;
final String lyricSource; final String lyricSource;
final String lyricStatus; 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.outcome = outcome;
this.lyricSource = lyricSource; this.lyricSource = lyricSource;
this.lyricStatus = lyricStatus; this.lyricStatus = lyricStatus;
this.coverSource = coverSource;
this.coverStatus = coverStatus;
} }
} }
@@ -956,7 +1024,8 @@ public class IngestService {
int missingMetaCount, int unreadableCount, int missingMetaCount, int unreadableCount,
int convFailedCount, int otherRejectedCount, int convFailedCount, int otherRejectedCount,
int missingCoverCount, int cleanupRemovedCount, int missingCoverCount, int cleanupRemovedCount,
int lyricsFoundCount, int lyricsMissingCount, int lyricsFailedCount) { int lyricsFoundCount, int lyricsMissingCount, int lyricsFailedCount,
int coversFoundCount, int coversMissingCount, int coversFailedCount) {
try { try {
Path reportsDir = rejectedPath.resolve(REPORT_SUBDIR); Path reportsDir = rejectedPath.resolve(REPORT_SUBDIR);
Files.createDirectories(reportsDir); Files.createDirectories(reportsDir);
@@ -982,7 +1051,10 @@ public class IngestService {
json.append(" \"libraryCleanupRemoved\": ").append(cleanupRemovedCount).append(",\n"); json.append(" \"libraryCleanupRemoved\": ").append(cleanupRemovedCount).append(",\n");
json.append(" \"lyricsFound\": ").append(lyricsFoundCount).append(",\n"); json.append(" \"lyricsFound\": ").append(lyricsFoundCount).append(",\n");
json.append(" \"lyricsMissing\": ").append(lyricsMissingCount).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(" },\n");
json.append(" \"files\": [\n"); json.append(" \"files\": [\n");
@@ -997,6 +1069,10 @@ public class IngestService {
json.append(", \"lyricStatus\": \"").append(escapeJson(fr.lyricStatus)).append("\""); json.append(", \"lyricStatus\": \"").append(escapeJson(fr.lyricStatus)).append("\"");
json.append(", \"lyricSource\": \"").append(escapeJson(fr.lyricSource)).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("}"); json.append("}");
if (++idx < size) json.append(","); if (++idx < size) json.append(",");
json.append("\n"); json.append("\n");
@@ -1611,7 +1687,7 @@ public class IngestService {
String currentFile, String message, boolean completed) { String currentFile, String message, boolean completed) {
sendProgress(taskId, total, processed, ingestedFiles, duplicateFiles, sendProgress(taskId, total, processed, ingestedFiles, duplicateFiles,
missingMetadataFiles, unreadableFiles, conversionFailedFiles, otherRejectedFiles, 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, private void sendProgress(String taskId, int total, int processed,
@@ -1619,6 +1695,7 @@ public class IngestService {
int missingMetadataFiles, int unreadableFiles, int missingMetadataFiles, int unreadableFiles,
int conversionFailedFiles, int otherRejectedFiles, int conversionFailedFiles, int otherRejectedFiles,
Integer lyricsFound, Integer lyricsMissing, Integer lyricsFailed, Integer lyricsFound, Integer lyricsMissing, Integer lyricsFailed,
Integer coversFound, Integer coversMissing, Integer coversFailed,
String currentFile, String message, boolean completed) { String currentFile, String message, boolean completed) {
try { try {
ProgressMessage pm = new ProgressMessage(); ProgressMessage pm = new ProgressMessage();
@@ -1637,6 +1714,9 @@ public class IngestService {
pm.setLyricsFound(lyricsFound); pm.setLyricsFound(lyricsFound);
pm.setLyricsMissing(lyricsMissing); pm.setLyricsMissing(lyricsMissing);
pm.setLyricsFailed(lyricsFailed); pm.setLyricsFailed(lyricsFailed);
pm.setCoversFound(coversFound);
pm.setCoversMissing(coversMissing);
pm.setCoversFailed(coversFailed);
pm.setCurrentFile(currentFile); pm.setCurrentFile(currentFile);
pm.setMessage(message); pm.setMessage(message);
pm.setCompleted(completed); pm.setCompleted(completed);
@@ -60,15 +60,19 @@ public class IngestTaskStore {
public String outcome; public String outcome;
public String lyricSource; public String lyricSource;
public String lyricStatus; public String lyricStatus;
public String coverSource;
public String coverStatus;
public FileOutcome() { 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.file = file;
this.outcome = outcome; this.outcome = outcome;
this.lyricSource = lyricSource; this.lyricSource = lyricSource;
this.lyricStatus = lyricStatus; this.lyricStatus = lyricStatus;
this.coverSource = coverSource;
this.coverStatus = coverStatus;
} }
} }
@@ -84,10 +88,14 @@ public class IngestTaskStore {
public final int lyricsFound; public final int lyricsFound;
public final int lyricsMissing; public final int lyricsMissing;
public final int lyricsFailed; 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, public Counters(int ingested, int duplicates, int missingMetadata, int unreadable,
int conversionFailed, int otherRejected, int missingCover, 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.ingested = ingested;
this.duplicates = duplicates; this.duplicates = duplicates;
this.missingMetadata = missingMetadata; this.missingMetadata = missingMetadata;
@@ -98,6 +106,9 @@ public class IngestTaskStore {
this.lyricsFound = lyricsFound; this.lyricsFound = lyricsFound;
this.lyricsMissing = lyricsMissing; this.lyricsMissing = lyricsMissing;
this.lyricsFailed = lyricsFailed; this.lyricsFailed = lyricsFailed;
this.coversFound = coversFound;
this.coversMissing = coversMissing;
this.coversFailed = coversFailed;
} }
} }
@@ -122,6 +133,9 @@ public class IngestTaskStore {
public int lyricsFound; public int lyricsFound;
public int lyricsMissing; public int lyricsMissing;
public int lyricsFailed; public int lyricsFailed;
public int coversFound;
public int coversMissing;
public int coversFailed;
/** 逐文件结构化结果(有界滚动窗口)。 */ /** 逐文件结构化结果(有界滚动窗口)。 */
public List<FileOutcome> files = new ArrayList<>(); public List<FileOutcome> files = new ArrayList<>();
@@ -173,12 +187,12 @@ public class IngestTaskStore {
* 汇总计数始终为最新值,二者共同保证重启后可经 /api/ingest/status 恢复观测数据。</p> * 汇总计数始终为最新值,二者共同保证重启后可经 /api/ingest/status 恢复观测数据。</p>
*/ */
public synchronized void recordProcessed(String taskId, String fileKey, String outcome, 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); TaskState s = tasks.get(taskId);
if (s == null) return; if (s == null) return;
s.processed++; s.processed++;
if (fileKey != null) { 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 条,防止单次导入写盘二次方膨胀 // 滚动窗口:仅保留最近 MAX_FILES_PER_TASK 条,防止单次导入写盘二次方膨胀
int overflow = s.files.size() - MAX_FILES_PER_TASK; int overflow = s.files.size() - MAX_FILES_PER_TASK;
if (overflow > 0) { if (overflow > 0) {
@@ -192,7 +206,7 @@ public class IngestTaskStore {
/** 便捷重载(仅记录文件键,无结构化结果/计数)。 */ /** 便捷重载(仅记录文件键,无结构化结果/计数)。 */
public synchronized void recordProcessed(String taskId, String fileKey) { 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) { private static void applyCounters(TaskState s, Counters c) {
@@ -207,6 +221,9 @@ public class IngestTaskStore {
s.lyricsFound = c.lyricsFound; s.lyricsFound = c.lyricsFound;
s.lyricsMissing = c.lyricsMissing; s.lyricsMissing = c.lyricsMissing;
s.lyricsFailed = c.lyricsFailed; s.lyricsFailed = c.lyricsFailed;
s.coversFound = c.coversFound;
s.coversMissing = c.coversMissing;
s.coversFailed = c.coversFailed;
} }
/** 压实历史:超过 MAX_TASKS 时按 updatedAt 丢弃最旧任务。 */ /** 压实历史:超过 MAX_TASKS 时按 updatedAt 丢弃最旧任务。 */
@@ -52,6 +52,8 @@ public class LibraryHealthService {
private AudioValidationService audioValidationService; private AudioValidationService audioValidationService;
@Autowired(required = false) @Autowired(required = false)
private LyricsService lyricsService; private LyricsService lyricsService;
@org.springframework.beans.factory.annotation.Autowired(required = false)
private CoverArtService coverArtService;
public LibraryHealthService(ConfigService configService, public LibraryHealthService(ConfigService configService,
TraditionalFilterService traditionalFilterService) { TraditionalFilterService traditionalFilterService) {
@@ -237,6 +239,69 @@ public class LibraryHealthService {
} }
} }
// 2.5 封面回填
if (request.isBackfillCovers() && coverArtService != null) {
Map<Path, List<Path>> albumToAudio = new TreeMap<>();
for (Path audio : allAudio) {
albumToAudio.computeIfAbsent(audio.getParent(), k -> new ArrayList<>()).add(audio);
}
for (Map.Entry<Path, List<Path>> 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 // 3. 删除解码校验失败的音频(危险,需开启解码校验);同时删除同 basename 侧车,避免遗留孤立 .lrc
if (request.isRemoveDecodeInvalid() && audioValidationService != null) { if (request.isRemoveDecodeInvalid() && audioValidationService != null) {
for (Path audio : allAudio) { for (Path audio : allAudio) {
@@ -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);
}
}
@@ -787,8 +787,9 @@ class IngestServiceE2ETest {
@Override @Override
public synchronized void recordProcessed(String taskId, String fileKey, String outcome, public synchronized void recordProcessed(String taskId, String fileKey, String outcome,
String lyricSource, String lyricStatus, String lyricSource, String lyricStatus,
String coverSource, String coverStatus,
Counters counters) { Counters counters) {
super.recordProcessed(taskId, fileKey, outcome, lyricSource, lyricStatus, counters); super.recordProcessed(taskId, fileKey, outcome, lyricSource, lyricStatus, coverSource, coverStatus, counters);
requestCancel(taskId); requestCancel(taskId);
} }
}; };
@@ -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")));
}
}
@@ -83,17 +83,17 @@ class IngestTaskStoreTest {
s1.setStateFileOverride(stateFile); s1.setStateFileOverride(stateFile);
s1.begin("mix", 4); s1.begin("mix", 4);
// 文件1:成功入库 + 侧车歌词 // 文件1:成功入库 + 侧车歌词
s1.recordProcessed("mix", "a.mp3", "ingested", "sidecar", "found", s1.recordProcessed("mix", "a.mp3", "ingested", "sidecar", "found", "none", "none",
new IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0)); new IngestTaskStore.Counters(1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
// 文件2:重复 // 文件2:重复
s1.recordProcessed("mix", "b.mp3", "rejected:duplicate", null, null, s1.recordProcessed("mix", "b.mp3", "rejected:duplicate", null, null, null, null,
new IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0)); new IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
// 文件3:缺元数据 // 文件3:缺元数据
s1.recordProcessed("mix", "c.mp3", "rejected:missing-metadata", null, null, 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)); new IngestTaskStore.Counters(1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
// 文件4:入库但歌词失败 // 文件4:入库但歌词失败
s1.recordProcessed("mix", "d.mp3", "ingested", "none", "failed", s1.recordProcessed("mix", "d.mp3", "ingested", "none", "failed", null, null,
new IngestTaskStore.Counters(2, 1, 1, 0, 0, 0, 0, 1, 0, 1)); new IngestTaskStore.Counters(2, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0));
// 未 complete → 模拟中断 // 未 complete → 模拟中断
IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class)); IngestTaskStore s2 = new IngestTaskStore(mock(ConfigService.class));
@@ -10,8 +10,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.*;
import static org.mockito.Mockito.when;
/** /**
* {@link LibraryHealthService} 只读扫描与确认后修复的侧车安全测试。 * {@link LibraryHealthService} 只读扫描与确认后修复的侧车安全测试。
@@ -345,4 +344,125 @@ class LibraryHealthServiceTest {
} }
} catch (Exception ignored) {} } 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()));
}
} }
@@ -190,10 +190,10 @@ class ProgressMessageMappingTest {
com.music.service.IngestTaskStore prev = new com.music.service.IngestTaskStore(cfgService); com.music.service.IngestTaskStore prev = new com.music.service.IngestTaskStore(cfgService);
prev.setStateFileOverride(stateFile); prev.setStateFileOverride(stateFile);
prev.begin("restart-task", 5); prev.begin("restart-task", 5);
prev.recordProcessed("restart-task", "done1.mp3", "ingested", "sidecar", "found", 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)); 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, 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)); new com.music.service.IngestTaskStore.Counters(1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
// 模拟“重启”:新 store 加载 + 恢复(running -> interrupted // 模拟“重启”:新 store 加载 + 恢复(running -> interrupted
com.music.service.IngestTaskStore fresh = new com.music.service.IngestTaskStore(cfgService); com.music.service.IngestTaskStore fresh = new com.music.service.IngestTaskStore(cfgService);
+15 -2
View File
@@ -120,14 +120,20 @@ docker compose exec mangtool wget -q -O- http://localhost:8080/api/health/depend
- 取消:`POST /api/ingest/cancel`,只停止后续文件处理,已入库文件保留,未处理文件留在 `Input/` - 取消:`POST /api/ingest/cancel`,只停止后续文件处理,已入库文件保留,未处理文件留在 `Input/`
- 重启恢复:任务状态持久化于 `工作根目录/.mangtool/ingest-tasks.json`;容器重启后仍在运行的任务标记为 `interrupted`,重新导入只处理未完成文件(已完成文件已移出 `Input/`,不会重复搬运)。 - 重启恢复:任务状态持久化于 `工作根目录/.mangtool/ingest-tasks.json`;容器重启后仍在运行的任务标记为 `interrupted`,重新导入只处理未完成文件(已完成文件已移出 `Input/`,不会重复搬运)。
### Library 健康检查(只读) ### Library 健康检查(只读,修复需确认
```bash ```bash
# 只读扫描(不修改任何文件) # 只读扫描(不修改任何文件)
docker compose exec mangtool wget -q -O- --post-data='' http://localhost:8080/api/library/health/scan 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 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
+4
View File
@@ -13,6 +13,10 @@ services:
volumes: volumes:
- ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork - ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork
environment: 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_HOME=/home/mangtool
- MANGTOOL_LYRICS_ENABLED=${MANGTOOL_LYRICS_ENABLED:-false} - MANGTOOL_LYRICS_ENABLED=${MANGTOOL_LYRICS_ENABLED:-false}
- MANGTOOL_LYRICS_BASE_URL=${MANGTOOL_LYRICS_BASE_URL:-http://lrcapi:1111} - MANGTOOL_LYRICS_BASE_URL=${MANGTOOL_LYRICS_BASE_URL:-http://lrcapi:1111}
+3
View File
@@ -19,6 +19,9 @@ export interface IngestStatusResponse {
lyricsFound: number; lyricsFound: number;
lyricsMissing: number; lyricsMissing: number;
lyricsFailed: number; lyricsFailed: number;
coversFound: number;
coversMissing: number;
coversFailed: number;
message: string; message: string;
} }
+6
View File
@@ -27,6 +27,12 @@ export interface ProgressMessage {
unreadableFiles?: number; unreadableFiles?: number;
conversionFailedFiles?: number; conversionFailedFiles?: number;
otherRejectedFiles?: number; otherRejectedFiles?: number;
lyricsFound?: number;
lyricsMissing?: number;
lyricsFailed?: number;
coversFound?: number;
coversMissing?: number;
coversFailed?: number;
} }
export function getProgress(taskId: string): Promise<ProgressMessage | null> { export function getProgress(taskId: string): Promise<ProgressMessage | null> {
+36 -7
View File
@@ -215,6 +215,12 @@
</div> </div>
</div> </div>
<!-- 封面统计 -->
<div class="stats-grid stats-secondary lyric-stats">
<div class="stat-cell-sm"><span class="stat-name-sm">有封面</span><span class="stat-num-sm">{{ coverStats.found }}</span></div>
<div class="stat-cell-sm"><span class="stat-name-sm">无封面</span><span class="stat-num-sm">{{ coverStats.missing }}</span></div>
<div class="stat-cell-sm"><span class="stat-name-sm">封面失败</span><span class="stat-num-sm">{{ coverStats.failed }}</span></div>
</div>
<!-- 歌词统计歌词有无绝不影响音频入库仅观测 --> <!-- 歌词统计歌词有无绝不影响音频入库仅观测 -->
<div class="stats-grid stats-secondary lyric-stats"> <div class="stats-grid stats-secondary lyric-stats">
<div class="stat-cell-sm"> <div class="stat-cell-sm">
@@ -339,6 +345,19 @@ import { useWebSocket } from '../composables/useWebSocket';
import type { ProgressMessage } from '../composables/useWebSocket'; import type { ProgressMessage } from '../composables/useWebSocket';
import { useAppState } from '../composables/useAppState'; 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 { const {
state: appState, state: appState,
isConfigured, isConfigured,
@@ -380,22 +399,27 @@ const canStart = computed(() => isConfigured.value && !submitting.value);
* 歌词统计(独立于 ProgressMessage 类型,避免修改共享类型)。 * 歌词统计(独立于 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 }>({ const lyricStats = reactive<{ found: number; missing: number; failed: number }>({
found: 0, found: 0,
missing: 0, missing: 0,
failed: 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.lyricsFound === 'number') lyricStats.found = src.lyricsFound;
if (typeof src.lyricsMissing === 'number') lyricStats.missing = src.lyricsMissing; if (typeof src.lyricsMissing === 'number') lyricStats.missing = src.lyricsMissing;
if (typeof src.lyricsFailed === 'number') lyricStats.failed = src.lyricsFailed; 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() { function resetLyricStats() {
coverStats.found = 0; coverStats.missing = 0; coverStats.failed = 0;
lyricStats.found = 0; lyricStats.found = 0;
lyricStats.missing = 0; lyricStats.missing = 0;
lyricStats.failed = 0; lyricStats.failed = 0;
@@ -600,7 +624,7 @@ async function scrollConsoleIfPinned() {
function handleProgressMessage(msg: ProgressMessage) { function handleProgressMessage(msg: ProgressMessage) {
Object.assign(progress, msg); Object.assign(progress, msg);
applyLyricStats(msg as unknown as LyricCarrier); applyLyricStats(msg);
recordActivity(msg); recordActivity(msg);
if (typeof msg.running === 'boolean') { if (typeof msg.running === 'boolean') {
@@ -619,7 +643,7 @@ const { connect: wsConnect, disconnect: wsDisconnect } = useWebSocket(taskId, ha
let pollTimer: ReturnType<typeof setInterval> | null = null; let pollTimer: ReturnType<typeof setInterval> | null = null;
function statusToProgress(resp: IngestStatusResponse): ProgressMessage { function statusToProgress(resp: IngestStatusResponse): IngestProgressMessage {
return { return {
taskId: resp.taskId, taskId: resp.taskId,
type: 'ingest', type: 'ingest',
@@ -636,7 +660,13 @@ function statusToProgress(resp: IngestStatusResponse): ProgressMessage {
missingMetadataFiles: resp.missingMetadataFiles, missingMetadataFiles: resp.missingMetadataFiles,
unreadableFiles: resp.unreadableFiles, unreadableFiles: resp.unreadableFiles,
conversionFailedFiles: resp.conversionFailedFiles, 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 { try {
const resp = await getIngestStatus(taskId.value); const resp = await getIngestStatus(taskId.value);
if (resp) { if (resp) {
applyLyricStats(resp);
handleProgressMessage(statusToProgress(resp)); handleProgressMessage(statusToProgress(resp));
if (resp.completed || !resp.running) { if (resp.completed || !resp.running) {
stopPolling(); stopPolling();