1056 lines
36 KiB
Luau
1056 lines
36 KiB
Luau
--!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
|