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:
+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
|
||||
|
||||
Reference in New Issue
Block a user