Template
feat: fetch and backfill cover art
This commit is contained in:
@@ -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