From 23d574d88cf224db41ce0023ac3c9eaadd57a0db Mon Sep 17 00:00:00 2001 From: rrzt <124551348+h465855hgg@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:06:24 +0800 Subject: [PATCH] feat(lyrics): update to 1.4.2 (#65) * feat(lyrics): add Simplified Chinese translations Ship translations/zh-Hans.json so the plugin UI follows Noctalia's host language when set to Simplified Chinese. * fix(lyrics): restore splayer in lyrics_sources help text The i18n reorder dropped splayer from settings.lyrics_sources.description. * feat(lyrics): add album artwork fallbacks Use artwork returned by matched lyric sources when MPRIS artwork is missing or fails, preserve detected image formats, and bound the cover cache. --- lyrics/README.md | 6 +- lyrics/lyric_sources.py | 118 +++++++++++-- lyrics/lyrics_service.luau | 256 ++++++++++++++++++++++----- lyrics/plugin.toml | 2 +- lyrics/translations/en.json | 2 +- lyrics/translations/zh-Hans.json | 294 +++++++++++++++++++++++++++++++ 6 files changed, 623 insertions(+), 55 deletions(-) create mode 100644 lyrics/translations/zh-Hans.json diff --git a/lyrics/README.md b/lyrics/README.md index c9fe755..7ea3946 100644 --- a/lyrics/README.md +++ b/lyrics/README.md @@ -1,4 +1,4 @@ -# Noctalia Lyrics 1.4.1 +# Noctalia Lyrics 1.4.2 Synchronized lyrics for the Noctalia bar, with multiple MPRIS players, translation and romanization layers, configurable sources, karaoke highlighting, @@ -105,6 +105,10 @@ IDs are: Source APIs can change or reject requests by region or account. Failure of one source in automatic mode moves to the next source without logging credentials. +Album artwork uses MPRIS first, then the matched lyric source when available. +LRCLIB, Qishui, and Musixmatch matches use the public iTunes Search API as an +artwork fallback. Cached covers retain their detected image format and the +oldest files are removed after the cache reaches 80 covers. ## Credential warning diff --git a/lyrics/lyric_sources.py b/lyrics/lyric_sources.py index 76c96c7..cc98443 100644 --- a/lyrics/lyric_sources.py +++ b/lyrics/lyric_sources.py @@ -461,9 +461,66 @@ def best_match(items, track, title_key, artist_key, album_key=None): return best if best_score >= 3 else None -def success(source, lines, diag, total=0): +def first_cover(*values): + for value in values: + if isinstance(value, dict): + nested = first_cover( + value.get("url"), value.get("cover"), value.get("coverUrl"), value.get("picUrl"), + value.get("img"), value.get("image"), value.get("artwork"), value.get("albumArt"), + ) + if nested: + return nested + continue + if isinstance(value, list): + for item in value: + nested = first_cover(item) + if nested: + return nested + continue + text = clean_text(value) + if text.startswith("//"): + text = "https:" + text + if text.startswith("http://") or text.startswith("https://") or text.startswith("file://"): + return text + return "" + + +def itunes_cover(track): + term = " ".join(filter(None, (clean_text(track.get("title")), clean_text(track.get("artist"))))) + if not term: + return "" + try: + data = request_json(query_url("https://itunes.apple.com/search", { + "term": term, "media": "music", "entity": "song", "limit": 5, + })) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError): + return "" + results = data.get("results") if isinstance(data, dict) else None + if not isinstance(results, list): + return "" + best = best_match( + results, track, + lambda x: x.get("trackName", ""), + lambda x: x.get("artistName", ""), + lambda x: x.get("collectionName", ""), + ) + if not best: + return "" + url = clean_text(best.get("artworkUrl100") or best.get("artworkUrl60")) + if not url: + return "" + return re.sub(r"/\d+x\d+bb\.", "/400x400bb.", url) + + +def success(source, lines, diag, total=0, cover=""): lines = finalize(lines, total) - return {"type": "lyrics", "source": source, "lines": lines, "diag": diag} if lines else empty(source, *diag) + if not lines: + return empty(source, *diag) + payload = {"type": "lyrics", "source": source, "lines": lines, "diag": diag} + cover = clean_text(cover) + if cover: + payload["cover"] = cover + return payload def adapter_lrclib(track, credentials, options): @@ -477,7 +534,10 @@ def adapter_lrclib(track, credentials, options): 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"))) + return success( + source, parse_lrc(lyrics) or parse_plain(lyrics), ["lrclib: match"], + duration_ms(track.get("duration")), itunes_cover(track), + ) def adapter_netease(track, credentials, options): @@ -491,6 +551,13 @@ def adapter_netease(track, credentials, options): lambda x: x.get("album", {}).get("name", "")) if not best: return empty(source, "netease: no match") + album = best.get("album") if isinstance(best.get("album"), dict) else {} + cover = first_cover(album.get("picUrl"), album.get("blurPicUrl"), best.get("picUrl"), best.get("albumPic")) + if cover and "music.126.net" in cover: + if re.search(r"[?&]param=\d+y\d+", cover): + cover = re.sub(r"param=\d+y\d+", "param=400y400", cover) + else: + cover = cover + ("&" if "?" in cover else "?") + "param=400y400" 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/"}) @@ -505,7 +572,7 @@ def adapter_netease(track, credentials, options): 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"))) + return success(source, lines, ["netease: match"], duration_ms(track.get("duration")), cover) def adapter_qqmusic(track, credentials, options): @@ -519,6 +586,11 @@ def adapter_qqmusic(track, credentials, options): lambda x: x.get("albumname", "")) if not best: return empty(source, "qqmusic: no match") + albummid = clean_text(best.get("albummid") or best.get("albumMid")) + cover = "" + if albummid: + cover = "https://y.gtimg.cn/music/photo_new/T002R300x300M000" + albummid + ".jpg" + cover = first_cover(cover, best.get("albumPic"), best.get("pic"), best.get("strAlbumPic")) 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 @@ -534,7 +606,7 @@ def adapter_qqmusic(track, credentials, options): 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"))) + return success(source, lines, ["qqmusic: match"], duration_ms(track.get("duration")), cover) def adapter_splayer(track, credentials, options): @@ -570,7 +642,12 @@ def adapter_splayer(track, credentials, options): if title_matches and artist_matches: lines = splayer_transmitted_lines(current) if lines: - return success(source, lines, ["splayer: transmitted lyrics"], expected_duration) + cover = first_cover( + current.get("cover"), current.get("coverUrl"), current.get("picUrl"), + current.get("albumCover"), current.get("albumArt"), current.get("img"), + current.get("image"), current.get("al"), + ) + return success(source, lines, ["splayer: transmitted lyrics"], expected_duration, cover) last_state = "loading" if current.get("lyricLoading") is True else "empty" else: last_state = "track not ready" @@ -592,6 +669,9 @@ def adapter_kugou(track, credentials, options): lambda x: x.get("singername", ""), lambda x: x.get("album_name", "")) if not best: return empty(source, "kugou: no match") + cover = first_cover(best.get("album_sizable_cover"), best.get("imgUrl"), best.get("album_img"), best.get("cover")) + if cover: + cover = cover.replace("{size}", "400") 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", "") @@ -608,7 +688,7 @@ def adapter_kugou(track, credentials, options): 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"))) + return success(source, parse_lrc(content), ["kugou: match"], duration_ms(track.get("duration")), cover) def adapter_qishui(track, credentials, options): @@ -629,7 +709,10 @@ def adapter_qishui(track, credentials, options): 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"))) + return success( + source, lines, ["qishui: response parsed"], + duration_ms(track.get("duration")), itunes_cover(track), + ) def spotify_token(credentials): @@ -659,6 +742,9 @@ def adapter_spotify(track, credentials, options): lambda x: x.get("album", {}).get("name", "")) if not best: return empty(source, "spotify: no match") + album = best.get("album") if isinstance(best.get("album"), dict) else {} + images = album.get("images") if isinstance(album.get("images"), list) else [] + cover = first_cover(images, album.get("image"), best.get("image")) 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) @@ -666,7 +752,7 @@ def adapter_spotify(track, credentials, options): 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"))) + return success(source, lines, ["spotify: match"], duration_ms(track.get("duration")), cover) def adapter_apple_music(track, credentials, options): @@ -689,6 +775,13 @@ def adapter_apple_music(track, credentials, options): lambda x: x.get("attributes", {}).get("albumName", "")) if not best: return empty(source, "apple_music: no match") + attrs = best.get("attributes") if isinstance(best.get("attributes"), dict) else {} + artwork = attrs.get("artwork") if isinstance(attrs.get("artwork"), dict) else {} + cover = "" + template = clean_text(artwork.get("url")) + if template: + cover = template.replace("{w}", "400").replace("{h}", "400") + cover = first_cover(cover, attrs.get("artworkUrl"), attrs.get("url")) body, charset = request_data( "https://amp-api.music.apple.com/v1/catalog/" + storefront + "/songs/" + urllib.parse.quote(str(best["id"])) + "/lyrics", headers, @@ -700,7 +793,7 @@ def adapter_apple_music(track, credentials, options): 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"))) + return success(source, lines, ["apple_music: match"], duration_ms(track.get("duration")), cover) def adapter_musixmatch(track, credentials, options): @@ -732,7 +825,10 @@ def adapter_musixmatch(track, credentials, options): 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"))) + return success( + source, lines, ["musixmatch: match"], + duration_ms(track.get("duration")), itunes_cover(track), + ) ADAPTERS = { diff --git a/lyrics/lyrics_service.luau b/lyrics/lyrics_service.luau index 1474e85..30e2e07 100644 --- a/lyrics/lyrics_service.luau +++ b/lyrics/lyrics_service.luau @@ -8,11 +8,14 @@ noctalia.setUpdateInterval(updateIntervalMs) local cache = {} local coverCache = {} +local sourceCoverCache = {} local lastTrackKey = "" local inFlight = nil local fetchGeneration = 0 local pollInFlight = false local coverInFlight = nil +local coverFetchGeneration = 0 +local maxCoverFiles = 80 local pluginDir = noctalia.pluginDir() or "/tmp" local cacheDir = pluginDir .. "/.cache" noctalia.mkdirAll(cacheDir) @@ -33,11 +36,17 @@ local currentTrack = nil local currentEmbeddedLyrics = "" local currentPlayerInstance = "" local currentArtUrl = "" +local currentCoverUrl = "" +local pendingSourceCoverUrl = "" +local mprisCoverFailed = false +local maybeApplyCover for _, name in ipairs(noctalia.listDir(requestDir) or {}) do if name:match("^source_request_.*%.json$") then noctalia.removeFile(requestDir .. "/" .. name) end end +local pruneCoverCache + local function normalizePatterns(value) if type(value) ~= "table" then return {} end local patterns = {} @@ -91,10 +100,14 @@ end local function clearPlayerState() fetchGeneration = fetchGeneration + 1 + coverFetchGeneration = coverFetchGeneration + 1 currentPlayerInstance = "" currentTrack = nil currentEmbeddedLyrics = "" currentArtUrl = "" + currentCoverUrl = "" + pendingSourceCoverUrl = "" + mprisCoverFailed = false lastTrackKey = "" inFlight = nil coverInFlight = nil @@ -112,14 +125,98 @@ local function stableHash(value) return string.format("%08x", hash) end -local function coverPathFor(track, artUrl) - return cacheDir .. "/cover_" .. stableHash(trackKey(track) .. "|" .. (artUrl or "")) .. ".jpg" -end - local function shellQuote(value) return "'" .. tostring(value):gsub("'", "'\\''") .. "'" end +local function coverStem(track, artUrl) + return "cover_" .. stableHash(trackKey(track) .. "|" .. (artUrl or "")) +end + +local function coverExtensionFromUrl(artUrl) + local path = tostring(artUrl or ""):match("^[^?#]+") or "" + local ext = path:match("%.([A-Za-z0-9]+)$") + if not ext then return "" end + ext = ext:lower() + if ext == "jpeg" then return "jpg" end + if ext == "jpg" or ext == "png" or ext == "webp" or ext == "gif" or ext == "bmp" then return ext end + return "" +end + +local function coverExtensionFromFile(path) + local contents = noctalia.readFile(path) + local header = contents and contents:sub(1, 16) or "" + if header:sub(1, 3) == "\255\216\255" then return "jpg" end + if header:sub(1, 8) == "\137PNG\r\n\26\n" then return "png" end + if header:sub(1, 4) == "RIFF" and header:sub(9, 12) == "WEBP" then return "webp" end + if header:sub(1, 6) == "GIF87a" or header:sub(1, 6) == "GIF89a" then return "gif" end + if header:sub(1, 2) == "BM" then return "bmp" end + return "" +end + +local function findCachedCover(track, artUrl) + local stem = coverStem(track, artUrl) + local function correctedPath(path) + if not noctalia.fileExists(path) then return nil end + local actualExtension = coverExtensionFromFile(path) + local storedExtension = path:match("%.([^.]+)$") + if actualExtension == "" or actualExtension == storedExtension then return path end + local corrected = path:gsub("%.[^.]+$", "." .. actualExtension) + if noctalia.fileExists(corrected) then + noctalia.removeFile(path) + return corrected + end + local renamed = noctalia.renameFile(path, corrected) + return renamed and corrected or path + end + local preferred = coverExtensionFromUrl(artUrl) + if preferred ~= "" then + local path = cacheDir .. "/" .. stem .. "." .. preferred + local corrected = correctedPath(path) + if corrected then return corrected end + end + for _, ext in ipairs({ "jpg", "png", "webp", "gif", "bmp" }) do + local path = cacheDir .. "/" .. stem .. "." .. ext + local corrected = correctedPath(path) + if corrected then return corrected end + end + return nil +end + +local function coverPathFor(track, artUrl, ext) + local extension = ext or coverExtensionFromUrl(artUrl) + if extension == "" then extension = "jpg" end + return cacheDir .. "/" .. coverStem(track, artUrl) .. "." .. extension +end + +pruneCoverCache = function() + local names = noctalia.listDir(cacheDir) or {} + local files = {} + for _, name in ipairs(names) do + if name:match("^cover_.+%.%w+$") then + local path = cacheDir .. "/" .. name + local info = noctalia.fileInfo(path) + files[#files + 1] = { path = path, mtime = info and info.mtime or 0 } + end + end + if #files > maxCoverFiles then + table.sort(files, function(a, b) return a.mtime < b.mtime end) + for index = 1, #files - maxCoverFiles do + noctalia.removeFile(files[index].path) + files[index].removed = true + end + end + local keep = {} + for key, path in pairs(coverCache) do + if type(path) == "string" and noctalia.fileExists(path) then + keep[key] = path + end + end + coverCache = keep +end + +pruneCoverCache() + local function parseLRC(lrcText) local lines = {} for line in lrcText:gmatch("[^\n]+") do @@ -206,7 +303,10 @@ local function evictCache() for k, _ in pairs(cache) do keys[#keys + 1] = k end if #keys > 30 then table.sort(keys) - for i = 1, #keys - 30 do cache[keys[i]] = nil end + for i = 1, #keys - 30 do + cache[keys[i]] = nil + sourceCoverCache[keys[i]] = nil + end end end @@ -500,6 +600,10 @@ local function fetchLyricsNetEase(track, embeddedLyrics) inFlight = nil noctalia.state.set("lyrics", parsed.lines) noctalia.state.set("lyrics_source_used", parsed.source or source) + if type(parsed.cover) == "string" and parsed.cover ~= "" then + sourceCoverCache[tk] = parsed.cover + maybeApplyCover(track, parsed.cover) + end else trySource(index + 1) end @@ -517,49 +621,94 @@ local function fetchLyricsNetEase(track, embeddedLyrics) trySource(1) end +local function finalizeCoverFile(tempPath, track, artUrl) + if not tempPath or tempPath == "" or not noctalia.fileExists(tempPath) then return nil end + local ext = coverExtensionFromFile(tempPath) + if ext == "" then + noctalia.removeFile(tempPath) + return nil + end + local dest = coverPathFor(track, artUrl, ext) + if tempPath == dest then return dest end + noctalia.removeFile(dest) + local ok = noctalia.renameFile(tempPath, dest) + if ok or noctalia.fileExists(dest) then return dest end + if noctalia.fileExists(tempPath) then return tempPath end + return nil +end + local function fetchCover(track, artUrl) local tk = trackKey(track) - local ck = tk .. "|" .. (artUrl or "") + local url = tostring(artUrl or "") + if url == "" then return end + local ck = tk .. "|" .. url if coverInFlight == ck then return end + if coverCache[ck] and noctalia.fileExists(coverCache[ck]) then + if tk == lastTrackKey then + currentCoverUrl = url + noctalia.state.set("cover", coverCache[ck]) + end + return + end + local cached = findCachedCover(track, url) + if cached then + coverCache[ck] = cached + if tk == lastTrackKey then + currentCoverUrl = url + noctalia.state.set("cover", cached) + end + return + end + + coverFetchGeneration = coverFetchGeneration + 1 + local generation = coverFetchGeneration coverInFlight = ck - local dest = coverPathFor(track, artUrl) - local apply = function(path) - if coverInFlight ~= ck then return end + if coverInFlight ~= ck or generation ~= coverFetchGeneration then return end coverInFlight = nil if tk ~= lastTrackKey then return end - coverCache[ck] = path - noctalia.state.set("cover", path) + if path then + coverCache[ck] = path + currentCoverUrl = url + if url == currentArtUrl then + mprisCoverFailed = false + pendingSourceCoverUrl = "" + end + noctalia.state.set("cover", path) + pruneCoverCache() + elseif url == currentArtUrl then + mprisCoverFailed = true + local fallbackUrl = pendingSourceCoverUrl + pendingSourceCoverUrl = "" + if fallbackUrl ~= "" then fetchCover(track, fallbackUrl) end + end end - if noctalia.fileExists(dest) then - apply(dest) - return - end - - if not artUrl or artUrl == "" then - noctalia.state.set("cover", nil) - coverInFlight = nil - return - end - - if artUrl:sub(1, 7) == "file://" then - local source = noctalia.string.urlDecode(artUrl:sub(8)) + if url:sub(1, 7) == "file://" then + local source = noctalia.string.urlDecode(url:sub(8)) if not noctalia.fileExists(source) then apply(nil) return end + local ext = coverExtensionFromUrl(source) + if ext == "" then ext = coverExtensionFromFile(source) end + local dest = coverPathFor(track, url, ext ~= "" and ext or "img") noctalia.runAsync("cp -- " .. shellQuote(source) .. " " .. shellQuote(dest), function(result) - if result.exitCode == 0 then apply(dest) else apply(source) end + if generation ~= coverFetchGeneration then return end + if result.exitCode == 0 then + apply(finalizeCoverFile(dest, track, url) or dest) + else + apply(source) + end end) return end - local downloadUrl = artUrl + local downloadUrl = url local fallbackUrl = nil - if artUrl:find("music%.126%.net", 1) then - downloadUrl = artUrl:gsub("https?://p%d+%.music%.126%.net", "https://p1.music.126.net") + if url:find("music%.126%.net", 1) then + downloadUrl = url:gsub("https?://p%d+%.music%.126%.net", "https://p1.music.126.net") if downloadUrl:find("[?&]param=%d+y%d+") then downloadUrl = downloadUrl:gsub("param=%d+y%d+", "param=400y400") else @@ -568,12 +717,15 @@ local function fetchCover(track, artUrl) fallbackUrl = downloadUrl:gsub("https://p1%.music%.126%.net", "https://p3.music.126.net") end - noctalia.download(downloadUrl, dest, function(ok) + local tempDest = coverPathFor(track, url, coverExtensionFromUrl(url) ~= "" and coverExtensionFromUrl(url) or "img") + noctalia.download(downloadUrl, tempDest, function(ok) + if generation ~= coverFetchGeneration then return end if ok then - apply(dest) + apply(finalizeCoverFile(tempDest, track, url)) elseif fallbackUrl then - noctalia.download(fallbackUrl, dest, function(fallbackOk) - if fallbackOk then apply(dest) else apply(nil) end + noctalia.download(fallbackUrl, tempDest, function(fallbackOk) + if generation ~= coverFetchGeneration then return end + if fallbackOk then apply(finalizeCoverFile(tempDest, track, url)) else apply(nil) end end) else apply(nil) @@ -581,6 +733,16 @@ local function fetchCover(track, artUrl) end) end +maybeApplyCover = function(track, artUrl) + if not track or not artUrl or artUrl == "" then return end + if currentCoverUrl ~= "" and currentCoverUrl == artUrl then return end + if currentArtUrl ~= "" and not mprisCoverFailed then + pendingSourceCoverUrl = artUrl + return + end + fetchCover(track, artUrl) +end + local function poll() if pollInFlight then return end pollInFlight = true @@ -659,6 +821,11 @@ local function poll() if tk ~= lastTrackKey then lastTrackKey = tk + currentCoverUrl = "" + pendingSourceCoverUrl = "" + mprisCoverFailed = false + coverFetchGeneration = coverFetchGeneration + 1 + coverInFlight = nil noctalia.state.set("track", t) noctalia.state.set("playing", playing) @@ -674,19 +841,18 @@ local function poll() fetchLyricsNetEase(t, selected.embeddedLyrics) end - local coverKey = tk .. "|" .. selected.artUrl - local cachedCover = coverCache[coverKey] - if cachedCover then - noctalia.state.set("cover", cachedCover) - else - noctalia.state.set("cover", nil) + noctalia.state.set("cover", nil) + if selected.artUrl ~= "" then + pendingSourceCoverUrl = sourceCoverCache[tk] or "" fetchCover(t, selected.artUrl) + elseif sourceCoverCache[tk] then + maybeApplyCover(t, sourceCoverCache[tk]) end else noctalia.state.set("track", t) noctalia.state.set("playing", playing) - if selected.artUrl ~= previousArtUrl then - noctalia.state.set("cover", nil) + if selected.artUrl ~= previousArtUrl and selected.artUrl ~= "" then + mprisCoverFailed = false fetchCover(t, selected.artUrl) end end @@ -754,6 +920,7 @@ function onConfigChanged() fetchGeneration = fetchGeneration + 1 inFlight = nil cache = {} + sourceCoverCache = {} noctalia.state.set("lyrics", nil) if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end end @@ -780,7 +947,14 @@ local function applyPushedLyrics(payload) end if decoded.position ~= nil then noctalia.state.set("position", decoded.position + lyricsOffsetMs * 1000) end if decoded.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) end - if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end + if type(decoded.cover) == "string" and decoded.cover ~= "" then + if decoded.cover:sub(1, 1) == "/" or decoded.cover:sub(1, 7) == "file://" then + currentCoverUrl = decoded.cover + noctalia.state.set("cover", decoded.cover:gsub("^file://", "")) + else + maybeApplyCover(currentTrack, decoded.cover) + end + end return true end return false diff --git a/lyrics/plugin.toml b/lyrics/plugin.toml index 2af682d..1e46edf 100644 --- a/lyrics/plugin.toml +++ b/lyrics/plugin.toml @@ -1,6 +1,6 @@ id = "h465855hgg/lyrics" name = "Lyrics" -version = "1.4.1" +version = "1.4.2" plugin_api = 3 author = "h465855hgg" license = "MIT" diff --git a/lyrics/translations/en.json b/lyrics/translations/en.json index e39e240..b8a701a 100644 --- a/lyrics/translations/en.json +++ b/lyrics/translations/en.json @@ -172,7 +172,7 @@ } }, "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": { diff --git a/lyrics/translations/zh-Hans.json b/lyrics/translations/zh-Hans.json new file mode 100644 index 0000000..7ca3aa9 --- /dev/null +++ b/lyrics/translations/zh-Hans.json @@ -0,0 +1,294 @@ +{ + "instrumental": "间奏", + "intro": "前奏", + "no_lyrics": "暂无可用歌词", + "settings": { + "active_color": { + "description": "设置当前歌词和已经唱过字符的颜色,默认使用界面文字颜色。", + "label": "当前歌词颜色" + }, + "animation": { + "description": "歌词换行时使用的动画。", + "label": "歌词动画", + "options": { + "blink": "闪烁显现", + "cascade": "字符级联淡入", + "fade": "仅淡入淡出", + "karaoke": "逐字渐变和淡入淡出", + "none": "无动画", + "pulse": "脉冲淡入", + "typewriter": "打字机显现", + "wave": "字符波浪显现" + } + }, + "apple_developer_token": { + "description": "用于 Apple Music 曲库和歌词请求的 developer token。", + "label": "Apple Music 开发者令牌" + }, + "apple_storefront": { + "description": "例如 us、cn、jp 或 hk。", + "label": "Apple Music 商店区域" + }, + "apple_user_token": { + "description": "需要订阅授权的歌词可填写 Music-User-Token。", + "label": "Apple Music 用户令牌" + }, + "char_width": { + "description": "用于计算歌词显示窗口和滚动速度的近似字符宽度。", + "label": "字符宽度(像素)" + }, + "cover_radius": { + "description": "自定义封面形状使用的圆角半径。", + "label": "封面圆角" + }, + "cover_shape": { + "description": "专辑封面的形状。", + "label": "封面形状", + "options": { + "circle": "圆形", + "custom": "自定义圆角", + "rounded": "圆角方形", + "square": "方形" + } + }, + "cover_size": { + "description": "专辑封面的宽度和高度。", + "label": "封面尺寸" + }, + "cue_font_family": { + "description": "自定义字体模式下,仅用于前奏和间奏字符的已安装字体族名称。", + "label": "前奏/间奏字体" + }, + "cue_font_mode": { + "description": "选择前奏和间奏字符跟随 Noctalia 界面字体,或使用其他已安装字体。", + "label": "前奏/间奏字体模式", + "options": { + "custom": "自定义字体", + "follow": "跟随界面字体" + } + }, + "cue_text": { + "description": "前奏和间奏期间逐字点亮的文字或字符。", + "label": "前奏/间奏字符" + }, + "custom_json_field": { + "description": "JSON 响应中的歌词字段,支持点号路径,例如 data.lyric;留空则按纯 LRC 解析。", + "label": "JSON 歌词字段" + }, + "custom_url": { + "description": "支持 {title}、{artist}、{album} 和 {duration} 占位符,文本响应可直接返回 LRC。", + "label": "自定义接口 URL" + }, + "display_mode": { + "description": "固定显示歌词、固定显示歌曲信息,或允许左键切换。", + "label": "显示模式", + "options": { + "lyrics": "仅歌词", + "toggle": "点击切换", + "track": "仅歌曲名和歌手" + } + }, + "double_line": { + "description": "在原文歌词下方显示翻译或罗马音。", + "label": "双行歌词" + }, + "double_line_auto_fit": { + "description": "在水平状态栏中缩小主副歌词字号和间距,避免胶囊外框裁剪第二行;垂直状态栏不受影响。", + "label": "双行紧凑模式" + }, + "double_line_height_budget": { + "description": "水平状态栏可用于两行文字的近似总高度;数值越小,两行越紧凑。", + "label": "双行高度预算" + }, + "font_family": { + "description": "用于所有歌词的已安装字体族;留空跟随 Noctalia。", + "label": "歌词字体" + }, + "font_style": { + "description": "Noctalia 渲染器支持的文字基线样式。", + "label": "字体布局样式", + "options": { + "fixed": "固定文字高度", + "ink_centered": "字形墨迹居中", + "normal": "普通" + } + }, + "font_weight": { + "description": "主歌词使用的字体粗细。", + "label": "歌词粗细", + "options": { + "bold": "粗体", + "heavy": "特粗", + "light": "细体", + "medium": "中等", + "normal": "常规", + "semibold": "半粗", + "thin": "极细" + } + }, + "glyph": { + "label": "图标" + }, + "gradient": { + "description": "根据播放进度逐字点亮当前歌词。", + "label": "逐字渐变" + }, + "hide_when_paused": { + "label": "暂停时隐藏" + }, + "inactive_color": { + "description": "设置未唱歌词、暂停状态和次要歌词行的颜色。", + "label": "未唱歌词颜色" + }, + "karaoke_enabled": { + "description": "使用词源逐字时间或模拟行进度进行逐字高亮。", + "label": "卡拉 OK 逐字高亮" + }, + "line_gap": { + "description": "原文、翻译、罗马音和上下文歌词之间的间距。", + "label": "歌词行间距" + }, + "lyrics_offset_ms": { + "description": "正值让歌词更早显示,负值让歌词更晚显示。", + "label": "歌词延迟(毫秒)" + }, + "lyrics_source": { + "description": "选择预设词源、本地播放器歌词、自定义接口或外部推送协议。", + "label": "歌词来源", + "options": { + "apple_music": "Apple Music", + "auto": "自动回退", + "custom": "自定义 HTTP 接口", + "external": "外部 IPC 推送", + "kugou": "酷狗音乐", + "lrclib": "LRCLIB", + "mpris": "本地播放器(MPRIS)", + "musixmatch": "Musixmatch", + "netease": "网易云音乐", + "qishui": "汽水音乐", + "qqmusic": "QQ 音乐", + "splayer": "SPlayer", + "spotify": "Spotify" + } + }, + "lyrics_sources": { + "description": "按从上到下的顺序尝试词源,可填写 lrclib、netease、splayer、qqmusic、kugou、qishui、apple_music、spotify、musixmatch、mpris 或 custom。", + "label": "自动词源顺序" + }, + "marquee_speed": { + "description": "长歌词每秒滚动的像素数。", + "label": "滚动速度" + }, + "max_chars": { + "description": "长歌词开始滚动前显示的最大字符数。", + "label": "最大字符数" + }, + "max_lines": { + "description": "垂直状态栏中同时显示的歌词行数。", + "label": "最大行数" + }, + "musixmatch_token": { + "description": "用于 Musixmatch 桌面字幕接口的 usertoken。", + "label": "Musixmatch 用户令牌" + }, + "padding_left": { + "description": "组件内容左侧的空白。", + "label": "左边距" + }, + "padding_right": { + "description": "组件内容右侧的空白。", + "label": "右边距" + }, + "player_allowlist": { + "description": "仅使用匹配的 MPRIS 播放器。留空表示允许全部;支持 * 通配符,可匹配播放器名称或实例名。", + "label": "播放器白名单" + }, + "player_blocklist": { + "description": "应用白名单后忽略匹配的 MPRIS 播放器。支持 * 通配符,例如 chromium* 或 firefox*。", + "label": "播放器黑名单" + }, + "poll_interval_ms": { + "description": "服务刷新 MPRIS 元数据和播放进度的间隔。", + "label": "播放器轮询频率(毫秒)" + }, + "primary_font_size": { + "description": "原文歌词行的字号,0 表示跟随 Noctalia。", + "label": "主歌词字号" + }, + "qishui_api_url": { + "description": "支持 {title}、{artist}、{album} 的接口模板;汽水公开接口不稳定,因此需要自行填写。", + "label": "汽水音乐接口 URL" + }, + "qishui_token": { + "description": "仅发送给自定义汽水接口的可选 Bearer token。", + "label": "汽水音乐令牌" + }, + "scroll_mode": { + "description": "设置长歌词在状态栏中的滚动方式。", + "label": "滚动模式", + "options": { + "auto": "自动滚动", + "marquee": "始终滚动", + "static": "静态截断" + } + }, + "secondary_color": { + "description": "翻译和罗马音歌词使用的颜色。", + "label": "第二行歌词颜色" + }, + "secondary_font_size": { + "description": "翻译和罗马音使用的字号。", + "label": "第二行字号" + }, + "secondary_line_mode": { + "description": "选择附加歌词内容的优先顺序。", + "label": "第二行内容", + "options": { + "romanization": "仅罗马音", + "romanization_first": "罗马音优先,其次翻译", + "translation": "仅翻译", + "translation_first": "翻译优先,其次罗马音" + } + }, + "show_artist": { + "label": "显示歌手" + }, + "show_cover": { + "description": "在歌词旁显示当前歌曲的专辑封面。", + "label": "显示专辑封面" + }, + "show_romanization": { + "description": "允许罗马音或音译出现在第二行。", + "label": "显示罗马音" + }, + "show_translation": { + "description": "允许翻译歌词出现在第二行。", + "label": "显示翻译" + }, + "splayer_api_url": { + "description": "SPlayer 本地服务地址,默认 http://127.0.0.1:25884。直接读取 SPlayer 当前加载的完整歌词数据。", + "label": "SPlayer 接口地址" + }, + "spotify_access_token": { + "description": "可选的 Spotify access token,优先于 sp_dc。", + "label": "Spotify 访问令牌" + }, + "spotify_sp_dc": { + "description": "用户手动填写的 Spotify 网页 Cookie。它会以普通插件设置保存,但不会写入日志。", + "label": "Spotify sp_dc" + }, + "translation_language": { + "description": "词源支持时优先请求的翻译语言。", + "label": "歌词翻译语言", + "options": { + "en": "英语", + "ja": "日语", + "ko": "韩语", + "zh-hans": "简体中文", + "zh-hant": "繁体中文" + } + } + }, + "source_label": "歌词来源", + "title": "歌词" +}