diff --git a/backend/src/main/java/com/music/service/IngestService.java b/backend/src/main/java/com/music/service/IngestService.java index 6f262c2..f505869 100644 --- a/backend/src/main/java/com/music/service/IngestService.java +++ b/backend/src/main/java/com/music/service/IngestService.java @@ -90,6 +90,8 @@ public class IngestService { private final TraditionalFilterService traditionalFilterService; private final ConfigService configService; private final AudioValidationService audioValidationService; + @Autowired(required = false) + private LyricsService lyricsService; /** * Spring DI 构造器。 @@ -648,6 +650,9 @@ public class IngestService { if (tag != null) { extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist); } + if (lyricsService != null) { + lyricsService.fetchIfMissing(targetDir.resolve(destFileName), title, effectiveArtist); + } return "ingested"; } diff --git a/backend/src/main/java/com/music/service/LyricsService.java b/backend/src/main/java/com/music/service/LyricsService.java new file mode 100644 index 0000000..f80a80e --- /dev/null +++ b/backend/src/main/java/com/music/service/LyricsService.java @@ -0,0 +1,86 @@ +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.ByteArrayOutputStream; +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.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** Optional lyric lookup. All failures are intentionally non-fatal to ingest. */ +@Service +public class LyricsService { + private static final Logger log = LoggerFactory.getLogger(LyricsService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int MAX_BYTES = 256 * 1024; + + public void fetchIfMissing(Path targetFile, String title, String artist) { + if (targetFile == null || title == null || artist == null) return; + Path lrc = targetFile.resolveSibling(baseName(targetFile.getFileName().toString()) + ".lrc"); + if (Files.isRegularFile(lrc)) return; + if (!enabled()) return; + try { + Candidate c = lookup(title, artist); + if (c == null) return; + Path tmp = lrc.resolveSibling(lrc.getFileName().toString() + ".tmp"); + Files.write(tmp, c.lyrics.getBytes(StandardCharsets.UTF_8)); + try { Files.move(tmp, lrc, StandardCopyOption.ATOMIC_MOVE); } + catch (Exception e) { Files.move(tmp, lrc, StandardCopyOption.REPLACE_EXISTING); } + log.info("已获取歌词: {} - {} [{}]", artist, title, c.source); + } catch (Exception e) { + log.warn("歌词获取失败(不影响入库): {} - {} - {}", artist, title, e.getMessage()); + } + } + + private static boolean enabled() { + return Boolean.parseBoolean(value("MANGTOOL_LYRICS_ENABLED", "mangtool.lyrics.enabled", "true")); + } + + Candidate lookup(String title, String artist) throws Exception { + String base = value("MANGTOOL_LYRICS_BASE_URL", "mangtool.lyrics.base-url", "http://lrcapi:1111"); + String auth = value("MANGTOOL_LYRICS_AUTH", "mangtool.lyrics.auth", ""); + String url = base.replaceAll("/$", "") + "/lrc?title=" + URLEncoder.encode(title, "UTF-8") + "&artist=" + URLEncoder.encode(artist, "UTF-8"); + HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + c.setConnectTimeout(5000); c.setReadTimeout(10000); c.setRequestMethod("GET"); + if (!auth.isEmpty()) c.setRequestProperty("Authorization", auth); + if (c.getResponseCode() / 100 != 2) return null; + byte[] body = readLimited(c.getInputStream()); + JsonNode root = MAPPER.readTree(body); + List candidates = new ArrayList<>(); + if (root != null && root.isArray()) for (JsonNode n : root) { + String lyrics = text(n, "lyrics"); + if (lyrics.trim().isEmpty()) continue; + String ct = text(n, "title"), ca = text(n, "artist"); + int score = score(title, artist, ct, ca, lyrics, text(n, "type"), n.path("isComplete").asBoolean(false)); + candidates.add(new Candidate(lyrics, text(n, "source"), score)); + } + return candidates.stream().filter(x -> x.score >= 60).max(Comparator.comparingInt(x -> x.score)).orElse(null); + } + + private static int score(String t, String a, String ct, String ca, String l, String type, boolean complete) { + String nt = norm(t), na = norm(a), nct = norm(ct), nca = norm(ca); int s = 0; + if (nt.equals(nct)) s += 45; else if (nct.contains(nt) || nt.contains(nct)) s += 25; + if (na.equals(nca)) s += 45; else if (nca.contains(na) || na.contains(nca)) s += 20; else s -= 40; + if (l.matches("(?s).*\\[\\d{1,2}:\\d{2}(?:\\.\\d+)?\\].*")) s += 10; + if ("lrc".equalsIgnoreCase(type)) s += 5; if (complete) s += 5; return s; + } + private static String norm(String s) { return s == null ? "" : s.toLowerCase(Locale.ROOT).replaceAll("[((](网易云音乐|酷狗音乐)[))]", "").replaceAll("[^\\p{L}\\p{N}]", ""); } + private static String text(JsonNode n, String k) { return n.path(k).asText(""); } + 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; } + private static String baseName(String n) { int i = n.lastIndexOf('.'); return i > 0 ? n.substring(0, i) : n; } + static class Candidate { final String lyrics, source; final int score; Candidate(String l, String s, int p) { lyrics=l; source=s; score=p; } } +} diff --git a/backend/src/test/java/com/music/service/LyricsServiceTest.java b/backend/src/test/java/com/music/service/LyricsServiceTest.java new file mode 100644 index 0000000..fbd7ce0 --- /dev/null +++ b/backend/src/test/java/com/music/service/LyricsServiceTest.java @@ -0,0 +1,53 @@ +package com.music.service; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class LyricsServiceTest { + private HttpServer server; + + @AfterEach + void cleanup() { + if (server != null) server.stop(0); + System.clearProperty("mangtool.lyrics.base-url"); + System.clearProperty("mangtool.lyrics.auth"); + } + + @Test + void selectsExactArtistInsteadOfFirstCoverCandidate() throws Exception { + String json = "[" + + "{\"title\":\"夜曲 (网易云音乐)\",\"artist\":\"Xai小爱\",\"lyrics\":\"[00:01]翻唱\",\"type\":\"lrc\",\"source\":\"netease\"}," + + "{\"title\":\"夜曲 (酷狗音乐)\",\"artist\":\"周杰伦\",\"lyrics\":\"[00:01]一群嗜血的蚂蚁\",\"type\":\"lrc\",\"source\":\"kugou\",\"isComplete\":true}]"; + start(json, 200); + LyricsService.Candidate result = new LyricsService().lookup("夜曲", "周杰伦"); + assertNotNull(result); + assertEquals("kugou", result.source); + assertTrue(result.lyrics.contains("一群嗜血的蚂蚁")); + } + + @Test + void ignoresNonSuccessfulResponse() throws Exception { + start("not found", 500); + assertNull(new LyricsService().lookup("夜曲", "周杰伦")); + } + + private void start(String body, int status) throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/lrc", exchange -> { + assertEquals("secret", exchange.getRequestHeaders().getFirst("Authorization")); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + System.setProperty("mangtool.lyrics.base-url", "http://127.0.0.1:" + server.getAddress().getPort()); + System.setProperty("mangtool.lyrics.auth", "secret"); + } +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b2003ea..844f7b3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,5 +14,8 @@ services: - ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork environment: - MANGTOOL_HOME=/home/mangtool + - MANGTOOL_LYRICS_ENABLED=${MANGTOOL_LYRICS_ENABLED:-false} + - MANGTOOL_LYRICS_BASE_URL=${MANGTOOL_LYRICS_BASE_URL:-http://lrcapi:1111} + - MANGTOOL_LYRICS_AUTH=${MANGTOOL_LYRICS_AUTH:-} # 如果需要覆盖 ffmpeg 路径: # - MANGTOOL_FFMPEG_BIN=/usr/bin/ffmpeg