feat(lyrics): expand sources and display controls

This commit is contained in:
h465855hgg
2026-07-18 18:29:33 +08:00
parent 1e23c02e0b
commit fc3d3447d0
10 changed files with 1682 additions and 230 deletions
+229 -47
View File
@@ -3,23 +3,38 @@
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
-- via /api endpoints, publishes state. No polling delay.
noctalia.setUpdateInterval(250)
local updateIntervalMs = 100
noctalia.setUpdateInterval(updateIntervalMs)
local cache = {}
local coverCache = {}
local lastTrackKey = ""
local inFlight = nil
local fetchGeneration = 0
local pollInFlight = false
local coverInFlight = nil
local pluginDir = noctalia.pluginDir() or "/tmp"
local cacheDir = pluginDir .. "/.cache"
noctalia.mkdirAll(cacheDir)
local krcTmp = cacheDir .. "/krc.tmp"
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
local lyricsSources = noctalia.getConfig("lyrics_sources") or {
"lrclib", "netease", "qqmusic", "kugou", "qishui", "apple_music", "spotify", "musixmatch"
}
local customUrl = noctalia.getConfig("custom_url") or ""
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local pollIntervalMs = tonumber(noctalia.getConfig("poll_interval_ms")) or 500
local lyricsOffsetMs = tonumber(noctalia.getConfig("lyrics_offset_ms")) or 0
local displayMode = noctalia.getConfig("display_mode") or "toggle"
local credentialSignature = ""
local currentTrack = nil
local currentEmbeddedLyrics = ""
local currentPlayerInstance = ""
local currentArtUrl = ""
for _, name in ipairs(noctalia.listDir(cacheDir) or {}) do
if name:match("^source_request_.*%.json$") then noctalia.removeFile(cacheDir .. "/" .. name) end
end
local function normalizePatterns(value)
if type(value) ~= "table" then return {} end
@@ -72,9 +87,11 @@ local function selectPlayer(players)
end
local function clearPlayerState()
fetchGeneration = fetchGeneration + 1
currentPlayerInstance = ""
currentTrack = nil
currentEmbeddedLyrics = ""
currentArtUrl = ""
lastTrackKey = ""
inFlight = nil
coverInFlight = nil
@@ -86,9 +103,14 @@ local function clearPlayerState()
noctalia.state.set("playing", false)
end
local function coverPathFor(track)
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_")
return cacheDir .. "/cover_" .. safe .. ".jpg"
local function stableHash(value)
local hash = 5381
for index = 1, #value do hash = (hash * 33 + value:byte(index)) % 4294967296 end
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)
@@ -134,6 +156,46 @@ local function parsePlain(text)
return lines
end
local function credentialsFor(source)
if source == "spotify" then
return {
spotify_sp_dc = noctalia.getConfig("spotify_sp_dc") or "",
spotify_access_token = noctalia.getConfig("spotify_access_token") or "",
}
elseif source == "apple_music" then
return {
apple_developer_token = noctalia.getConfig("apple_developer_token") or "",
apple_user_token = noctalia.getConfig("apple_user_token") or "",
apple_storefront = noctalia.getConfig("apple_storefront") or "us",
}
elseif source == "musixmatch" then
return { musixmatch_token = noctalia.getConfig("musixmatch_token") or "" }
elseif source == "qishui" then
return {
qishui_token = noctalia.getConfig("qishui_token") or "",
qishui_api_url = noctalia.getConfig("qishui_api_url") or "",
}
end
return {}
end
local function normalizedSources()
if lyricsSource ~= "auto" then return { lyricsSource } end
local result = {}
local seen = {}
if type(lyricsSources) == "table" then
for _, source in ipairs(lyricsSources) do
source = tostring(source):lower():gsub("^%s+", ""):gsub("%s+$", "")
if source ~= "" and not seen[source] then
result[#result + 1] = source
seen[source] = true
end
end
end
if #result == 0 then result = { "lrclib", "netease" } end
return result
end
local function evictCache()
local keys = {}
for k, _ in pairs(cache) do keys[#keys + 1] = k end
@@ -145,14 +207,15 @@ end
local function fetchLyricsNetEase(track, embeddedLyrics)
local tk = trackKey(track)
if inFlight == tk then return end
inFlight = tk
fetchGeneration = fetchGeneration + 1
local flight = tk .. "|" .. tostring(fetchGeneration)
inFlight = flight
local function tryFetch(query, fallback)
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
noctalia.http({ url = searchUrl, headers = { "Referer: https://music.163.com" } }, function(r1)
if inFlight ~= tk then return end
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then
@@ -196,7 +259,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. noctalia.string.urlEncode(songId) .. "&lv=1&kv=1&tv=-1"
noctalia.http({ url = lyricUrl, headers = { "Referer: https://music.163.com" } }, function(r2)
if inFlight ~= tk then return end
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local lyrics = nil
@@ -218,7 +281,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
if klyricStr ~= "" then
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
noctalia.runAsync(py, function(r3)
if inFlight ~= tk then return end
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
if ok and parsed and parsed.type == "krc" and parsed.lines then
@@ -296,7 +359,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local function runPy(script, cb)
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
noctalia.runAsync(py, function(r)
if inFlight ~= tk then return end
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
cb(ok and parsed or nil)
@@ -314,10 +377,9 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
return true
end
local function fetchCustom()
local function fetchCustom(fallback)
if customUrl == "" then
inFlight = nil
noctalia.state.set("lyrics", nil)
if fallback then fallback() else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
@@ -332,6 +394,7 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
end)
noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response)
if inFlight ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local text = response.ok and response.body or ""
if text ~= "" and customJsonField ~= "" then
@@ -339,17 +402,25 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
for part in customJsonField:gmatch("[^.]+") do
decoded = type(decoded) == "table" and decoded[part] or nil
end
if type(decoded) == "table" then
if type(decoded) == "table" then
cache[tk] = decoded
evictCache()
noctalia.state.set("lyrics", decoded)
noctalia.state.set("lyrics_source_used", "custom")
inFlight = nil
return
end
text = type(decoded) == "string" and decoded or ""
end
applyText(text)
inFlight = nil
if applyText(text) then
noctalia.state.set("lyrics_source_used", "custom")
inFlight = nil
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end)
end
@@ -357,48 +428,98 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
inFlight = nil
return
elseif lyricsSource == "mpris" then
applyText(embeddedLyrics)
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
inFlight = nil
return
elseif lyricsSource == "custom" then
fetchCustom()
return
elseif lyricsSource == "netease" then
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
fetchCustom(nil)
return
end
-- Auto: LRCLIB, then the public NetEase API.
runPy("lrclib_lyric.py", function(parsed)
if applyParsed(parsed) then inFlight = nil; return end
if lyricsSource == "lrclib" then
local requestPath = cacheDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json"
local sources = normalizedSources()
local request = {
track = track,
options = {
translation_language = noctalia.getConfig("translation_language") or "zh-Hans",
},
}
local function trySource(index)
if inFlight ~= flight then return end
local source = sources[index]
if not source then
inFlight = nil
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
return
end
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
end)
if source == "mpris" then
if applyText(embeddedLyrics) then
inFlight = nil
noctalia.state.set("lyrics_source_used", "mpris")
else
trySource(index + 1)
end
return
end
if source == "custom" then
fetchCustom(function() trySource(index + 1) end)
return
end
request.source = source
request.credentials = credentialsFor(source)
local encoded = noctalia.json.encode(request)
if not encoded or not noctalia.writeFile(requestPath, encoded) then
trySource(index + 1)
return
end
local command = "chmod 600 " .. shellQuote(requestPath) .. " && python3 "
.. shellQuote(pluginDir .. "/lyric_sources.py") .. " " .. shellQuote(requestPath)
local started = noctalia.runAsync(command, function(result)
noctalia.removeFile(requestPath)
if inFlight ~= flight or tk ~= lastTrackKey then return end
local parsed = noctalia.json.decode(result.stdout or "")
if type(parsed) == "table" and parsed.type == "lyrics" and type(parsed.lines) == "table" and #parsed.lines > 0 then
cache[tk] = parsed.lines
evictCache()
inFlight = nil
noctalia.state.set("lyrics", parsed.lines)
noctalia.state.set("lyrics_source_used", parsed.source or source)
else
trySource(index + 1)
end
end, 30000)
if not started then
noctalia.removeFile(requestPath)
trySource(index + 1)
end
end
trySource(1)
end
local function fetchCover(track, artUrl)
local tk = trackKey(track)
if coverInFlight == tk then return end
coverInFlight = tk
local ck = tk .. "|" .. (artUrl or "")
if coverInFlight == ck then return end
coverInFlight = ck
local dest = coverPathFor(track)
local dest = coverPathFor(track, artUrl)
local apply = function(path)
if coverInFlight ~= tk then return end
if coverInFlight ~= ck then return end
coverInFlight = nil
if tk ~= lastTrackKey then return end
coverCache[tk] = path
coverCache[ck] = path
noctalia.state.set("cover", path)
end
if noctalia.fileExists(dest) then
apply(dest)
return
end
if not artUrl or artUrl == "" then
noctalia.state.set("cover", nil)
coverInFlight = nil
@@ -417,15 +538,38 @@ local function fetchCover(track, artUrl)
return
end
noctalia.download(artUrl, dest, function(ok)
if ok then apply(dest) else apply(nil) end
local downloadUrl = artUrl
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 downloadUrl:find("[?&]param=%d+y%d+") then
downloadUrl = downloadUrl:gsub("param=%d+y%d+", "param=400y400")
else
downloadUrl = downloadUrl .. (downloadUrl:find("?", 1, true) and "&" or "?") .. "param=400y400"
end
fallbackUrl = downloadUrl:gsub("https://p1%.music%.126%.net", "https://p3.music.126.net")
end
noctalia.download(downloadUrl, dest, function(ok)
if ok then
apply(dest)
elseif fallbackUrl then
noctalia.download(fallbackUrl, dest, function(fallbackOk)
if fallbackOk then apply(dest) else apply(nil) end
end)
else
apply(nil)
end
end)
end
local function poll()
if pollInFlight then return end
pollInFlight = true
local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]]
noctalia.runAsync(cmd, function(r)
local started = noctalia.runAsync(cmd, function(r)
pollInFlight = false
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
clearPlayerState()
return
@@ -486,8 +630,10 @@ local function poll()
local tk = trackKey(t)
currentTrack = t
currentEmbeddedLyrics = selected.embeddedLyrics
local previousArtUrl = currentArtUrl
currentArtUrl = selected.artUrl
noctalia.state.set("position", t.position)
noctalia.state.set("position", t.position + lyricsOffsetMs * 1000)
if tk ~= lastTrackKey then
lastTrackKey = tk
@@ -495,14 +641,19 @@ local function poll()
noctalia.state.set("playing", playing)
local cached = cache[tk]
if cached then
if displayMode == "track" then
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
elseif cached then
noctalia.state.set("lyrics", cached)
noctalia.state.set("lyrics_source_used", "cache")
else
noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(t, selected.embeddedLyrics)
end
local cachedCover = coverCache[tk]
local coverKey = tk .. "|" .. selected.artUrl
local cachedCover = coverCache[coverKey]
if cachedCover then
noctalia.state.set("cover", cachedCover)
else
@@ -512,16 +663,22 @@ local function poll()
else
noctalia.state.set("track", t)
noctalia.state.set("playing", playing)
if selected.artUrl ~= previousArtUrl then
noctalia.state.set("cover", nil)
fetchCover(t, selected.artUrl)
end
end
end)
if not started then pollInFlight = false end
end
local pollTick = 0
local pollElapsedMs = pollIntervalMs
function update()
if lyricsSource == "external" then return end
pollTick = pollTick + 1
if pollTick % 2 == 0 then
pollElapsedMs = pollElapsedMs + updateIntervalMs
if pollElapsedMs >= pollIntervalMs then
pollElapsedMs = 0
poll()
end
@@ -531,26 +688,51 @@ function onConfigChanged()
local nextSource = noctalia.getConfig("lyrics_source") or "auto"
local nextUrl = noctalia.getConfig("custom_url") or ""
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local nextSources = noctalia.getConfig("lyrics_sources") or lyricsSources
local nextPollInterval = tonumber(noctalia.getConfig("poll_interval_ms")) or 500
local nextOffset = tonumber(noctalia.getConfig("lyrics_offset_ms")) or 0
local nextDisplayMode = noctalia.getConfig("display_mode") or "toggle"
local nextCredentialSignature = table.concat({
noctalia.getConfig("spotify_sp_dc") or "",
noctalia.getConfig("spotify_access_token") or "",
noctalia.getConfig("apple_developer_token") or "",
noctalia.getConfig("apple_user_token") or "",
noctalia.getConfig("apple_storefront") or "us",
noctalia.getConfig("musixmatch_token") or "",
noctalia.getConfig("qishui_token") or "",
noctalia.getConfig("qishui_api_url") or "",
noctalia.getConfig("translation_language") or "zh-Hans",
}, "\0")
local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField
or table.concat(nextSources, "\n") ~= table.concat(lyricsSources, "\n")
or nextCredentialSignature ~= credentialSignature
or nextDisplayMode ~= displayMode
local playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n")
or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n")
lyricsSource = nextSource
customUrl = nextUrl
customJsonField = nextField
lyricsSources = nextSources
pollIntervalMs = math.max(100, nextPollInterval)
lyricsOffsetMs = nextOffset
displayMode = nextDisplayMode
credentialSignature = nextCredentialSignature
playerAllowlist = nextAllowlist
playerBlocklist = nextBlocklist
if playersChanged then
currentPlayerInstance = ""
fetchGeneration = fetchGeneration + 1
inFlight = nil
poll()
end
if sourceChanged and currentTrack and not playersChanged then
fetchGeneration = fetchGeneration + 1
inFlight = nil
cache = {}
noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics)
if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end
end
end
@@ -572,7 +754,7 @@ local function applyPushedLyrics(payload)
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
noctalia.state.set("lyrics", parsed)
end
if decoded.position ~= nil then noctalia.state.set("position", decoded.position) 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
return true