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.
This commit is contained in:
rrzt
2026-07-20 21:06:24 -04:00
committed by GitHub
parent 03b52eccaf
commit 23d574d88c
6 changed files with 623 additions and 55 deletions
+5 -1
View File
@@ -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
+107 -11
View File
@@ -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 = {
+215 -41
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
id = "h465855hgg/lyrics"
name = "Lyrics"
version = "1.4.1"
version = "1.4.2"
plugin_api = 3
author = "h465855hgg"
license = "MIT"
+1 -1
View File
@@ -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": {
+294
View File
@@ -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": "歌词"
}