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
@@ -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()
);
@@ -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;
}
@@ -14,6 +14,7 @@ public class LibraryHealthRepairRequest {
/** 为缺歌词的曲目远程回填歌词(非致命,失败仅统计)。 */
private boolean backfillLyrics;
private boolean backfillCovers;
/** 删除孤立侧车(无对应音频的 .lrc)。仅删除确为孤立的侧车。 */
private boolean removeOrphanSidecars;
@@ -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;
@@ -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;
}
@@ -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;
@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);
@@ -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<FileOutcome> files = new ArrayList<>();
@@ -173,12 +187,12 @@ public class IngestTaskStore {
* 汇总计数始终为最新值,二者共同保证重启后可经 /api/ingest/status 恢复观测数据。</p>
*/
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 丢弃最旧任务。 */
@@ -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<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
if (request.isRemoveDecodeInvalid() && audioValidationService != null) {
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
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);
}
};
@@ -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.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));
@@ -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()));
}
}
@@ -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);