Template
feat: fetch and backfill cover art
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user