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:
+5
-1
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
id = "h465855hgg/lyrics"
|
||||
name = "Lyrics"
|
||||
version = "1.4.1"
|
||||
version = "1.4.2"
|
||||
plugin_api = 3
|
||||
author = "h465855hgg"
|
||||
license = "MIT"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "歌词"
|
||||
}
|
||||
Reference in New Issue
Block a user