Files
community-plugins/anilist/service.luau
T
CleboostandGitHub 0162521282 feat: add AniList plugin (#70)
* feat: add AniList plugin

Browse and update anime/manga lists from the bar widget and library panel with OAuth login.

* feat(anilist): add French translations

* docs(anilist): align README with template and current features

* chore(anilist): bump version to 1.0.0

* chore(anilist): update plugin thumbnail

* docs(anilist): document network, cache, and filesystem behavior

* fix(anilist): comply with AniList ToS naming guidelines

Rename the plugin to AniList (UNOFFICIAL), update the thumbnail, and keep service-facing copy referring to AniList itself.
2026-07-23 17:55:08 -04:00

1037 lines
24 KiB
Luau

--!nonstrict
-- AniList (UNOFFICIAL) background service: OAuth token storage, GraphQL queries, and list mutations.
-- The panel and widget only talk through noctalia.state; they never hit the network.
local GRAPHQL_URL = "https://graphql.anilist.co"
local TOKEN_FILE = "token.json"
local OAUTH_CREDS_FILE = "oauth_credentials.json"
local OAUTH_RESULT_FILE = "oauth_result.json"
local accessToken = ""
local viewer = nil
local snapshot = {
revision = 0,
loading = false,
refreshing = false,
busy = false,
error = "",
viewer = nil,
anime = {},
manga = {},
}
local oauthRunning = false
local oauthCredPath = ""
local oauthResultPath = ""
local oauthStartedAt = 0
local function tr(key, subst)
return noctalia.tr("service." .. key, subst)
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function tokenPath()
local dir = noctalia.pluginDataDir()
if not dir then
return nil
end
return dir .. "/" .. TOKEN_FILE
end
local function trim(value)
return noctalia.string.trim(tostring(value or ""))
end
local function readStoredToken()
local path = tokenPath()
if not path then
return ""
end
local raw = noctalia.readFile(path)
if not raw or raw == "" then
return ""
end
local parsed = noctalia.json.decode(raw)
if type(parsed) == "table" and type(parsed.access_token) == "string" then
return trim(parsed.access_token)
end
return ""
end
local function writeStoredToken(token)
local path = tokenPath()
if not path then
return false
end
local payload = noctalia.json.encode({
access_token = token,
saved_at = os.time(),
})
return noctalia.writeFile(path, payload)
end
local function clearStoredToken()
local path = tokenPath()
if path then
noctalia.removeFile(path)
end
end
local function resolveToken()
local fromSetting = noctalia.getConfig("access_token")
local token = trim(fromSetting)
if token ~= "" then
return token
end
return readStoredToken()
end
local function publishSnapshot()
snapshot.revision += 1
noctalia.state.set("anilist_snapshot", snapshot)
end
local function setError(message)
snapshot.error = message or ""
snapshot.loading = false
snapshot.refreshing = false
publishSnapshot()
end
local function mediaTotal(media)
if type(media) ~= "table" then
return nil
end
if media.type == "MANGA" then
local chapters = tonumber(media.chapters)
if chapters and chapters > 0 then
return chapters
end
return nil
end
local episodes = tonumber(media.episodes)
if episodes and episodes > 0 then
return episodes
end
return nil
end
local function normalizeEntry(entry, mediaType)
local media = entry.media or {}
local title = media.title and media.title.userPreferred or ("#" .. tostring(entry.mediaId or media.id or "?"))
local cover = media.coverImage or {}
local nextAiring = media.nextAiringEpisode
return {
listEntryId = entry.id,
mediaId = entry.mediaId or media.id,
status = entry.status or "CURRENT",
progress = tonumber(entry.progress) or 0,
progressVolumes = tonumber(entry.progressVolumes) or 0,
score = entry.score,
total = mediaTotal(media),
volumes = tonumber(media.volumes),
title = title,
coverColor = cover.color or "#334155",
coverUrl = cover.extraLarge or cover.large or cover.medium or nil,
coverPath = nil,
mediaType = mediaType,
nextEpisode = nextAiring and nextAiring.episode or nil,
nextAiringAt = nextAiring and nextAiring.airingAt or nil,
updatedAt = tonumber(entry.updatedAt) or 0,
}
end
local function mergeEntries(target, entries, mediaType)
for _, entry in ipairs(entries) do
if type(entry) == "table" and entry.media then
local normalized = normalizeEntry(entry, mediaType)
local mediaId = normalized.mediaId
if mediaId then
local existing = target[mediaId]
if not existing or (normalized.updatedAt or 0) >= (existing.updatedAt or 0) then
target[mediaId] = normalized
end
end
end
end
end
local function flattenCollection(lists, mediaType)
local byMediaId = {}
if type(lists) ~= "table" then
return {}
end
for _, list in ipairs(lists) do
if type(list) == "table" and type(list.entries) == "table" then
mergeEntries(byMediaId, list.entries, mediaType)
end
end
local rows = {}
for _, row in pairs(byMediaId) do
table.insert(rows, row)
end
table.sort(rows, function(a, b)
return (a.title or ""):lower() < (b.title or ""):lower()
end)
return rows
end
local COVER_DIR_NAME = "covers"
local COVER_CACHE_VERSION = 2
local coverDownloadsInFlight = {}
local coverLoadingIds = {}
local lastCoverPriority = {}
local activeCoverDownloads = 0
local pendingCoverSnapshot = false
local MAX_BACKGROUND_COVERS = 64
local MAX_PRIORITY_COVERS = 48
local function coversDir()
local dataDir = noctalia.pluginDataDir()
if not dataDir then
return nil
end
return dataDir .. "/" .. COVER_DIR_NAME
end
local function coverCacheDir()
local dir = coversDir()
if not dir then
return nil
end
return dir .. "/v" .. tostring(COVER_CACHE_VERSION)
end
local function coverCachePath(mediaId)
local dir = coverCacheDir()
if not dir or not mediaId then
return nil
end
return dir .. "/" .. tostring(mediaId) .. ".jpg"
end
local function coverMetaPath(mediaId)
local path = coverCachePath(mediaId)
if not path then
return nil
end
return path .. ".meta.json"
end
local function isCoverCacheValid(mediaId, url)
local path = coverCachePath(mediaId)
local metaPath = coverMetaPath(mediaId)
if not path or not metaPath or not noctalia.fileExists(path) then
return false
end
local raw = noctalia.readFile(metaPath)
if not raw or raw == "" then
return false
end
local ok, parsed = pcall(noctalia.json.decode, raw)
if not ok or type(parsed) ~= "table" then
return false
end
return parsed.url == url and parsed.version == COVER_CACHE_VERSION
end
local function writeCoverMeta(mediaId, url)
local metaPath = coverMetaPath(mediaId)
if not metaPath then
return
end
noctalia.writeFile(metaPath, noctalia.json.encode({
url = url,
version = COVER_CACHE_VERSION,
}))
end
local function removeCoverCache(mediaId)
local path = coverCachePath(mediaId)
local metaPath = coverMetaPath(mediaId)
if path then
noctalia.removeFile(path)
end
if metaPath then
noctalia.removeFile(metaPath)
end
end
local function purgeLegacyCoverCache()
local dir = coversDir()
if not dir then
return
end
local entries = noctalia.listDir(dir) or {}
for _, name in ipairs(entries) do
local path = dir .. "/" .. name
if name:match("%.jpg$") or name:match("_xl%.jpg$") or name:match("%.meta%.json$") then
noctalia.removeFile(path)
elseif name:match("^v%d+$") and name ~= ("v" .. tostring(COVER_CACHE_VERSION)) then
local files = noctalia.listDir(path) or {}
for _, file in ipairs(files) do
noctalia.removeFile(path .. "/" .. file)
end
end
end
local cacheDir = coverCacheDir()
if cacheDir then
noctalia.mkdirAll(cacheDir)
end
end
local function attachCoverPaths(rows)
for _, row in ipairs(rows or {}) do
local url = row.coverUrl
if isCoverCacheValid(row.mediaId, url) then
row.coverPath = coverCachePath(row.mediaId)
else
row.coverPath = nil
if row.mediaId and url then
removeCoverCache(row.mediaId)
end
end
end
end
local function publishCoverLoadingState()
local ids = {}
for mediaId in pairs(coverLoadingIds) do
table.insert(ids, mediaId)
end
noctalia.state.set("anilist_cover_loading", ids)
end
local function setCoverLoading(mediaId, loading)
if not mediaId then
return
end
if loading then
coverLoadingIds[mediaId] = true
else
coverLoadingIds[mediaId] = nil
end
publishCoverLoadingState()
end
local function needsCoverDownload(entry)
if type(entry) ~= "table" then
return false
end
local mediaId = entry.mediaId
local url = entry.coverUrl
if not mediaId or type(url) ~= "string" or url == "" then
return false
end
return not isCoverCacheValid(mediaId, url)
end
local function requestCoverSnapshot()
pendingCoverSnapshot = true
end
local function flushCoverSnapshot()
if pendingCoverSnapshot then
pendingCoverSnapshot = false
publishSnapshot()
end
end
local function setEntryCoverPath(mediaId, path)
for _, list in ipairs({ snapshot.anime, snapshot.manga }) do
for _, entry in ipairs(list) do
if entry.mediaId == mediaId then
entry.coverPath = path
requestCoverSnapshot()
return
end
end
end
end
local function syncCoverDownloadInterval()
if activeCoverDownloads > 0 then
noctalia.setUpdateInterval(120)
else
noctalia.setUpdateInterval(30000)
flushCoverSnapshot()
end
end
local function findEntry(mediaId)
if not mediaId then
return nil
end
for _, list in ipairs({ snapshot.anime, snapshot.manga }) do
for _, entry in ipairs(list) do
if entry.mediaId == mediaId then
return entry
end
end
end
return nil
end
local function downloadCoverForEntry(row)
if type(row) ~= "table" then
return
end
local mediaId = row.mediaId
local url = row.coverUrl
if not mediaId or type(url) ~= "string" or url == "" then
return
end
local path = coverCachePath(mediaId)
if not path then
return
end
if isCoverCacheValid(mediaId, url) then
row.coverPath = path
return
end
removeCoverCache(mediaId)
if coverDownloadsInFlight[mediaId] then
return
end
local dir = coverCacheDir()
if dir then
noctalia.mkdirAll(dir)
end
coverDownloadsInFlight[mediaId] = true
setCoverLoading(mediaId, true)
activeCoverDownloads += 1
syncCoverDownloadInterval()
local started = noctalia.download(url, path, function(ok)
coverDownloadsInFlight[mediaId] = nil
setCoverLoading(mediaId, false)
activeCoverDownloads = math.max(0, activeCoverDownloads - 1)
if ok then
writeCoverMeta(mediaId, url)
setEntryCoverPath(mediaId, path)
end
syncCoverDownloadInterval()
end)
if not started then
coverDownloadsInFlight[mediaId] = nil
setCoverLoading(mediaId, false)
activeCoverDownloads = math.max(0, activeCoverDownloads - 1)
syncCoverDownloadInterval()
end
end
local function queueCoverDownloads(priorityMediaIds)
local prioritySet = {}
local priorityEntries = {}
local backgroundEntries = {}
local downloadQueue = {}
if type(priorityMediaIds) == "table" then
for _, id in ipairs(priorityMediaIds) do
local mediaId = tonumber(id)
if mediaId and not prioritySet[mediaId] then
prioritySet[mediaId] = true
if #priorityEntries < MAX_PRIORITY_COVERS then
local entry = findEntry(mediaId)
if entry then
table.insert(priorityEntries, entry)
end
end
end
end
end
local backgroundCount = 0
for _, list in ipairs({ snapshot.anime, snapshot.manga }) do
for _, entry in ipairs(list) do
if not prioritySet[entry.mediaId] then
backgroundCount += 1
if backgroundCount <= MAX_BACKGROUND_COVERS then
table.insert(backgroundEntries, entry)
end
end
end
end
for _, entry in ipairs(priorityEntries) do
table.insert(downloadQueue, entry)
end
for _, entry in ipairs(backgroundEntries) do
table.insert(downloadQueue, entry)
end
for _, entry in ipairs(downloadQueue) do
if needsCoverDownload(entry) and entry.mediaId then
coverLoadingIds[entry.mediaId] = true
end
end
publishCoverLoadingState()
for _, entry in ipairs(downloadQueue) do
downloadCoverForEntry(entry)
end
end
local LIST_QUERY = [[
query ($userId: Int, $type: MediaType) {
MediaListCollection(userId: $userId, type: $type, forceSingleCompletedList: true) {
lists {
name
isCustomList
status
entries {
id
mediaId
status
progress
progressVolumes
score(format: POINT_100)
updatedAt
media {
id
type
episodes
chapters
volumes
coverImage { color extraLarge large medium }
title { userPreferred }
nextAiringEpisode { airingAt episode }
}
}
}
}
}
]]
local VIEWER_QUERY = [[
query {
Viewer {
id
name
}
}
]]
local SAVE_PROGRESS_MUTATION = [[
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) {
id
progress
status
media {
id
episodes
chapters
title { userPreferred }
}
}
}
]]
local function decodeBody(body)
if type(body) ~= "string" or body == "" then
return nil, "empty response"
end
local ok, parsed = pcall(noctalia.json.decode, body)
if not ok or type(parsed) ~= "table" then
return nil, "invalid json"
end
if type(parsed.errors) == "table" and #parsed.errors > 0 then
local message = parsed.errors[1].message or tr("mutation_failed")
if message:lower():find("invalid token", 1, true) then
return nil, tr("invalid_token")
end
return nil, message
end
return parsed.data, nil
end
local function graphqlRequest(query, variables, callback)
if accessToken == "" then
callback(nil, tr("not_configured"))
return false
end
local body = noctalia.json.encode({
query = query,
variables = variables or {},
})
return noctalia.http({
url = GRAPHQL_URL,
method = "POST",
headers = {
"Content-Type: application/json",
"Accept: application/json",
"Authorization: Bearer " .. accessToken,
},
body = body,
}, function(response)
if not response then
callback(nil, tr("network_error"))
return
end
if response.status < 200 or response.status >= 300 then
callback(nil, tr("network_error"))
return
end
local data, err = decodeBody(response.body)
callback(data, err)
end)
end
local function fetchMediaType(mediaType, userId, callback)
graphqlRequest(LIST_QUERY, {
userId = userId,
type = mediaType,
}, function(data, err)
if not data then
callback(nil, err)
return
end
local collection = data.MediaListCollection
local lists = collection and collection.lists or {}
callback(flattenCollection(lists, mediaType), nil)
end)
end
local function loadLibrary()
if accessToken == "" then
snapshot.viewer = nil
snapshot.anime = {}
snapshot.manga = {}
snapshot.loading = false
snapshot.error = ""
publishSnapshot()
return
end
snapshot.loading = true
snapshot.error = ""
publishSnapshot()
graphqlRequest(VIEWER_QUERY, nil, function(data, err)
if not data or not data.Viewer then
setError(err or tr("invalid_token"))
return
end
viewer = data.Viewer
snapshot.viewer = viewer
local userId = viewer.id
local animeRows = nil
local mangaRows = nil
local fetchError = nil
local pending = 2
local function doneOne()
pending -= 1
if pending > 0 then
return
end
if fetchError then
setError(fetchError)
return
end
snapshot.anime = animeRows or {}
snapshot.manga = mangaRows or {}
purgeLegacyCoverCache()
attachCoverPaths(snapshot.anime)
attachCoverPaths(snapshot.manga)
queueCoverDownloads(lastCoverPriority)
snapshot.loading = false
snapshot.error = ""
publishSnapshot()
end
fetchMediaType("ANIME", userId, function(rows, typeErr)
if typeErr then
fetchError = typeErr
else
animeRows = rows
end
doneOne()
end)
fetchMediaType("MANGA", userId, function(rows, typeErr)
if typeErr then
fetchError = typeErr
else
mangaRows = rows
end
doneOne()
end)
end)
end
local function reloadMediaType(mediaType)
if accessToken == "" then
return
end
if mediaType ~= "ANIME" and mediaType ~= "MANGA" then
loadLibrary()
return
end
if not viewer or not viewer.id then
loadLibrary()
return
end
if snapshot.loading or snapshot.refreshing or snapshot.busy then
return
end
snapshot.refreshing = true
snapshot.error = ""
publishSnapshot()
fetchMediaType(mediaType, viewer.id, function(rows, err)
snapshot.refreshing = false
if err then
snapshot.error = err
publishSnapshot()
return
end
if mediaType == "ANIME" then
snapshot.anime = rows or {}
attachCoverPaths(snapshot.anime)
else
snapshot.manga = rows or {}
attachCoverPaths(snapshot.manga)
end
queueCoverDownloads(lastCoverPriority)
snapshot.error = ""
publishSnapshot()
end)
end
local function applyProgressDelta(mediaId, delta, forceStatus, forcedProgress)
if snapshot.busy then
return
end
local entry = findEntry(mediaId)
if not entry then
return
end
local total = entry.total
local nextProgress = forcedProgress
local nextStatus = forceStatus or entry.status
if nextProgress == nil then
nextProgress = math.max(0, (entry.progress or 0) + delta)
nextStatus = entry.status
if delta < 0 and entry.status == "COMPLETED" then
if not total or nextProgress < total then
nextStatus = "CURRENT"
end
end
if total and nextProgress >= total then
nextProgress = total
nextStatus = "COMPLETED"
elseif delta > 0 and entry.status == "PLANNING" then
nextStatus = "CURRENT"
end
end
snapshot.busy = true
publishSnapshot()
graphqlRequest(SAVE_PROGRESS_MUTATION, {
mediaId = mediaId,
progress = nextProgress,
status = nextStatus,
}, function(data, err)
snapshot.busy = false
if not data or not data.SaveMediaListEntry then
setError(err or tr("mutation_failed"))
return
end
local saved = data.SaveMediaListEntry
entry.progress = tonumber(saved.progress) or nextProgress
entry.status = saved.status or nextStatus
if saved.media then
entry.total = mediaTotal(saved.media)
end
publishSnapshot()
end)
end
local function clientCredentials()
local clientId = noctalia.getConfig("client_id")
local clientSecret = noctalia.getConfig("client_secret")
if type(clientId) ~= "string" then clientId = "" end
if type(clientSecret) ~= "string" then clientSecret = "" end
clientId = trim(clientId)
clientSecret = trim(clientSecret)
return clientId, clientSecret
end
local function setToken(token)
token = trim(token)
if token == "" then
accessToken = ""
clearStoredToken()
viewer = nil
snapshot.viewer = nil
snapshot.anime = {}
snapshot.manga = {}
snapshot.error = ""
snapshot.loading = false
publishSnapshot()
return
end
accessToken = token
writeStoredToken(token)
loadLibrary()
end
local function finishOAuth()
if oauthCredPath ~= "" then
noctalia.removeFile(oauthCredPath)
end
if oauthResultPath ~= "" then
noctalia.removeFile(oauthResultPath)
end
oauthRunning = false
oauthCredPath = ""
oauthResultPath = ""
oauthStartedAt = 0
noctalia.setUpdateInterval(30000)
end
local function handleOAuthPayload(parsed)
if type(parsed) ~= "table" or parsed.ok == nil then
return false
end
finishOAuth()
if parsed.ok == true and type(parsed.access_token) == "string" then
setToken(parsed.access_token)
return true
end
setError(tostring(parsed.error or tr("oauth_failed")))
return true
end
local function startOAuthLogin()
if oauthRunning then
setError(tr("oauth_busy"))
return
end
local clientId, clientSecret = clientCredentials()
if clientId == "" or clientSecret == "" then
setError(tr("not_configured"))
return
end
if not noctalia.commandExists("python3") then
setError(tr("oauth_unavailable"))
return
end
local pluginDir = noctalia.pluginDir()
if not pluginDir then
setError(tr("oauth_unavailable"))
return
end
local dataDir = noctalia.pluginDataDir()
if not dataDir then
setError(tr("oauth_unavailable"))
return
end
local credPath = dataDir .. "/" .. OAUTH_CREDS_FILE
local resultPath = dataDir .. "/" .. OAUTH_RESULT_FILE
noctalia.removeFile(resultPath)
local credOk = noctalia.writeFile(credPath, noctalia.json.encode({
client_id = clientId,
client_secret = clientSecret,
}))
if not credOk then
setError(tr("oauth_unavailable"))
return
end
local scriptPath = pluginDir .. "/scripts/oauth_login.py"
local command = "python3 "
.. shellQuote(scriptPath)
.. " "
.. shellQuote(credPath)
.. " "
.. shellQuote(resultPath)
oauthRunning = true
oauthCredPath = credPath
oauthResultPath = resultPath
oauthStartedAt = os.clock()
snapshot.loading = true
snapshot.error = ""
publishSnapshot()
noctalia.setUpdateInterval(500)
local function handleOAuthLine(line)
line = (line or ""):gsub("^%s+", ""):gsub("%s+$", "")
if line == "" then
return
end
local ok, parsed = pcall(noctalia.json.decode, line)
if ok then
handleOAuthPayload(parsed)
end
end
local started = noctalia.runStream(command, handleOAuthLine)
if not started then
finishOAuth()
setError(tr("oauth_unavailable"))
end
end
local function pollOAuthResult()
if not oauthRunning or oauthResultPath == "" then
return
end
local raw = noctalia.readFile(oauthResultPath)
if raw and raw ~= "" then
local ok, parsed = pcall(noctalia.json.decode, raw)
if ok and handleOAuthPayload(parsed) then
return
end
end
if oauthStartedAt > 0 and (os.clock() - oauthStartedAt) > 185 then
finishOAuth()
setError(tr("oauth_failed"))
end
end
function update()
pollOAuthResult()
if activeCoverDownloads > 0 then
flushCoverSnapshot()
end
end
local function loginWithInput(input)
input = trim(input)
if input == "" then
return
end
if input:sub(1, 3) == "eyJ" then
setToken(input)
return
end
setError(tr("oauth_failed"))
end
local function processCommand(command)
if type(command) ~= "table" then
return
end
local action = command.action
if action == "refresh" then
local mediaType = tostring(command.mediaType or "")
if mediaType == "ANIME" or mediaType == "MANGA" then
reloadMediaType(mediaType)
else
loadLibrary()
end
elseif action == "start_oauth" then
startOAuthLogin()
elseif action == "login" then
loginWithInput(tostring(command.token or ""))
elseif action == "logout" then
setToken("")
elseif action == "prioritize_covers" then
local ids = {}
if type(command.mediaIds) == "table" then
for _, id in ipairs(command.mediaIds) do
local mediaId = tonumber(id)
if mediaId then
table.insert(ids, mediaId)
end
end
end
lastCoverPriority = ids
queueCoverDownloads(lastCoverPriority)
elseif action == "increment" then
applyProgressDelta(tonumber(command.mediaId), 1, nil)
elseif action == "decrement" then
applyProgressDelta(tonumber(command.mediaId), -1, nil)
elseif action == "complete" then
local mediaId = tonumber(command.mediaId)
local entry = mediaId and findEntry(mediaId) or nil
if entry then
applyProgressDelta(mediaId, 0, "COMPLETED", entry.total or entry.progress or 0)
end
elseif action == "open_media" then
local mediaId = tonumber(command.mediaId)
local mediaType = tostring(command.mediaType or "ANIME"):lower()
if mediaId then
local segment = if mediaType == "manga" then "manga" else "anime"
noctalia.runAsync("xdg-open " .. shellQuote("https://anilist.co/" .. segment .. "/" .. mediaId) .. " >/dev/null 2>&1")
end
end
end
noctalia.state.watch("anilist_command", function(command)
processCommand(command)
end)
function onIpc(event, payload)
if event == "refresh" then
loadLibrary()
elseif event == "logout" then
setToken("")
elseif event == "login" and type(payload) == "string" then
loginWithInput(payload)
end
end
function init()
purgeLegacyCoverCache()
accessToken = resolveToken()
publishSnapshot()
if accessToken ~= "" then
loadLibrary()
end
end
init()