diff --git a/lyrics/README.md b/lyrics/README.md index 7be0e02..c9fe755 100644 --- a/lyrics/README.md +++ b/lyrics/README.md @@ -1,4 +1,4 @@ -# Noctalia Lyrics 1.4.0 +# Noctalia Lyrics 1.4.1 Synchronized lyrics for the Noctalia bar, with multiple MPRIS players, translation and romanization layers, configurable sources, karaoke highlighting, @@ -59,6 +59,7 @@ noctalia plugins lint lyrics sh lyrics/scripts/setup-deps.sh --check cd lyrics python3 -m py_compile lyric_sources.py krc_decode.py lrclib_lyric.py +python3 -m unittest test_lyric_sources.py ``` ## Lyrics model @@ -85,6 +86,10 @@ IDs are: - `lrclib`: public LRCLIB search. - `netease`: public NetEase search, synchronized lyrics, translations, and romanization when returned. +- `splayer`: SPlayer's complete current lyric data, including line and word + timing, translations, romanization, background lines, and duet markers. + SPlayer must be running; the default API URL is `http://127.0.0.1:25884`. + Changing this URL sends current track metadata to the configured service. - `qqmusic`: public QQ Music search and lyric endpoint. - `kugou`: public Kugou search and lyric download endpoint. - `qishui`: user-configured HTTP endpoint supporting `{title}`, `{artist}`, and diff --git a/lyrics/lyric_sources.py b/lyrics/lyric_sources.py index 705f534..76c96c7 100644 --- a/lyrics/lyric_sources.py +++ b/lyrics/lyric_sources.py @@ -7,6 +7,7 @@ import json import os import re import sys +import time import urllib.error import urllib.parse import urllib.request @@ -18,6 +19,7 @@ TIME_TAG = re.compile(r"\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]") KRC_LINE = re.compile(r"^\[(\d+),(\d+)\](.*)$") PREFIX_WORD = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)") SUFFIX_WORD = re.compile(r"(.*?)<(\d+),(\d+)(?:,\d+)?>") +QRC_SUFFIX_WORD = re.compile(r"(.*?)[(](\d+),(\d+)[)]") ENHANCED_WORD = re.compile(r"<(?:(\d+):)?(\d{1,2}(?:[.:]\d{1,3})?)>([^<]*)") META_TAG = re.compile(r"^\[(ar|al|ti|by|re|ve|length|offset):", re.I) CREDIT_LINE = re.compile(r"^(词|曲|作词|作曲|编曲|制作人|lyricist|composer|arranger)\s*[::]", re.I) @@ -68,6 +70,64 @@ def line(time=-1, duration=0, text="", translation="", romanization="", chars=No } +def splayer_transmitted_lines(data): + if not isinstance(data, dict): + return [] + + def parse_lines(source_lines): + if not isinstance(source_lines, list): + return [] + result = [] + for line_index, source_line in enumerate(source_lines): + if not isinstance(source_line, dict): + continue + start = number(source_line.get("startTime"), -1) + end = number(source_line.get("endTime"), start) + words = source_line.get("words") if isinstance(source_line.get("words"), list) else [] + text_parts, roman_parts, chars, word_timings = [], [], [], [] + for word in words: + if not isinstance(word, dict): + continue + text = html.unescape(str(word.get("word", ""))).replace("\ufeff", "") + if not text: + continue + word_start = number(word.get("startTime"), start) + word_end = number(word.get("endTime"), word_start) + text_parts.append(text) + roman_word = clean_text(word.get("romanWord", word.get("romanization", ""))) + if roman_word: + roman_parts.append(roman_word) + chars.extend(word_start + index * max(0, word_end - word_start) // max(1, len(text)) + for index in range(len(text))) + word_timings.append({"text": text, "start": word_start, "end": word_end, + "romanization": roman_word}) + text = "".join(text_parts) or source_line.get("text", source_line.get("lyric", "")) + item = line(start, max(0, end - start), text, + source_line.get("translatedLyric", source_line.get("translation", "")), + source_line.get("romanLyric", source_line.get("romanization", "")) + or " ".join(roman_parts), chars) + item["words"] = word_timings + item["is_background"] = source_line.get( + "isBG", source_line.get("isBg", source_line.get("isBackground"))) is True + item["is_duet"] = source_line.get("isDuet") is True + next_line = source_lines[line_index + 1] if line_index + 1 < len(source_lines) else None + next_start = number(next_line.get("startTime"), -1) if isinstance(next_line, dict) else -1 + if len(word_timings) == 1 and end - start >= 7000 and abs(next_start - end) <= 50: + word = word_timings[0] + if abs(word["start"] - start) <= 50 and abs(word["end"] - end) <= 50: + item["duration_inferred"] = True + item["chars"] = [] + if item["text"] or item["translation"] or item["romanization"]: + result.append(item) + return finalize(result, number(data.get("duration"), 0)) + + for key in ("yrcData", "lrcData"): + parsed = parse_lines(data.get(key)) + if parsed: + return parsed + return [] + + def finalize(lines, total_duration=0): cleaned = [] for item in lines or []: @@ -81,6 +141,12 @@ def finalize(lines, total_duration=0): item.get("romanization", item.get("romanized", item.get("romaji", ""))), item.get("chars", item.get("charTimes", [])), ) + if isinstance(item.get("words"), list): + normalized["words"] = item["words"] + if item.get("is_background") is True: + normalized["is_background"] = True + if item.get("is_duet") is True: + normalized["is_duet"] = True if item.get("duration_inferred") is True: normalized["duration_inferred"] = True if normalized["text"] or normalized["translation"] or normalized["romanization"]: @@ -136,18 +202,24 @@ def parse_lrc(text): if krc: start, duration, body = number(krc.group(1)), number(krc.group(2)), krc.group(3) words = PREFIX_WORD.findall(body) if re.match(r"^[<(]\d+,", body) else [] + absolute_word_times = body.startswith("(") if words: pieces = [(word, number(word_offset), number(word_duration)) for word_offset, word_duration, word in words] else: + suffix_words = SUFFIX_WORD.findall(body) + if not suffix_words: + suffix_words = QRC_SUFFIX_WORD.findall(body) + absolute_word_times = bool(suffix_words) pieces = [(word, number(word_offset), number(word_duration)) - for word, word_offset, word_duration in SUFFIX_WORD.findall(body)] + for word, word_offset, word_duration in suffix_words] if pieces: content, chars = "", [] for word, word_offset, word_duration in pieces: for index, character in enumerate(word): content += character - chars.append(start + word_offset + (index * word_duration // max(1, len(word)))) + word_start = word_offset if absolute_word_times else start + word_offset + chars.append(word_start + (index * word_duration // max(1, len(word)))) if clean_text(content): result.append(line(start + offset, duration, content, chars=chars)) continue @@ -238,6 +310,22 @@ def parse_ttml(text): return finalize(primary) +def qrc_content(text): + text = str(text or "").strip() + if not text.startswith("<"): + return text + try: + root = ET.fromstring(text) + for node in root.iter(): + for key, value in node.attrib.items(): + if key.rsplit("}", 1)[-1].lower() == "lyriccontent": + return value + except ET.ParseError: + pass + match = re.search(r'LyricContent\s*=\s*"([\s\S]*?)"\s*/?>', text, re.I) + return html.unescape(match.group(1)) if match else text + + def first_value(data, names): if isinstance(data, dict): for name in names: @@ -335,8 +423,8 @@ def request_data(url, headers=None, data=None, method=None, timeout=15): return response.read(), response.headers.get_content_charset() or "utf-8" -def request_json(url, headers=None, data=None, method=None): - body, charset = request_data(url, headers, data, method) +def request_json(url, headers=None, data=None, method=None, timeout=15): + body, charset = request_data(url, headers, data, method, timeout) text = body.decode(charset, "replace").strip() if text.startswith("callback(") and text.endswith(")"): text = text[9:-1] @@ -449,6 +537,50 @@ def adapter_qqmusic(track, credentials, options): return success(source, lines, ["qqmusic: match"], duration_ms(track.get("duration"))) +def adapter_splayer(track, credentials, options): + source = "splayer" + base_url = clean_text(credentials.get("splayer_api_url")) or "http://127.0.0.1:25884" + parsed_url = urllib.parse.urlsplit(base_url) + if parsed_url.scheme not in ("http", "https") or not parsed_url.netloc: + return empty(source, "splayer: invalid API URL") + title = clean_text(track.get("title")) + artist = clean_text(track.get("artist")) + expected_duration = duration_ms(track.get("duration")) + song_info_endpoint = base_url.rstrip("/") + "/api/control/song-info" + last_state = "unavailable" + for attempt in range(3): + try: + response = request_json(song_info_endpoint, timeout=1) + current = response.get("data", {}) if isinstance(response, dict) else {} + current_title = current.get("name", current.get("playName", "")) + current_artist = current.get("artistName", current.get("artist", current.get("artists", ""))) + if isinstance(current_artist, list): + current_artist = " ".join( + clean_text(item.get("name", item) if isinstance(item, dict) else item) + for item in current_artist + ) + wanted_title = normalize(title) + normalized_title = normalize(current_title) + title_matches = normalized_title == wanted_title or ( + bool(normalized_title) and (normalized_title in wanted_title or wanted_title in normalized_title) + ) + artist_matches = not artist or not current_artist or ( + normalize(artist) in normalize(current_artist) or normalize(current_artist) in normalize(artist) + ) + if title_matches and artist_matches: + lines = splayer_transmitted_lines(current) + if lines: + return success(source, lines, ["splayer: transmitted lyrics"], expected_duration) + last_state = "loading" if current.get("lyricLoading") is True else "empty" + else: + last_state = "track not ready" + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError): + last_state = "API unavailable" + if attempt < 2: + time.sleep(0.4) + return empty(source, "splayer: " + last_state) + + def adapter_kugou(track, credentials, options): source = "kugou" keyword = " ".join(filter(None, (track.get("title"), track.get("artist")))) @@ -609,6 +741,7 @@ ADAPTERS = { "netease_public": adapter_netease, "qq": adapter_qqmusic, "qqmusic": adapter_qqmusic, + "splayer": adapter_splayer, "kugou": adapter_kugou, "qishui": adapter_qishui, "apple": adapter_apple_music, diff --git a/lyrics/lyrics.luau b/lyrics/lyrics.luau index ee132f6..42c6604 100644 --- a/lyrics/lyrics.luau +++ b/lyrics/lyrics.luau @@ -171,8 +171,11 @@ local function getLineInfo() lineEnd = math.min(nextTime, startTime + line.duration) elseif nextTime - startTime >= INTERLUDE_GAP_MS then -- LRC only marks line starts. Do not stretch a lyric across a long instrumental gap. + local firstCharTime = line.chars and tonumber(line.chars[1]) or nil local lastCharTime = line.chars and tonumber(line.chars[#line.chars]) or nil - if lastCharTime and lastCharTime >= startTime and lastCharTime < nextTime then + local hasWordTiming = line.chars and #line.chars >= 2 and firstCharTime and lastCharTime + and lastCharTime > firstCharTime + if hasWordTiming and lastCharTime >= startTime and lastCharTime < nextTime then lineEnd = math.min(nextTime, lastCharTime + 600) else local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000) @@ -545,7 +548,7 @@ local function render() end local sourceNames = { - lrclib = "LRCLIB", netease = "NetEase", qqmusic = "QQ Music", kugou = "Kugou", + lrclib = "LRCLIB", netease = "NetEase", splayer = "SPlayer", qqmusic = "QQ Music", kugou = "Kugou", qishui = "Qishui", apple_music = "Apple Music", spotify = "Spotify", musixmatch = "Musixmatch", mpris = "MPRIS", custom = "Custom", cache = "Cache", } diff --git a/lyrics/lyrics_service.luau b/lyrics/lyrics_service.luau index a52f057..1474e85 100644 --- a/lyrics/lyrics_service.luau +++ b/lyrics/lyrics_service.luau @@ -21,7 +21,7 @@ noctalia.mkdirAll(requestDir) local krcTmp = cacheDir .. "/krc.tmp" local lyricsSource = noctalia.getConfig("lyrics_source") or "auto" local lyricsSources = noctalia.getConfig("lyrics_sources") or { - "lrclib", "netease", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch" + "lrclib", "netease", "splayer", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch" } local customUrl = noctalia.getConfig("custom_url") or "" local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics" @@ -53,7 +53,8 @@ local playerAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist") local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist")) local function trackKey(track) - return (track.playerInstance or "") .. "|" .. track.title .. "|" .. track.artist .. "|" .. track.album + return (track.playerInstance or "") .. "|" .. (track.trackId or "") .. "|" + .. track.title .. "|" .. track.artist .. "|" .. track.album end local function patternMatches(value, pattern) @@ -177,6 +178,8 @@ local function credentialsFor(source) qishui_token = noctalia.getConfig("qishui_token") or "", qishui_api_url = noctalia.getConfig("qishui_api_url") or "", } + elseif source == "splayer" then + return { splayer_api_url = noctalia.getConfig("splayer_api_url") or "http://127.0.0.1:25884" } end return {} end @@ -581,7 +584,7 @@ end local function poll() if pollInFlight then return end pollInFlight = true - local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]] + local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{mpris:trackid}}\x1f{{xesam:url}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]] local started = noctalia.runAsync(cmd, function(r) pollInFlight = false @@ -609,7 +612,9 @@ local function poll() position = tonumber(parts[7]) or 0, duration = tonumber(parts[8]) or 0, artUrl = parts[9] or "", - embeddedLyrics = parts[10] or "", + trackId = parts[10] or "", + mediaUrl = parts[11] or "", + embeddedLyrics = parts[12] or "", } if player.instance ~= "" or player.name ~= "" then players[#players + 1] = player end end @@ -641,6 +646,8 @@ local function poll() position = selected.position, duration = selected.duration, playerInstance = selected.instance, + trackId = selected.trackId, + mediaUrl = selected.mediaUrl, } local tk = trackKey(t) currentTrack = t @@ -716,6 +723,7 @@ function onConfigChanged() noctalia.getConfig("musixmatch_token") or "", noctalia.getConfig("qishui_token") or "", noctalia.getConfig("qishui_api_url") or "", + noctalia.getConfig("splayer_api_url") or "http://127.0.0.1:25884", noctalia.getConfig("translation_language") or "zh-Hans", }, "\0") local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist")) @@ -758,6 +766,7 @@ local function applyPushedLyrics(payload) currentTrack = decoded.track lastTrackKey = trackKey({ playerInstance = decoded.track.playerInstance or "", + trackId = decoded.track.trackId or "", title = decoded.track.title or "", artist = decoded.track.artist or "", album = decoded.track.album or "", diff --git a/lyrics/plugin.toml b/lyrics/plugin.toml index 27fdccf..2af682d 100644 --- a/lyrics/plugin.toml +++ b/lyrics/plugin.toml @@ -1,6 +1,6 @@ id = "h465855hgg/lyrics" name = "Lyrics" -version = "1.4.0" +version = "1.4.1" plugin_api = 3 author = "h465855hgg" license = "MIT" @@ -35,6 +35,7 @@ options = [ { value = "auto", label_key = "settings.lyrics_source.options.auto" }, { value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" }, { value = "netease", label_key = "settings.lyrics_source.options.netease" }, + { value = "splayer", label_key = "settings.lyrics_source.options.splayer" }, { value = "qqmusic", label_key = "settings.lyrics_source.options.qqmusic" }, { value = "kugou", label_key = "settings.lyrics_source.options.kugou" }, { value = "qishui", label_key = "settings.lyrics_source.options.qishui" }, @@ -51,7 +52,7 @@ key = "lyrics_sources" type = "string_list" label_key = "settings.lyrics_sources.label" description_key = "settings.lyrics_sources.description" -default = ["lrclib", "netease", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch"] +default = ["lrclib", "netease", "splayer", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch"] visible_when = { key = "lyrics_source", values = ["auto"] } advanced = true @@ -123,6 +124,15 @@ default = "" advanced = true visible_when = { key = "lyrics_source", values = ["musixmatch"] } +[[setting]] +key = "splayer_api_url" +type = "string" +label_key = "settings.splayer_api_url.label" +description_key = "settings.splayer_api_url.description" +default = "http://127.0.0.1:25884" +advanced = true +visible_when = { key = "lyrics_source", values = ["splayer"] } + [[setting]] key = "qishui_api_url" type = "string" diff --git a/lyrics/test_lyric_sources.py b/lyrics/test_lyric_sources.py new file mode 100644 index 0000000..65dc497 --- /dev/null +++ b/lyrics/test_lyric_sources.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + +import unittest +from unittest import mock +import urllib.error + +import lyric_sources + + +class SPlayerLinesTest(unittest.TestCase): + def test_preserves_timing_layers_and_markers(self): + lines = lyric_sources.splayer_transmitted_lines({ + "duration": 5000, + "yrcData": [{ + "startTime": 1000, + "endTime": 3000, + "translatedLyric": "Hello", + "isBG": True, + "isDuet": True, + "words": [ + {"word": "A", "startTime": 1000, "endTime": 1500, "romanWord": "ay"}, + {"word": "B", "startTime": 1500, "endTime": 2000, "romanWord": "bee"}, + ], + }], + }) + + self.assertEqual(lines[0]["text"], "AB") + self.assertEqual(lines[0]["translation"], "Hello") + self.assertEqual(lines[0]["romanization"], "ay bee") + self.assertEqual(lines[0]["chars"], [1000, 1500]) + self.assertTrue(lines[0]["is_background"]) + self.assertTrue(lines[0]["is_duet"]) + self.assertEqual(lines[0]["words"][1]["end"], 2000) + + def test_falls_back_to_lrc_when_yrc_is_invalid(self): + lines = lyric_sources.splayer_transmitted_lines({ + "yrcData": [{"unexpected": "value"}], + "lrcData": [{"startTime": 2000, "endTime": 3000, "text": "fallback"}], + }) + + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0]["text"], "fallback") + + def test_marks_stretched_single_word_as_inferred(self): + lines = lyric_sources.splayer_transmitted_lines({ + "yrcData": [ + { + "startTime": 1000, + "endTime": 9000, + "words": [{"word": "line", "startTime": 1000, "endTime": 9000}], + }, + {"startTime": 9000, "endTime": 10000, "text": "next"}, + ], + }) + + self.assertTrue(lines[0]["duration_inferred"]) + self.assertEqual(lines[0]["chars"], []) + + +class SPlayerAdapterTest(unittest.TestCase): + @mock.patch("lyric_sources.time.sleep") + @mock.patch("lyric_sources.request_json") + def test_unavailable_api_uses_bounded_retries(self, request_json, sleep): + request_json.side_effect = urllib.error.URLError("offline") + + result = lyric_sources.adapter_splayer( + {"title": "Song", "artist": "Artist"}, + {"splayer_api_url": "http://127.0.0.1:25884"}, + {}, + ) + + self.assertEqual(result["type"], "none") + self.assertEqual(result["diag"], ["splayer: API unavailable"]) + self.assertEqual(request_json.call_count, 3) + request_json.assert_called_with( + "http://127.0.0.1:25884/api/control/song-info", timeout=1 + ) + self.assertEqual(sleep.call_count, 2) + + @mock.patch("lyric_sources.request_json") + def test_matches_title_suffix_and_artist_list(self, request_json): + request_json.return_value = {"data": { + "name": "Song (Live)", + "artists": [{"name": "Artist"}], + "lrcData": [{"startTime": 0, "endTime": 1000, "text": "line"}], + }} + + result = lyric_sources.adapter_splayer( + {"title": "Song", "artist": "Artist"}, + {"splayer_api_url": "http://127.0.0.1:25884"}, + {}, + ) + + self.assertEqual(result["type"], "lyrics") + + +if __name__ == "__main__": + unittest.main() diff --git a/lyrics/translations/en.json b/lyrics/translations/en.json index c772d6c..d91bd0e 100644 --- a/lyrics/translations/en.json +++ b/lyrics/translations/en.json @@ -167,11 +167,12 @@ "netease": "NetEase Music (public API)", "qishui": "Qishui Music", "qqmusic": "QQ Music", - "spotify": "Spotify" + "spotify": "Spotify", + "splayer": "SPlayer" } }, "lyrics_sources": { - "description": "Source IDs tried from top to bottom: lrclib, netease, qqmusic, kugou, qishui, apple_music, spotify, musixmatch, mpris, or custom.", + "description": "Source IDs tried from top to bottom: lrclib, netease, splayer, qqmusic, kugou, qishui, apple_music, spotify, musixmatch, mpris, or custom.", "label": "Automatic source order" }, "marquee_speed": { @@ -272,6 +273,10 @@ "description": "Optional manually supplied Spotify web cookie. Stored as a normal plugin setting; never written to logs.", "label": "Spotify sp_dc" }, + "splayer_api_url": { + "description": "SPlayer local service URL. Defaults to http://127.0.0.1:25884 and reads the complete lyrics currently loaded by SPlayer.", + "label": "SPlayer API URL" + }, "translation_language": { "description": "Preferred translated-lyric language when supported by the source.", "label": "Translation language",