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