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