feat(lyrics): add SPlayer source (#55)
* feat(lyrics): add SPlayer source * fix(lyrics): harden SPlayer fallback
This commit is contained in:
+6
-1
@@ -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
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user