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
+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,