Files
community-plugins/lyrics/lyrics_service.luau
T

1056 lines
36 KiB
Luau
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--!nonstrict
-- Lyrics — headless service.
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
-- via /api endpoints, publishes state. No polling delay.
local updateIntervalMs = 100
noctalia.setUpdateInterval(updateIntervalMs)
local cache = {}
local coverCache = {}
local sourceCoverCache = {}
local candidateCache = {}
local selectedCandidateCache = {}
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)
local requestDir = cacheDir .. "/requests"
noctalia.mkdirAll(requestDir)
local krcTmp = cacheDir .. "/krc.tmp"
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
local lyricsSources = noctalia.getConfig("lyrics_sources") or {
"lrclib", "netease", "splayer", "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 = ""
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 = {}
for _, pattern in ipairs(value) do
pattern = tostring(pattern):lower():gsub("^%s+", ""):gsub("%s+$", "")
if pattern ~= "" then patterns[#patterns + 1] = pattern end
end
table.sort(patterns)
return patterns
end
local playerAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local function trackKey(track)
local durationSeconds = math.floor((tonumber(track.duration) or 0) / 1000000)
return (track.playerInstance or "") .. "|" .. (track.trackId or "") .. "|"
.. (track.title or "") .. "|" .. (track.artist or "") .. "|" .. (track.album or "")
.. "|" .. tostring(durationSeconds)
end
local function publishCandidates(tk, loading, selectionError, pendingCandidateId)
noctalia.state.set("lyrics_candidate_state", {
track_key = tk or "",
candidates = candidateCache[tk] or {},
selected_id = selectedCandidateCache[tk],
loading = loading == true,
error = selectionError,
pending_id = pendingCandidateId,
})
end
local function patternMatches(value, pattern)
local luaPattern = "^" .. pattern:gsub("([%%%^%$%(%)%.%[%]%+%-%?])", "%%%1"):gsub("%*", ".*") .. "$"
return value:lower():match(luaPattern) ~= nil
end
local function matchesAny(player, patterns)
for _, pattern in ipairs(patterns) do
if patternMatches(player.name, pattern) or patternMatches(player.instance, pattern) then return true end
end
return false
end
local function playerAllowed(player)
if #playerAllowlist > 0 and not matchesAny(player, playerAllowlist) then return false end
return not matchesAny(player, playerBlocklist)
end
local function selectPlayer(players)
local best = nil
local bestRank = -1
for _, player in ipairs(players) do
if playerAllowed(player) then
local rank = player.status == "playing" and 2 or (player.status == "paused" and 1 or 0)
if rank > bestRank or (rank == bestRank and player.instance == currentPlayerInstance) then
best = player
bestRank = rank
end
end
end
return best
end
local function clearPlayerState()
fetchGeneration = fetchGeneration + 1
coverFetchGeneration = coverFetchGeneration + 1
currentPlayerInstance = ""
currentTrack = nil
currentEmbeddedLyrics = ""
currentArtUrl = ""
currentCoverUrl = ""
pendingSourceCoverUrl = ""
mprisCoverFailed = false
lastTrackKey = ""
inFlight = nil
coverInFlight = nil
noctalia.state.set("player_instance", nil)
noctalia.state.set("player_name", nil)
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
publishCandidates("")
noctalia.state.set("cover", nil)
noctalia.state.set("playing", false)
end
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 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
local mins, secs = line:match("%[(%d+):(%d+%.?%d*)%]")
if mins and secs then
local ms = math.floor(tonumber(mins) * 60000 + tonumber(secs) * 1000)
local text = line:gsub("%[%d+:%d+%.?%d*%]", ""):gsub("^%s+", ""):gsub("%s+$", "")
if text ~= "" then
local isMeta = ms < 5000 and (text:find(":") or text:find(":"))
if not isMeta then
lines[#lines + 1] = { time = ms, text = text }
end
end
end
end
if #lines == 0 then return nil end
return lines
end
local function parsePlain(text)
local lines = {}
local seenLyric = false
for line in text:gmatch("[^\n]+") do
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
if trimmed ~= "" then
if seenLyric then
lines[#lines + 1] = { time = -1, text = trimmed }
elseif trimmed:find(":") or trimmed:find(":") then
-- skip metadata header line
else
seenLyric = true
lines[#lines + 1] = { time = -1, text = trimmed }
end
end
end
if #lines == 0 then return nil end
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 "",
}
elseif source == "splayer" then
return { splayer_api_url = noctalia.getConfig("splayer_api_url") or "http://127.0.0.1:25884" }
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
if #keys > 30 then
table.sort(keys)
for i = 1, #keys - 30 do
cache[keys[i]] = nil
sourceCoverCache[keys[i]] = nil
candidateCache[keys[i]] = nil
selectedCandidateCache[keys[i]] = nil
end
end
end
local function fetchLyricsNetEase(track, embeddedLyrics, requestedCandidateId)
local tk = trackKey(track)
requestedCandidateId = tostring(requestedCandidateId or "")
local manualSelection = requestedCandidateId ~= ""
fetchGeneration = fetchGeneration + 1
local flight = tk .. "|" .. tostring(fetchGeneration)
inFlight = flight
publishCandidates(tk, manualSelection, nil, manualSelection and requestedCandidateId or nil)
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 ~= 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
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local data = noctalia.json.decode(r1.body)
if not data or not data.result or not data.result.songs or #data.result.songs == 0 then
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local function songArtist(s)
if s.artists and #s.artists > 0 then
return (s.artists[1].name or ""):lower()
end
return ""
end
local bestMatch = nil
local trackArtist = track.artist:lower()
for _, s in ipairs(data.result.songs) do
if songArtist(s):find(trackArtist, 1, true) then
bestMatch = s
break
end
end
if not bestMatch then
bestMatch = data.result.songs[1]
end
local songId = tostring(bestMatch.id or "")
if not songId:match("^%d+$") then
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
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 ~= flight then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local lyrics = nil
if r2.ok and r2.status >= 200 and r2.status < 300 and r2.body and #r2.body > 0 then
local ldata = noctalia.json.decode(r2.body)
local klyricStr = ""
if ldata and ldata.klyric then
local k = ldata.klyric
if type(k) == "string" then
klyricStr = k
elseif type(k) == "table" then
klyricStr = (k.lyric and type(k.lyric) == "string") and k.lyric or ""
end
end
if klyricStr ~= "" then
local ok = pcall(noctalia.writeFile, krcTmp, klyricStr)
if not ok then klyricStr = "" end
end
if klyricStr ~= "" then
local py = "python3 " .. shellQuote(pluginDir .. "/krc_decode.py") .. " " .. shellQuote(krcTmp)
noctalia.runAsync(py, function(r3)
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
cache[tk] = parsed.lines
evictCache()
inFlight = nil
noctalia.state.set("lyrics", parsed.lines)
return
end
local lrc = (ldata.lrc and ldata.lrc.lyric) or ""
local lyr = parseLRC(lrc)
if not lyr then lyr = parsePlain(lrc) end
if lyr then
cache[tk] = lyr
evictCache()
inFlight = nil
noctalia.state.set("lyrics", lyr)
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end)
return
end
if ldata and ldata.lrc and ldata.lrc.lyric and ldata.lrc.lyric ~= "" then
lyrics = parseLRC(ldata.lrc.lyric)
if not lyrics then
lyrics = parsePlain(ldata.lrc.lyric)
end
end
end
if lyrics then
cache[tk] = lyrics
evictCache()
inFlight = nil
noctalia.state.set("lyrics", lyrics)
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end)
end)
end
local query = track.title .. "\n" .. track.artist .. "\n" .. (track.album or "")
local qTmp = cacheDir .. "/query.tmp"
noctalia.writeFile(qTmp, query)
local dir = noctalia.pluginDir() or "/tmp"
local function applyParsed(parsed)
if parsed and parsed.type == "krc" and parsed.lines then
cache[tk] = parsed.lines
evictCache()
noctalia.state.set("lyrics", parsed.lines)
return true
end
if parsed and parsed.type == "lrc" and parsed.lrc and parsed.lrc ~= "" then
local lyr = parseLRC(parsed.lrc)
if not lyr then lyr = parsePlain(parsed.lrc) end
if lyr then
cache[tk] = lyr
evictCache()
noctalia.state.set("lyrics", lyr)
return true
end
end
return false
end
local function runPy(script, cb)
local py = "python3 " .. shellQuote(dir .. "/" .. script) .. " " .. shellQuote(qTmp)
noctalia.runAsync(py, function(r)
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)
end)
end
local function applyText(text)
if not text or text == "" then return false end
local parsed = parseLRC(text)
if not parsed then parsed = parsePlain(text) end
if not parsed then return false end
cache[tk] = parsed
evictCache()
noctalia.state.set("lyrics", parsed)
return true
end
local function fetchCustom(fallback)
if customUrl == "" then
if fallback then fallback() else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local replacements = {
title = track.title,
artist = track.artist,
album = track.album,
duration = tostring(math.floor((track.duration or 0) / 1000000)),
}
local url = customUrl:gsub("{([%w_]+)}", function(key)
return noctalia.string.urlEncode(replacements[key] or "")
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
local decoded = noctalia.json.decode(text)
for part in customJsonField:gmatch("[^.]+") do
decoded = type(decoded) == "table" and decoded[part] or nil
end
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
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
if not manualSelection then
if lyricsSource == "external" then
inFlight = nil
return
elseif lyricsSource == "mpris" then
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
inFlight = nil
return
elseif lyricsSource == "custom" then
fetchCustom(nil)
return
end
end
local requestPath = requestDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json"
local sources = manualSelection and { "lrclib" } or normalizedSources()
local request = {
track = track,
options = {
translation_language = noctalia.getConfig("translation_language") or "zh-Hans",
lyrics_candidate_id = manualSelection and requestedCandidateId or nil,
},
}
local function trySource(index)
if inFlight ~= flight then return end
local source = sources[index]
if not source then
inFlight = nil
if manualSelection then
publishCandidates(tk, false, "selection_failed")
else
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
end
return
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 then
trySource(index + 1)
return
end
-- Secure the containing directory before any credentials are written.
local secured = noctalia.runAsync("chmod 700 " .. shellQuote(requestDir), function(chmodResult)
if inFlight ~= flight or tk ~= lastTrackKey then return end
if chmodResult.exitCode ~= 0 or not noctalia.writeFile(requestPath, encoded) then
trySource(index + 1)
return
end
local command = "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
if (parsed.source or source) == "lrclib" then
candidateCache[tk] = type(parsed.candidates) == "table" and parsed.candidates or {}
selectedCandidateCache[tk] = tostring(parsed.selected_candidate_id or "")
else
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
end
evictCache()
inFlight = nil
noctalia.state.set("lyrics", parsed.lines)
noctalia.state.set("lyrics_source_used", parsed.source or source)
publishCandidates(tk)
if type(parsed.cover) == "string" and parsed.cover ~= "" then
sourceCoverCache[tk] = parsed.cover
maybeApplyCover(track, parsed.cover)
end
else
trySource(index + 1)
end
end, 30000)
if not started then
noctalia.removeFile(requestPath)
trySource(index + 1)
end
end, 5000)
if not secured then
trySource(index + 1)
end
end
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 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 apply = function(path)
if coverInFlight ~= ck or generation ~= coverFetchGeneration then return end
coverInFlight = nil
if tk ~= lastTrackKey then return end
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 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 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 = url
local fallbackUrl = nil
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
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
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(finalizeCoverFile(tempDest, track, url))
elseif fallbackUrl then
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)
end
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
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{{mpris:trackid}}\x1f{{xesam:url}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]]
local started = noctalia.runAsync(cmd, function(r)
pollInFlight = false
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
clearPlayerState()
return
end
local players = {}
local fieldSeparator = string.char(31)
local recordSeparator = string.char(30)
for record in (r.stdout .. recordSeparator):gmatch("(.-)" .. recordSeparator) do
if record ~= "" then
local parts = {}
for field in (record .. fieldSeparator):gmatch("(.-)" .. fieldSeparator) do
parts[#parts + 1] = field:gsub("^%s+", ""):gsub("%s+$", "")
end
local player = {
instance = parts[1] or "",
name = parts[2] or "",
status = parts[3] or "stopped",
title = parts[4] or "",
artist = parts[5] or "",
album = parts[6] or "",
position = tonumber(parts[7]) or 0,
duration = tonumber(parts[8]) or 0,
artUrl = parts[9] or "",
trackId = parts[10] or "",
mediaUrl = parts[11] or "",
embeddedLyrics = parts[12] or "",
}
if player.instance ~= "" or player.name ~= "" then players[#players + 1] = player end
end
end
local selected = selectPlayer(players)
if not selected then
clearPlayerState()
return
end
currentPlayerInstance = selected.instance
noctalia.state.set("player_instance", selected.instance)
noctalia.state.set("player_name", selected.name)
if selected.title == "" and selected.artist == "" then
fetchGeneration = fetchGeneration + 1
inFlight = nil
currentTrack = nil
currentEmbeddedLyrics = ""
lastTrackKey = ""
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
publishCandidates("")
noctalia.state.set("playing", selected.status == "playing")
return
end
local playing = selected.status == "playing"
local t = {
title = selected.title,
artist = selected.artist,
album = selected.album,
status = selected.status,
position = selected.position,
duration = selected.duration,
playerInstance = selected.instance,
trackId = selected.trackId,
mediaUrl = selected.mediaUrl,
}
local tk = trackKey(t)
currentTrack = t
currentEmbeddedLyrics = selected.embeddedLyrics
local previousArtUrl = currentArtUrl
currentArtUrl = selected.artUrl
noctalia.state.set("position", t.position + lyricsOffsetMs * 1000)
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)
local cached = cache[tk]
if displayMode == "track" then
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
elseif cached then
noctalia.state.set("lyrics", cached)
noctalia.state.set("lyrics_source_used", "cache")
publishCandidates(tk)
else
noctalia.state.set("lyrics", nil)
publishCandidates(tk)
fetchLyricsNetEase(t, selected.embeddedLyrics)
end
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 and selected.artUrl ~= "" then
mprisCoverFailed = false
fetchCover(t, selected.artUrl)
end
end
end)
if not started then pollInFlight = false end
end
local pollElapsedMs = pollIntervalMs
function update()
if lyricsSource == "external" then return end
pollElapsedMs = pollElapsedMs + updateIntervalMs
if pollElapsedMs >= pollIntervalMs then
pollElapsedMs = 0
poll()
end
end
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("splayer_api_url") or "http://127.0.0.1:25884",
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 = {}
sourceCoverCache = {}
candidateCache = {}
selectedCandidateCache = {}
noctalia.state.set("lyrics", nil)
publishCandidates(trackKey(currentTrack))
if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end
end
end
local lastCandidateRequest = ""
noctalia.state.watch("lyrics_candidate_request", function(request)
if type(request) ~= "table" or not currentTrack then return end
local requestId = tostring(request.request_id or "")
if requestId == "" or requestId == lastCandidateRequest then return end
lastCandidateRequest = requestId
local candidateId = tostring(request.candidate_id or "")
if candidateId == "" then return end
local tk = trackKey(currentTrack)
if tostring(request.track_key or "") ~= tk then return end
local known = false
for _, candidate in ipairs(candidateCache[tk] or {}) do
if tostring(candidate.id or "") == candidateId then
known = true
break
end
end
if not known or candidateId == tostring(selectedCandidateCache[tk] or "") then return end
inFlight = nil
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics, candidateId)
end)
local function applyPushedLyrics(payload)
local decoded = noctalia.json.decode(payload or "")
if type(decoded) == "table" then
if decoded.track then
currentTrack = decoded.track
lastTrackKey = trackKey(decoded.track)
noctalia.state.set("track", decoded.track)
end
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end
if decoded.lyrics then
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 + lyricsOffsetMs * 1000) end
if decoded.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) 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
end
function onIpc(event, payload)
if event == "push-lrc" then
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
noctalia.state.set("lyrics", parseLRC(payload or "") or parsePlain(payload or ""))
elseif event == "push-json" or event == "push-state" then
applyPushedLyrics(payload)
elseif event == "clear" then
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
noctalia.state.set("lyrics", nil)
end
end
if lyricsSource ~= "external" then poll() end