feat(lyrics): add SPlayer source (#55)

* feat(lyrics): add SPlayer source

* fix(lyrics): harden SPlayer fallback
This commit is contained in:
rrzt
2026-07-19 20:32:03 -04:00
committed by GitHub
parent 5651052175
commit a079c8a652
7 changed files with 278 additions and 15 deletions
+6 -1
View File
@@ -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
+137 -4
View File
@@ -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,
+5 -2
View File
@@ -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",
}
+13 -4
View File
@@ -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 "",
+12 -2
View File
@@ -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"
+98
View File
@@ -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()
+7 -2
View File
@@ -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",