#!/usr/bin/env python3 """Standalone, standard-library lyric source adapter for the Noctalia plugin.""" import base64 import html import json import os import re import sys import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET USER_AGENT = "Noctalia-Lyrics/1.0" 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+)?>") 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) def empty(source, *diag): return {"type": "none", "source": source, "lines": [], "diag": list(diag)} def clean_text(value): if value is None: return "" return html.unescape(str(value)).replace("\ufeff", "").strip() def number(value, default=0): try: return int(float(value)) except (TypeError, ValueError, OverflowError): return default def normalize(value): return "".join(c.lower() for c in clean_text(value) if c.isalnum()) def timestamp_ms(minutes, seconds): seconds = str(seconds).replace(":", ".") return int(round(number(minutes) * 60000 + float(seconds) * 1000)) def duration_ms(value): value = number(value, 0) if value <= 0: return 0 # Track duration is commonly milliseconds, but MPRIS callers may send microseconds. return value // 1000 if value > 10_000_000 else value def line(time=-1, duration=0, text="", translation="", romanization="", chars=None): return { "time": number(time, -1), "duration": max(0, number(duration)), "text": clean_text(text), "translation": clean_text(translation), "romanization": clean_text(romanization), "chars": [number(item) for item in (chars or [])], } def finalize(lines, total_duration=0): cleaned = [] for item in lines or []: if not isinstance(item, dict): continue normalized = line( item.get("time", item.get("start", item.get("startTimeMs", -1))), item.get("duration", item.get("durationMs", 0)), item.get("text", item.get("words", item.get("lyric", ""))), item.get("translation", item.get("translated", "")), item.get("romanization", item.get("romanized", item.get("romaji", ""))), item.get("chars", item.get("charTimes", [])), ) if normalized["text"] or normalized["translation"] or normalized["romanization"]: cleaned.append(normalized) cleaned.sort(key=lambda item: (item["time"] < 0, item["time"] if item["time"] >= 0 else 0)) for index, item in enumerate(cleaned): if item["duration"] > 0 or item["time"] < 0: continue next_time = next( (other["time"] for other in cleaned[index + 1:] if other["time"] > item["time"]), total_duration if total_duration > item["time"] else 0, ) if next_time: item["duration"] = max(0, next_time - item["time"]) return cleaned def merge_timed(primary, secondary, field, tolerance=500): if not primary or not secondary: return primary untimed = [item for item in secondary if item.get("time", -1) < 0] timed = [item for item in secondary if item.get("time", -1) >= 0] for index, target in enumerate(primary): value = "" if target.get("time", -1) >= 0 and timed: candidate = min(timed, key=lambda item: abs(item["time"] - target["time"])) if abs(candidate["time"] - target["time"]) <= tolerance: value = candidate.get("text", "") elif index < len(untimed): value = untimed[index].get("text", "") if value and not target.get(field): target[field] = value return primary def parse_plain(text): return [line(-1, text=value) for value in str(text or "").splitlines() if clean_text(value)] def parse_lrc(text): text = str(text or "").replace("\r\n", "\n").replace("\r", "\n") offset = 0 match = re.search(r"\[offset:([+-]?\d+)\]", text, re.I) if match: offset = number(match.group(1)) result = [] for raw in text.splitlines(): raw = raw.strip() if not raw or META_TAG.match(raw): continue krc = KRC_LINE.match(raw) 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 [] if words: pieces = [(word, number(word_offset), number(word_duration)) for word_offset, word_duration, word in words] else: pieces = [(word, number(word_offset), number(word_duration)) for word, word_offset, word_duration in SUFFIX_WORD.findall(body)] 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)))) if clean_text(content): result.append(line(start + offset, duration, content, chars=chars)) continue if clean_text(body): result.append(line(start + offset, duration, body)) continue tags = list(TIME_TAG.finditer(raw)) if not tags: continue body = TIME_TAG.sub("", raw).strip() enhanced = list(ENHANCED_WORD.finditer(body)) visible = clean_text(ENHANCED_WORD.sub(lambda item: item.group(3), body)) if enhanced else clean_text(body) if not visible: continue for tag in tags: start = timestamp_ms(tag.group(1), tag.group(2)) + offset if CREDIT_LINE.match(visible) or (start <= 1000 and " - " in visible): continue chars = [] if enhanced: for word in enhanced: word_time = timestamp_ms(word.group(1) or tag.group(1), word.group(2)) + offset chars.extend([word_time] * len(word.group(3))) result.append(line(start, text=visible, chars=chars)) return finalize(result) def parse_time_expression(value): value = clean_text(value) if not value: return -1 if value.endswith("ms"): return number(value[:-2], -1) if value.endswith("s"): try: return int(float(value[:-1]) * 1000) except ValueError: return -1 parts = value.split(":") try: if len(parts) == 3: return int((float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])) * 1000) if len(parts) == 2: return int((float(parts[0]) * 60 + float(parts[1])) * 1000) return int(float(value) * 1000) except ValueError: return -1 def parse_ttml(text): try: root = ET.fromstring(text) except (ET.ParseError, TypeError): return [] result = [] for node in root.iter(): if node.tag.rsplit("}", 1)[-1] != "p": continue start = parse_time_expression(node.attrib.get("begin", "")) end = parse_time_expression(node.attrib.get("end", "")) content = clean_text("".join(node.itertext())) if not content: continue chars = [] for child in node.iter(): if child is node or child.tag.rsplit("}", 1)[-1] != "span": continue child_text = "".join(child.itertext()) child_start = parse_time_expression(child.attrib.get("begin", "")) if child_text and child_start >= 0: chars.extend([child_start] * len(child_text)) role = " ".join(str(value) for key, value in node.attrib.items() if "role" in key.lower()).lower() item = line(start, max(0, end - start) if end >= start >= 0 else 0, content, chars=chars) if "translation" in role: item["_kind"] = "translation" elif "roman" in role: item["_kind"] = "romanization" result.append(item) primary = [item for item in result if not item.get("_kind")] translations = [item for item in result if item.get("_kind") == "translation"] romanizations = [item for item in result if item.get("_kind") == "romanization"] if not primary: primary = translations or romanizations merge_timed(primary, translations, "translation") merge_timed(primary, romanizations, "romanization") for item in primary: item.pop("_kind", None) return finalize(primary) def first_value(data, names): if isinstance(data, dict): for name in names: if name in data and data[name] not in (None, "", [], {}): return data[name] for value in data.values(): found = first_value(value, names) if found not in (None, "", [], {}): return found elif isinstance(data, list): for value in data: found = first_value(value, names) if found not in (None, "", [], {}): return found return None def parse_json_lines(value): if isinstance(value, str): stripped = value.strip() if stripped.startswith("<") and (" best_score: best, best_score = item, score return best if best_score >= 3 else None def success(source, lines, diag, total=0): lines = finalize(lines, total) return {"type": "lyrics", "source": source, "lines": lines, "diag": diag} if lines else empty(source, *diag) def adapter_lrclib(track, credentials, options): source = "lrclib" params = {"track_name": track.get("title", ""), "artist_name": track.get("artist", "")} if track.get("album"): params["album_name"] = track["album"] data = request_json(query_url("https://lrclib.net/api/search", params)) best = best_match(data, track, lambda x: x.get("trackName", ""), lambda x: x.get("artistName", ""), lambda x: x.get("albumName", "")) if not best: return empty(source, "lrclib: no match") lyrics = best.get("syncedLyrics") or best.get("plainLyrics") or "" return success(source, parse_lrc(lyrics) or parse_plain(lyrics), ["lrclib: match"], duration_ms(track.get("duration"))) def adapter_netease(track, credentials, options): source = "netease" search = request_json(query_url("https://music.163.com/api/search/get", { "type": 1, "s": " ".join(filter(None, (track.get("title"), track.get("artist")))), "limit": 10 }), {"Referer": "https://music.163.com/"}) songs = search.get("result", {}).get("songs", []) best = best_match(songs, track, lambda x: x.get("name", ""), lambda x: " ".join(a.get("name", "") for a in x.get("artists", [])), lambda x: x.get("album", {}).get("name", "")) if not best: return empty(source, "netease: no match") data = request_json(query_url("https://music.163.com/api/song/lyric", { "id": best.get("id"), "lv": 1, "kv": 1, "tv": 1, "rv": 1, "yv": 1 }), {"Referer": "https://music.163.com/"}) lines = [] for name in ("yrc", "klyric", "lrc"): main = data.get(name, {}) main = main.get("lyric", "") if isinstance(main, dict) else main lines = parse_lrc(main) if main else [] if lines: break translation = data.get("tlyric", {}) romanization = data.get("romalrc", {}) merge_timed(lines, parse_lrc(translation.get("lyric", "") if isinstance(translation, dict) else translation), "translation") merge_timed(lines, parse_lrc(romanization.get("lyric", "") if isinstance(romanization, dict) else romanization), "romanization") return success(source, lines, ["netease: match"], duration_ms(track.get("duration"))) def adapter_qqmusic(track, credentials, options): source = "qqmusic" search = request_json(query_url("https://c.y.qq.com/soso/fcgi-bin/client_search_cp", { "format": "json", "p": 1, "n": 10, "w": " ".join(filter(None, (track.get("title"), track.get("artist")))) }), {"Referer": "https://y.qq.com/"}) songs = search.get("data", {}).get("song", {}).get("list", []) best = best_match(songs, track, lambda x: x.get("songname", x.get("title", "")), lambda x: " ".join(a.get("name", "") for a in x.get("singer", [])), lambda x: x.get("albumname", "")) if not best: return empty(source, "qqmusic: no match") data = request_json(query_url("https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg", { "songmid": best.get("songmid", best.get("mid", "")), "format": "json", "nobase64": 1, "g_tk": 5381 }), {"Referer": "https://y.qq.com/portal/player.html"}) def decoded(name): value = data.get(name, "") if not value: return "" try: return base64.b64decode(value).decode("utf-8", "replace") if not TIME_TAG.search(value) else value except (ValueError, TypeError): return value lines = parse_lrc(decoded("lyric")) merge_timed(lines, parse_lrc(decoded("trans")), "translation") merge_timed(lines, parse_lrc(decoded("roma")), "romanization") return success(source, lines, ["qqmusic: match"], duration_ms(track.get("duration"))) def adapter_kugou(track, credentials, options): source = "kugou" keyword = " ".join(filter(None, (track.get("title"), track.get("artist")))) search = request_json(query_url("https://mobilecdn.kugou.com/api/v3/search/song", { "format": "json", "keyword": keyword, "page": 1, "pagesize": 10, "showtype": 1 })) songs = search.get("data", {}).get("info", []) best = best_match(songs, track, lambda x: x.get("songname", x.get("filename", "")), lambda x: x.get("singername", ""), lambda x: x.get("album_name", "")) if not best: return empty(source, "kugou: no match") candidates = request_json(query_url("https://lyrics.kugou.com/search", { "ver": 1, "man": "yes", "client": "pc", "keyword": keyword, "duration": best.get("duration", duration_ms(track.get("duration"))), "hash": best.get("hash", "") })).get("candidates", []) if not candidates: return empty(source, "kugou: lyrics unavailable") candidate = candidates[0] data = request_json(query_url("https://lyrics.kugou.com/download", { "ver": 1, "client": "pc", "id": candidate.get("id"), "accesskey": candidate.get("accesskey"), "fmt": "lrc", "charset": "utf8" })) content = data.get("content", "") try: content = base64.b64decode(content).decode("utf-8", "replace") except (ValueError, TypeError): pass return success(source, parse_lrc(content), ["kugou: match"], duration_ms(track.get("duration"))) def adapter_qishui(track, credentials, options): source = "qishui" template = clean_text(credentials.get("qishui_api_url")) if not template: return empty(source, "qishui: endpoint required") replacements = {key: urllib.parse.quote(str(track.get(key, "")), safe="") for key in ("title", "artist", "album")} try: url = template.format(**replacements) except (KeyError, ValueError): return empty(source, "qishui: invalid endpoint template") parsed = urllib.parse.urlsplit(url) if parsed.scheme not in ("http", "https") or not parsed.netloc: return empty(source, "qishui: invalid endpoint") headers = {} if credentials.get("qishui_token"): headers["Authorization"] = "Bearer " + str(credentials["qishui_token"]) body, charset = request_data(url, headers) lines = parse_payload(body.decode(charset, "replace")) return success(source, lines, ["qishui: response parsed"], duration_ms(track.get("duration"))) def spotify_token(credentials): token = clean_text(credentials.get("spotify_access_token")) if token: return token cookie = clean_text(credentials.get("spotify_sp_dc")) if not cookie: return "" data = request_json("https://open.spotify.com/get_access_token?reason=transport&productType=web_player", {"Cookie": "sp_dc=" + cookie, "Referer": "https://open.spotify.com/"}) return clean_text(data.get("accessToken")) def adapter_spotify(track, credentials, options): source = "spotify" token = spotify_token(credentials) if not token: return empty(source, "spotify: credentials required") headers = {"Authorization": "Bearer " + token} search = request_json(query_url("https://api.spotify.com/v1/search", { "q": " ".join(filter(None, (track.get("title"), track.get("artist")))), "type": "track", "limit": 10 }), headers) items = search.get("tracks", {}).get("items", []) best = best_match(items, track, lambda x: x.get("name", ""), lambda x: " ".join(a.get("name", "") for a in x.get("artists", [])), lambda x: x.get("album", {}).get("name", "")) if not best: return empty(source, "spotify: no match") data = request_json(query_url("https://spclient.wg.spotify.com/color-lyrics/v2/track/" + urllib.parse.quote(best["id"]), { "format": "json", "market": "from_token" }), headers) lines = parse_json_lines(data.get("lyrics", {}).get("lines", [])) alternatives = data.get("lyrics", {}).get("alternatives", []) if alternatives and isinstance(alternatives[0], dict): merge_timed(lines, parse_json_lines(alternatives[0].get("lines", [])), "translation") return success(source, lines, ["spotify: match"], duration_ms(track.get("duration"))) def adapter_apple_music(track, credentials, options): source = "apple_music" developer = clean_text(credentials.get("apple_developer_token")) if not developer: return empty(source, "apple_music: developer token required") storefront = clean_text(credentials.get("apple_storefront")) or "us" if not re.fullmatch(r"[A-Za-z0-9-]+", storefront): return empty(source, "apple_music: invalid storefront") headers = {"Authorization": "Bearer " + developer, "Origin": "https://music.apple.com"} if credentials.get("apple_user_token"): headers["Music-User-Token"] = str(credentials["apple_user_token"]) search = request_json(query_url("https://api.music.apple.com/v1/catalog/" + storefront + "/search", { "term": " ".join(filter(None, (track.get("title"), track.get("artist")))), "types": "songs", "limit": 10 }), headers) songs = search.get("results", {}).get("songs", {}).get("data", []) best = best_match(songs, track, lambda x: x.get("attributes", {}).get("name", ""), lambda x: x.get("attributes", {}).get("artistName", ""), lambda x: x.get("attributes", {}).get("albumName", "")) if not best: return empty(source, "apple_music: no match") body, charset = request_data( "https://amp-api.music.apple.com/v1/catalog/" + storefront + "/songs/" + urllib.parse.quote(str(best["id"])) + "/lyrics", headers, ) text = body.decode(charset, "replace") try: payload = json.loads(text) lyric_data = first_value(payload, ("ttml", "syllableLyrics", "lyrics", "content")) lines = parse_payload(lyric_data) if lyric_data is not None else parse_json_lines(payload) except ValueError: lines = parse_ttml(text) return success(source, lines, ["apple_music: match"], duration_ms(track.get("duration"))) def adapter_musixmatch(track, credentials, options): source = "musixmatch" token = clean_text(credentials.get("musixmatch_token")) if not token: return empty(source, "musixmatch: usertoken required") params = { "app_id": "web-desktop-app-v1.0", "usertoken": token, "q_track": track.get("title", ""), "q_artist": track.get("artist", ""), "q_album": track.get("album", ""), "subtitle_format": "lrc", "page_size": 5, } language = clean_text(options.get("translation_language")) if language: params["selected_language"] = language data = request_json(query_url("https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get", params), {"Origin": "https://www.musixmatch.com", "Referer": "https://www.musixmatch.com/"}) subtitle = first_value(data, ("subtitle_body",)) if not subtitle: return empty(source, "musixmatch: lyrics unavailable") lines = parse_lrc(subtitle) translated = first_value(data, ("translation_list", "translations")) if isinstance(translated, list): translated_lines = [] for item in translated: value = item.get("translation", item) if isinstance(item, dict) else item if isinstance(value, dict): text = value.get("description", value.get("translation", "")) time = value.get("time", value.get("matched_line", -1)) translated_lines.append(line(time, text=text)) merge_timed(lines, translated_lines, "translation") return success(source, lines, ["musixmatch: match"], duration_ms(track.get("duration"))) ADAPTERS = { "lrclib": adapter_lrclib, "netease": adapter_netease, "netease_public": adapter_netease, "qq": adapter_qqmusic, "qqmusic": adapter_qqmusic, "kugou": adapter_kugou, "qishui": adapter_qishui, "apple": adapter_apple_music, "apple_music": adapter_apple_music, "spotify": adapter_spotify, "musixmatch": adapter_musixmatch, } def main(): source = "" response = None if len(sys.argv) != 2: response = empty(source, "request: expected one file path") else: try: with open(sys.argv[1], "r", encoding="utf-8") as request_file: request = json.load(request_file) try: os.remove(sys.argv[1]) except OSError: pass except FileNotFoundError: response = empty(source, "request: file not found") except (OSError, UnicodeError, json.JSONDecodeError): response = empty(source, "request: unreadable or invalid JSON") else: try: if not isinstance(request, dict): response = empty(source, "request: invalid JSON object") else: source = clean_text(request.get("source")).lower() track = request.get("track") if isinstance(request.get("track"), dict) else {} credentials = request.get("credentials") if isinstance(request.get("credentials"), dict) else {} options = request.get("options") if isinstance(request.get("options"), dict) else {} adapter = ADAPTERS.get(source) if not adapter: response = empty(source, "request: unknown source") elif not clean_text(track.get("title")): response = empty(source, "request: track title required") else: response = adapter(track, credentials, options) except urllib.error.HTTPError as error: response = empty(source, "source: HTTP " + str(error.code)) except (urllib.error.URLError, TimeoutError): response = empty(source, "source: network failure") except (ValueError, ET.ParseError): response = empty(source, "source: invalid response") except Exception: response = empty(source, "source: unexpected failure") print(json.dumps(response or empty(source, "source: empty response"), ensure_ascii=False, separators=(",", ":"))) if __name__ == "__main__": main()