Template
Compare commits
2
Commits
3462881a0a
...
f5033c7020
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5033c7020 | ||
|
|
0ba6297596 |
@@ -90,6 +90,8 @@ public class IngestService {
|
|||||||
private final TraditionalFilterService traditionalFilterService;
|
private final TraditionalFilterService traditionalFilterService;
|
||||||
private final ConfigService configService;
|
private final ConfigService configService;
|
||||||
private final AudioValidationService audioValidationService;
|
private final AudioValidationService audioValidationService;
|
||||||
|
@Autowired(required = false)
|
||||||
|
private LyricsService lyricsService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring DI 构造器。
|
* Spring DI 构造器。
|
||||||
@@ -648,6 +650,9 @@ public class IngestService {
|
|||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
|
extractEmbeddedLyrics(tag, targetDir, trackStr, safeTitle, title, effectiveArtist);
|
||||||
}
|
}
|
||||||
|
if (lyricsService != null) {
|
||||||
|
lyricsService.fetchIfMissing(targetDir.resolve(destFileName), title, effectiveArtist);
|
||||||
|
}
|
||||||
|
|
||||||
return "ingested";
|
return "ingested";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Candidate> 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; } }
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,5 +14,8 @@ services:
|
|||||||
- ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork
|
- ${MANGTOOL_DATA_DIR:-./data}:/home/mangtool/MusicWork
|
||||||
environment:
|
environment:
|
||||||
- MANGTOOL_HOME=/home/mangtool
|
- 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 路径:
|
# 如果需要覆盖 ffmpeg 路径:
|
||||||
# - MANGTOOL_FFMPEG_BIN=/usr/bin/ffmpeg
|
# - MANGTOOL_FFMPEG_BIN=/usr/bin/ffmpeg
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LIBRARY_DIR="${1:-/vol3/1000/音视频/MusicWork/Library}"
|
||||||
|
BASE_URL="${MANGTOOL_LYRICS_BASE_URL:-http://192.168.10.159:28883}"
|
||||||
|
AUTH="${MANGTOOL_LYRICS_AUTH:-}"
|
||||||
|
[[ -d "$LIBRARY_DIR" ]] || { echo "Library 不存在: $LIBRARY_DIR" >&2; exit 1; }
|
||||||
|
export BASE_URL AUTH
|
||||||
|
find "$LIBRARY_DIR" -type f \( -iname '*.mp3' -o -iname '*.flac' -o -iname '*.m4a' -o -iname '*.aac' -o -iname '*.ogg' -o -iname '*.opus' -o -iname '*.wma' \) -print0 |
|
||||||
|
while IFS= read -r -d '' audio; do
|
||||||
|
lrc="${audio%.*}.lrc"; [[ -s "$lrc" ]] && { echo "SKIP $audio"; continue; }
|
||||||
|
meta=$(ffprobe -v error -show_entries format_tags=title,artist -of json "$audio" 2>/dev/null || true)
|
||||||
|
result=$(AUDIO_META="$meta" python3 - <<'PY'
|
||||||
|
import json,os,sys,urllib.parse,urllib.request
|
||||||
|
m=json.loads(os.environ.get('AUDIO_META','{}')).get('format',{}).get('tags',{}); t=m.get('title','').strip(); a=m.get('artist','').strip()
|
||||||
|
if not t or not a: sys.exit(0)
|
||||||
|
req=urllib.request.Request(os.environ['BASE_URL'].rstrip('/')+'/lrc?'+urllib.parse.urlencode({'title':t,'artist':a}))
|
||||||
|
if os.environ.get('AUTH'): req.add_header('Authorization',os.environ['AUTH'])
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req,timeout=15) as r: rows=json.load(r)
|
||||||
|
except Exception: sys.exit(0)
|
||||||
|
def n(x): return ''.join(c.lower() for c in x if c.isalnum())
|
||||||
|
best=None
|
||||||
|
for x in rows if isinstance(rows,list) else []:
|
||||||
|
l=x.get('lyrics','').strip(); ct=x.get('title',''); ca=x.get('artist','')
|
||||||
|
if not l: continue
|
||||||
|
s=(45 if n(t)==n(ct) else 25 if n(t) in n(ct) or n(ct) in n(t) else 0)+(45 if n(a)==n(ca) else 20 if n(a) in n(ca) or n(ca) in n(a) else -40)
|
||||||
|
s+=10 if '[' in l and ':' in l else 0
|
||||||
|
if s>=60 and (best is None or s>best[0]): best=(s,l)
|
||||||
|
if best: sys.stdout.write(best[1])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
[[ -n "$result" ]] || { echo "MISS $audio"; continue; }
|
||||||
|
printf '%s\n' "$result" > "${lrc}.tmp" && mv -f "${lrc}.tmp" "$lrc"; echo "OK $lrc"
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user