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, Synchronized lyrics for the Noctalia bar, with multiple MPRIS players,
translation and romanization layers, configurable sources, karaoke highlighting, translation and romanization layers, configurable sources, karaoke highlighting,
@@ -59,6 +59,7 @@ noctalia plugins lint lyrics
sh lyrics/scripts/setup-deps.sh --check sh lyrics/scripts/setup-deps.sh --check
cd lyrics cd lyrics
python3 -m py_compile lyric_sources.py krc_decode.py lrclib_lyric.py python3 -m py_compile lyric_sources.py krc_decode.py lrclib_lyric.py
python3 -m unittest test_lyric_sources.py
``` ```
## Lyrics model ## Lyrics model
@@ -85,6 +86,10 @@ IDs are:
- `lrclib`: public LRCLIB search. - `lrclib`: public LRCLIB search.
- `netease`: public NetEase search, synchronized lyrics, translations, and - `netease`: public NetEase search, synchronized lyrics, translations, and
romanization when returned. 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. - `qqmusic`: public QQ Music search and lyric endpoint.
- `kugou`: public Kugou search and lyric download endpoint. - `kugou`: public Kugou search and lyric download endpoint.
- `qishui`: user-configured HTTP endpoint supporting `{title}`, `{artist}`, and - `qishui`: user-configured HTTP endpoint supporting `{title}`, `{artist}`, and
+137 -4
View File
@@ -7,6 +7,7 @@ import json
import os import os
import re import re
import sys import sys
import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request 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+)\](.*)$") KRC_LINE = re.compile(r"^\[(\d+),(\d+)\](.*)$")
PREFIX_WORD = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)") PREFIX_WORD = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)")
SUFFIX_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})?)>([^<]*)") 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) 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) 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): def finalize(lines, total_duration=0):
cleaned = [] cleaned = []
for item in lines or []: 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("romanization", item.get("romanized", item.get("romaji", ""))),
item.get("chars", item.get("charTimes", [])), 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: if item.get("duration_inferred") is True:
normalized["duration_inferred"] = True normalized["duration_inferred"] = True
if normalized["text"] or normalized["translation"] or normalized["romanization"]: if normalized["text"] or normalized["translation"] or normalized["romanization"]:
@@ -136,18 +202,24 @@ def parse_lrc(text):
if krc: if krc:
start, duration, body = number(krc.group(1)), number(krc.group(2)), krc.group(3) 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 [] words = PREFIX_WORD.findall(body) if re.match(r"^[<(]\d+,", body) else []
absolute_word_times = body.startswith("(")
if words: if words:
pieces = [(word, number(word_offset), number(word_duration)) pieces = [(word, number(word_offset), number(word_duration))
for word_offset, word_duration, word in words] for word_offset, word_duration, word in words]
else: 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)) 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: if pieces:
content, chars = "", [] content, chars = "", []
for word, word_offset, word_duration in pieces: for word, word_offset, word_duration in pieces:
for index, character in enumerate(word): for index, character in enumerate(word):
content += character 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): if clean_text(content):
result.append(line(start + offset, duration, content, chars=chars)) result.append(line(start + offset, duration, content, chars=chars))
continue continue
@@ -238,6 +310,22 @@ def parse_ttml(text):
return finalize(primary) 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): def first_value(data, names):
if isinstance(data, dict): if isinstance(data, dict):
for name in names: 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" return response.read(), response.headers.get_content_charset() or "utf-8"
def request_json(url, headers=None, data=None, method=None): def request_json(url, headers=None, data=None, method=None, timeout=15):
body, charset = request_data(url, headers, data, method) body, charset = request_data(url, headers, data, method, timeout)
text = body.decode(charset, "replace").strip() text = body.decode(charset, "replace").strip()
if text.startswith("callback(") and text.endswith(")"): if text.startswith("callback(") and text.endswith(")"):
text = text[9:-1] 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"))) 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): def adapter_kugou(track, credentials, options):
source = "kugou" source = "kugou"
keyword = " ".join(filter(None, (track.get("title"), track.get("artist")))) keyword = " ".join(filter(None, (track.get("title"), track.get("artist"))))
@@ -609,6 +741,7 @@ ADAPTERS = {
"netease_public": adapter_netease, "netease_public": adapter_netease,
"qq": adapter_qqmusic, "qq": adapter_qqmusic,
"qqmusic": adapter_qqmusic, "qqmusic": adapter_qqmusic,
"splayer": adapter_splayer,
"kugou": adapter_kugou, "kugou": adapter_kugou,
"qishui": adapter_qishui, "qishui": adapter_qishui,
"apple": adapter_apple_music, "apple": adapter_apple_music,
+5 -2
View File
@@ -171,8 +171,11 @@ local function getLineInfo()
lineEnd = math.min(nextTime, startTime + line.duration) lineEnd = math.min(nextTime, startTime + line.duration)
elseif nextTime - startTime >= INTERLUDE_GAP_MS then elseif nextTime - startTime >= INTERLUDE_GAP_MS then
-- LRC only marks line starts. Do not stretch a lyric across a long instrumental gap. -- 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 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) lineEnd = math.min(nextTime, lastCharTime + 600)
else else
local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000) local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000)
@@ -545,7 +548,7 @@ local function render()
end end
local sourceNames = { 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", qishui = "Qishui", apple_music = "Apple Music", spotify = "Spotify",
musixmatch = "Musixmatch", mpris = "MPRIS", custom = "Custom", cache = "Cache", 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 krcTmp = cacheDir .. "/krc.tmp"
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto" local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
local lyricsSources = noctalia.getConfig("lyrics_sources") or { 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 customUrl = noctalia.getConfig("custom_url") or ""
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics" 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 playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local function trackKey(track) 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 end
local function patternMatches(value, pattern) local function patternMatches(value, pattern)
@@ -177,6 +178,8 @@ local function credentialsFor(source)
qishui_token = noctalia.getConfig("qishui_token") or "", qishui_token = noctalia.getConfig("qishui_token") or "",
qishui_api_url = noctalia.getConfig("qishui_api_url") 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 end
return {} return {}
end end
@@ -581,7 +584,7 @@ end
local function poll() local function poll()
if pollInFlight then return end if pollInFlight then return end
pollInFlight = true 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) local started = noctalia.runAsync(cmd, function(r)
pollInFlight = false pollInFlight = false
@@ -609,7 +612,9 @@ local function poll()
position = tonumber(parts[7]) or 0, position = tonumber(parts[7]) or 0,
duration = tonumber(parts[8]) or 0, duration = tonumber(parts[8]) or 0,
artUrl = parts[9] or "", 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 if player.instance ~= "" or player.name ~= "" then players[#players + 1] = player end
end end
@@ -641,6 +646,8 @@ local function poll()
position = selected.position, position = selected.position,
duration = selected.duration, duration = selected.duration,
playerInstance = selected.instance, playerInstance = selected.instance,
trackId = selected.trackId,
mediaUrl = selected.mediaUrl,
} }
local tk = trackKey(t) local tk = trackKey(t)
currentTrack = t currentTrack = t
@@ -716,6 +723,7 @@ function onConfigChanged()
noctalia.getConfig("musixmatch_token") or "", noctalia.getConfig("musixmatch_token") or "",
noctalia.getConfig("qishui_token") or "", noctalia.getConfig("qishui_token") or "",
noctalia.getConfig("qishui_api_url") 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", noctalia.getConfig("translation_language") or "zh-Hans",
}, "\0") }, "\0")
local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist")) local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
@@ -758,6 +766,7 @@ local function applyPushedLyrics(payload)
currentTrack = decoded.track currentTrack = decoded.track
lastTrackKey = trackKey({ lastTrackKey = trackKey({
playerInstance = decoded.track.playerInstance or "", playerInstance = decoded.track.playerInstance or "",
trackId = decoded.track.trackId or "",
title = decoded.track.title or "", title = decoded.track.title or "",
artist = decoded.track.artist or "", artist = decoded.track.artist or "",
album = decoded.track.album or "", album = decoded.track.album or "",
+12 -2
View File
@@ -1,6 +1,6 @@
id = "h465855hgg/lyrics" id = "h465855hgg/lyrics"
name = "Lyrics" name = "Lyrics"
version = "1.4.0" version = "1.4.1"
plugin_api = 3 plugin_api = 3
author = "h465855hgg" author = "h465855hgg"
license = "MIT" license = "MIT"
@@ -35,6 +35,7 @@ options = [
{ value = "auto", label_key = "settings.lyrics_source.options.auto" }, { value = "auto", label_key = "settings.lyrics_source.options.auto" },
{ value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" }, { value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" },
{ value = "netease", label_key = "settings.lyrics_source.options.netease" }, { 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 = "qqmusic", label_key = "settings.lyrics_source.options.qqmusic" },
{ value = "kugou", label_key = "settings.lyrics_source.options.kugou" }, { value = "kugou", label_key = "settings.lyrics_source.options.kugou" },
{ value = "qishui", label_key = "settings.lyrics_source.options.qishui" }, { value = "qishui", label_key = "settings.lyrics_source.options.qishui" },
@@ -51,7 +52,7 @@ key = "lyrics_sources"
type = "string_list" type = "string_list"
label_key = "settings.lyrics_sources.label" label_key = "settings.lyrics_sources.label"
description_key = "settings.lyrics_sources.description" 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"] } visible_when = { key = "lyrics_source", values = ["auto"] }
advanced = true advanced = true
@@ -123,6 +124,15 @@ default = ""
advanced = true advanced = true
visible_when = { key = "lyrics_source", values = ["musixmatch"] } 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]] [[setting]]
key = "qishui_api_url" key = "qishui_api_url"
type = "string" 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)", "netease": "NetEase Music (public API)",
"qishui": "Qishui Music", "qishui": "Qishui Music",
"qqmusic": "QQ Music", "qqmusic": "QQ Music",
"spotify": "Spotify" "spotify": "Spotify",
"splayer": "SPlayer"
} }
}, },
"lyrics_sources": { "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" "label": "Automatic source order"
}, },
"marquee_speed": { "marquee_speed": {
@@ -272,6 +273,10 @@
"description": "Optional manually supplied Spotify web cookie. Stored as a normal plugin setting; never written to logs.", "description": "Optional manually supplied Spotify web cookie. Stored as a normal plugin setting; never written to logs.",
"label": "Spotify sp_dc" "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": { "translation_language": {
"description": "Preferred translated-lyric language when supported by the source.", "description": "Preferred translated-lyric language when supported by the source.",
"label": "Translation language", "label": "Translation language",