From 0162521282828e0f6313b76bbf45ac919505f251 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Thu, 23 Jul 2026 23:55:08 +0200 Subject: [PATCH] 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. --- anilist/README.md | 80 +++ anilist/panel.luau | 725 ++++++++++++++++++++++ anilist/plugin.toml | 67 +++ anilist/scripts/oauth_login.py | 245 ++++++++ anilist/service.luau | 1036 ++++++++++++++++++++++++++++++++ anilist/thumbnail.webp | Bin 0 -> 48314 bytes anilist/translations/en.json | 93 +++ anilist/translations/fr.json | 93 +++ anilist/widget.luau | 126 ++++ 9 files changed, 2465 insertions(+) create mode 100644 anilist/README.md create mode 100644 anilist/panel.luau create mode 100644 anilist/plugin.toml create mode 100755 anilist/scripts/oauth_login.py create mode 100644 anilist/service.luau create mode 100644 anilist/thumbnail.webp create mode 100644 anilist/translations/en.json create mode 100644 anilist/translations/fr.json create mode 100644 anilist/widget.luau diff --git a/anilist/README.md b/anilist/README.md new file mode 100644 index 0000000..a759ffe --- /dev/null +++ b/anilist/README.md @@ -0,0 +1,80 @@ +# AniList (UNOFFICIAL) + +Unofficial Noctalia plugin to browse and update your AniList anime and manga lists from the bar. Open the panel to see what you are watching, planning, or have finished, then increment or decrement episode/chapter progress without opening the website. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `cleboost/anilist` | +| Entries | Bar widget: `tracker`; panel: `library`; service: `api` | +| Launcher Prefix | — | + +## Requirements + +1. Create an AniList API application at [anilist.co/settings/developer](https://anilist.co/settings/developer). +2. Set the application **Redirect URL** to `http://127.0.0.1:7823/callback`. +3. Copy the **Client ID** and **Client secret** into the plugin settings. +4. `python3` must be available on `PATH` (used for the temporary localhost login helper). +5. `xdg-open` must be available on `PATH` (used to open AniList media pages from the panel). +6. `zenity` or `kdialog` is required only if you want to download cover images from the panel preview. + +Network access: GraphQL and OAuth on `anilist.co`, plus cover image URLs returned by the API (typically AniList CDN hosts such as `s4.anilist.co`). + +## Usage + +Add the **AniList (UNOFFICIAL)** bar widget (`tracker`) from Settings, then click it to open the library panel. + +```sh +noctalia msg panel-toggle cleboost/anilist:library +``` + +First launch: + +1. Open plugin settings and paste your AniList **client ID** and **client secret**. +2. Open the panel and click **Connect with AniList**. +3. Approve the app in your browser. +4. When the browser shows “Connected to AniList”, return to Noctalia — your lists load automatically. + +Inside the panel: + +- Switch between **Anime** and **Manga** tabs. +- Filter by list status. Anime uses **Watching**, **Planning**, and so on; manga uses **Reading** instead of Watching and **Plan to Read** instead of Planning. +- The **Watching** / **Reading** filter sorts entries by progress (highest first). Other filters sort A–Z. +- Use **Reload list** to refresh the active tab without logging in again. +- Use the settings button to open **Settings → Plugins**. +- Click a cover image to open a larger preview. From there you can download the cover or close the preview. +- Use **−** / **+** to go back or forward one episode/chapter. +- Use the check button to mark an entry completed. +- Use the external-link button to open the entry on AniList. + +Right-click the bar widget to open the AniList website. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `client_id` | `string` | `""` | Your AniList OAuth client ID (required for login). | +| `client_secret` | `string` | `""` | Your AniList OAuth client secret (required for login). | +| `access_token` | `string` | `""` | Optional bearer token. If empty, the token saved after browser login is used. | +| `glyph` | `glyph` | `device-tv` | Bar widget icon. | +| `count_mode` | `select` | `current` | Which count to show in the bar widget: `current` (anime in progress), `completed`, `total`, `in_progress` (anime + manga), or `planning`. | + +## IPC + +```sh +noctalia msg plugin cleboost/anilist:api all refresh +noctalia msg plugin cleboost/anilist:api all logout +noctalia msg plugin cleboost/anilist:api all login "" +``` + +## Notes + +- This is an unofficial third-party plugin and is not affiliated with or endorsed by AniList. +- Login starts a temporary localhost server on port `7823` only for the duration of the OAuth flow. The helper also opens your default browser for authorization. +- OAuth login briefly writes temporary credential/result files in the plugin data directory; they are removed when login finishes. +- Access tokens are stored in the plugin data directory (`token.json`) after a successful login. +- Cover images are cached under the plugin data directory (`covers/v2/`). +- Downloading a cover from the preview writes the image to a path you choose (for example `~/Downloads/`). +- AniList tokens last about one year; connect again when they expire. +- Incrementing progress on a **Planning** / **Plan to Read** entry moves it to **Watching** / **Reading**. Reaching the last episode/chapter marks it **Completed**. diff --git a/anilist/panel.luau b/anilist/panel.luau new file mode 100644 index 0000000..aeb1778 --- /dev/null +++ b/anilist/panel.luau @@ -0,0 +1,725 @@ +--!nonstrict +-- AniList (UNOFFICIAL) library panel: login, filters, scrollable list, episode/chapter controls. + +local COVER_WIDTH = 44 +local COVER_HEIGHT = 62 +local PREVIEW_WIDTH = 280 +local PREVIEW_HEIGHT = math.floor(PREVIEW_WIDTH * COVER_HEIGHT / COVER_WIDTH) +local INITIAL_VISIBLE_ROWS = 12 +local ROW_BATCH_SIZE = 10 +local MAX_VISIBLE_ROWS = 200 +local LIST_LOAD_INTERVAL_MS = 80 +local LIST_IDLE_INTERVAL_MS = 1000 +local lastCoverPriorityKey = "" +local visibleRowLimit = INITIAL_VISIBLE_ROWS + +local snapshot = noctalia.state.get("anilist_snapshot") or { + revision = 0, + loading = false, + refreshing = false, + busy = false, + error = "", + viewer = nil, + anime = {}, + manga = {}, +} + +local mediaTab = "ANIME" +local statusFilter = "CURRENT" +local dirty = true +local render +local coverPreview = nil +local coverDownloadBusy = false + +local function coverLoadingSet() + local set = {} + for _, id in ipairs(noctalia.state.get("anilist_cover_loading") or {}) do + local mediaId = tonumber(id) + if mediaId then + set[mediaId] = true + end + end + return set +end + +local env = getfenv() + +local function tr(key, subst) + return noctalia.tr("panel." .. key, subst) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function sendCommand(action, values) + local command = { action = action } + if type(values) == "table" then + for k, v in pairs(values) do + command[k] = v + end + end + noctalia.state.set("anilist_command", command) +end + +local function rowCallback(prefix, mediaId, fn) + local name = prefix .. "_" .. tostring(mediaId) + env[name] = fn + return name +end + +local function resetListView() + visibleRowLimit = INITIAL_VISIBLE_ROWS + lastCoverPriorityKey = "" +end + +local function listRowCap(rows) + return math.min(#rows, MAX_VISIBLE_ROWS) +end + +local function syncListLoadInterval(rows) + if visibleRowLimit < listRowCap(rows) then + noctalia.setUpdateInterval(LIST_LOAD_INTERVAL_MS) + else + noctalia.setUpdateInterval(LIST_IDLE_INTERVAL_MS) + end +end + +local function growVisibleRows(rows) + local cap = listRowCap(rows) + if visibleRowLimit >= cap then + return false + end + visibleRowLimit = math.min(visibleRowLimit + ROW_BATCH_SIZE, cap) + return true +end + +local function titleCompare(a, b) + return (a.title or ""):lower() < (b.title or ""):lower() +end + +local function sortEntries(entries, filter) + local sorted = {} + for _, entry in ipairs(entries) do + table.insert(sorted, entry) + end + + if filter == "CURRENT" then + table.sort(sorted, function(a, b) + local progressA = a.progress or 0 + local progressB = b.progress or 0 + if progressA ~= progressB then + return progressA > progressB + end + return titleCompare(a, b) + end) + else + table.sort(sorted, titleCompare) + end + + return sorted +end + +local function entriesForView() + local source = if mediaTab == "MANGA" then snapshot.manga else snapshot.anime + if statusFilter == "ALL" then + return sortEntries(source, statusFilter) + end + + local filtered = {} + for _, entry in ipairs(source) do + if entry.status == statusFilter then + table.insert(filtered, entry) + end + end + return sortEntries(filtered, statusFilter) +end + +local function progressLabel(entry) + local current = entry.progress or 0 + local total = entry.total + if entry.mediaType == "MANGA" then + if total then + return tr("progress_manga", { current = current, total = total }) + end + return tr("progress_manga_unknown", { current = current }) + end + if total then + return tr("progress_anime", { current = current, total = total }) + end + return tr("progress_anime_unknown", { current = current }) +end + +local STAR_GLYPH_SIZE = 15 + +local function renderScoreStars(score) + local value = tonumber(score) + if not value or value <= 0 then + return nil + end + local filled = math.max(1, math.min(5, math.floor(value / 20 + 0.5))) + local stars = {} + for index = 1, 5 do + table.insert(stars, ui.glyph({ + name = if index <= filled then "star-filled" else "star", + size = STAR_GLYPH_SIZE, + color = "on_surface_variant", + })) + end + return ui.row({ gap = 1, align = "center" }, stars) +end + +local function subtitleTextFor(entry) + local parts = { progressLabel(entry) } + if entry.nextEpisode and entry.mediaType == "ANIME" then + table.insert(parts, tr("next_episode", { episode = entry.nextEpisode })) + end + return table.concat(parts, " · ") +end + +local function renderTitle(entry) + return ui.label({ + text = entry.title or "?", + fontWeight = "medium", + maxLines = 2, + }) +end + +local function renderSubtitle(entry) + return ui.label({ + text = subtitleTextFor(entry), + fontSize = 12, + color = "on_surface_variant", + maxLines = 2, + }) +end + +local function syncVisibleCoverPriority(rows) + local ids = {} + local cap = math.min(listRowCap(rows), visibleRowLimit) + for index = 1, cap do + local entry = rows[index] + if entry and entry.mediaId then + table.insert(ids, entry.mediaId) + end + end + + local key = table.concat(ids, ",") + if key == lastCoverPriorityKey then + return + end + lastCoverPriorityKey = key + sendCommand("prioritize_covers", { mediaIds = ids }) +end + +local function filterButton(key, label, filter) + local active = statusFilter == filter + return ui.button({ + key = "filter-" .. key, + text = label, + variant = if active then "primary" else "ghost", + onClick = rowCallback("onFilter", key, function() + statusFilter = filter + resetListView() + dirty = true + render() + end), + }) +end + +local function renderLogin() + local children = { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.label({ text = tr("login_title"), fontSize = 18, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("settings"), onClick = "onOpenSettings" }), + ui.button({ glyph = "close", variant = "ghost", tooltip = tr("close"), onClick = "onClosePanel" }), + }), + ui.label({ text = tr("login_help"), fontSize = 12, color = "on_surface_variant", maxLines = 8 }), + } + + if snapshot.loading then + table.insert(children, ui.label({ text = tr("login_waiting"), color = "primary", fontSize = 13 })) + else + table.insert(children, ui.button({ + text = tr("connect"), + variant = "primary", + onClick = "onConnect", + flexGrow = 1, + })) + end + + if snapshot.error ~= "" then + table.insert(children, ui.label({ text = tr("error", { message = snapshot.error }), color = "error", fontSize = 12 })) + end + + panel.render(ui.column({ gap = 12, padding = 16 }, children)) +end + +local function coverFilename(title) + local name = (title or "cover"):gsub("[^%w%-%._ ]", ""):gsub("%s+", " ") + name = noctalia.string.trim(name) + if name == "" then + name = "cover" + end + if not name:lower():match("%.jpe?g$") then + name = name .. ".jpg" + end + return name +end + +local function ensureJpegExtension(path) + path = noctalia.string.trim(path or "") + if path == "" then + return path + end + if not path:lower():match("%.jpe?g$") then + return path .. ".jpg" + end + return path +end + +local function pickCoverSavePath(defaultName, callback) + local suggested = noctalia.expandPath("~/Downloads/" .. defaultName) + local cmd + if noctalia.commandExists("zenity") then + cmd = "zenity --file-selection --save --confirm-overwrite --filename=" + .. shellQuote(suggested) + elseif noctalia.commandExists("kdialog") then + cmd = "kdialog --getsavefilename " .. shellQuote(suggested) .. " 'Images (*.jpg)'" + else + callback(nil, tr("download_cover_unavailable")) + return + end + + noctalia.runAsync(cmd, function(result) + local path = (result.stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") + if result.exitCode ~= 0 or path == "" then + callback(nil, nil) + return + end + callback(ensureJpegExtension(path), nil) + end) +end + +local function writeCoverToPath(coverPath, coverUrl, destPath, callback) + if coverPath and noctalia.fileExists(coverPath) then + local data = noctalia.readFile(coverPath) + if type(data) == "string" and data ~= "" then + local ok = noctalia.writeFile(destPath, data) + callback(ok, ok and nil or tr("download_cover_failed")) + return + end + end + + if type(coverUrl) == "string" and coverUrl ~= "" then + local started = noctalia.download(coverUrl, destPath, function(ok) + callback(ok == true, ok and nil or tr("download_cover_failed")) + end) + if not started then + callback(false, tr("download_cover_failed")) + end + return + end + + callback(false, tr("download_cover_failed")) +end + +local function startCoverDownload(preview) + if coverDownloadBusy or type(preview) ~= "table" then + return + end + + coverDownloadBusy = true + dirty = true + render() + + pickCoverSavePath(coverFilename(preview.title), function(destPath, pickError) + if pickError then + coverDownloadBusy = false + noctalia.notifyError(tr("title"), pickError) + dirty = true + render() + return + end + if not destPath then + coverDownloadBusy = false + dirty = true + render() + return + end + + writeCoverToPath(preview.coverPath, preview.coverUrl, destPath, function(ok, saveError) + coverDownloadBusy = false + if ok then + noctalia.notify(tr("title"), tr("download_cover_success", { path = destPath })) + elseif saveError then + noctalia.notifyError(tr("title"), saveError) + end + dirty = true + render() + end) + end) +end + +local function openCoverPreview(entry) + if not entry.coverPath then + return + end + coverPreview = { + mediaId = entry.mediaId, + title = entry.title or "?", + coverPath = entry.coverPath, + coverUrl = entry.coverUrl, + } + dirty = true + render() +end + +local function renderCoverPreview() + return ui.column({ gap = 12, padding = 16, flexGrow = 1 }, { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.label({ + text = coverPreview.title or "?", + fontSize = 16, + fontWeight = "bold", + flexGrow = 1, + maxLines = 2, + }), + ui.row({ gap = 4, align = "center" }, { + ui.button({ + glyph = "download", + variant = "ghost", + tooltip = tr("download_cover"), + disabled = coverDownloadBusy, + onClick = coverDownloadBusy and "onNoop" or "onDownloadCoverPreview", + }), + ui.button({ glyph = "close", variant = "ghost", tooltip = tr("close_preview"), onClick = "onCloseCoverPreview" }), + }), + }), + ui.column({ flexGrow = 1, align = "center", justify = "center" }, { + ui.image({ + path = coverPreview.coverPath, + width = PREVIEW_WIDTH, + height = PREVIEW_HEIGHT, + radius = 10, + fit = "cover", + }), + }), + }) +end + +local function renderCover(entry, loadingSet) + local mediaId = entry.mediaId + local coverPath = entry.coverPath + + if coverPath then + return ui.image({ + key = "cover-" .. tostring(mediaId), + path = coverPath, + width = COVER_WIDTH, + height = COVER_HEIGHT, + radius = 6, + fit = "cover", + tooltip = tr("cover_preview"), + onClick = rowCallback("onCover", mediaId, function() + openCoverPreview(entry) + end), + }) + end + + local children = {} + if loadingSet[entry.mediaId] then + table.insert(children, ui.glyph({ + name = "loader", + size = 18, + color = "on_surface", + })) + end + + return ui.column({ + width = COVER_WIDTH, + height = COVER_HEIGHT, + radius = 6, + fill = entry.coverColor or "#334155", + align = "center", + justify = "center", + }, children) +end + +local function renderEntryRow(entry, index, loadingSet) + if index > visibleRowLimit then + return nil + end + + local mediaId = entry.mediaId + local canDecrement = (entry.progress or 0) > 0 and not snapshot.busy + local canIncrement = not snapshot.busy + local isComplete = entry.status == "COMPLETED" + + local textChildren = { + renderTitle(entry), + renderSubtitle(entry), + } + local stars = renderScoreStars(entry.score) + local mainChildren = { + ui.column({ gap = 2, flexGrow = 1, justify = "center" }, textChildren), + } + if stars then + table.insert(mainChildren, stars) + end + + local rowChildren = { + renderCover(entry, loadingSet), + ui.row({ + height = COVER_HEIGHT, + gap = 8, + flexGrow = 1, + align = "center", + }, mainChildren), + } + + table.insert(rowChildren, ui.button({ + glyph = "minus", + variant = "ghost", + tooltip = tr("decrement"), + onClick = canDecrement and rowCallback("onDec", mediaId, function() + sendCommand("decrement", { mediaId = mediaId }) + end) or "onNoop", + })) + table.insert(rowChildren, ui.button({ + glyph = "plus", + variant = "ghost", + tooltip = tr("increment"), + onClick = canIncrement and rowCallback("onInc", mediaId, function() + sendCommand("increment", { mediaId = mediaId }) + end) or "onNoop", + })) + table.insert(rowChildren, ui.button({ + glyph = if isComplete then "check" else "circle-check", + variant = if isComplete then "primary" else "ghost", + tooltip = tr("mark_complete"), + onClick = snapshot.busy and "onNoop" or rowCallback("onDone", mediaId, function() + sendCommand("complete", { mediaId = mediaId }) + end), + })) + table.insert(rowChildren, ui.button({ + glyph = "external-link", + variant = "ghost", + tooltip = tr("open_anilist"), + onClick = rowCallback("onOpen", mediaId, function() + sendCommand("open_media", { mediaId = mediaId, mediaType = entry.mediaType }) + end), + })) + + return ui.row({ + key = "entry-" .. tostring(mediaId), + gap = 10, + align = "center", + padding = { top = 8, bottom = 8 }, + }, rowChildren) +end + +local function renderLibrary() + local rows = entriesForView() + local loadingSet = coverLoadingSet() + local children = {} + + local headerChildren = { + ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }), + } + if snapshot.viewer and snapshot.viewer.name then + table.insert(headerChildren, ui.label({ + text = tr("logged_in_as", { name = snapshot.viewer.name }), + fontSize = 12, + color = "on_surface_variant", + })) + end + + table.insert(children, ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.column({ gap = 2, flexGrow = 1 }, headerChildren), + ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("settings"), onClick = "onOpenSettings" }), + ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("refresh"), onClick = "onRefresh" }), + ui.button({ glyph = "logout", variant = "ghost", tooltip = tr("logout"), onClick = "onLogout" }), + ui.button({ glyph = "close", variant = "ghost", tooltip = tr("close"), onClick = "onClosePanel" }), + })) + + table.insert(children, ui.row({ gap = 6 }, { + ui.button({ + text = tr("tab_anime"), + variant = if mediaTab == "ANIME" then "primary" else "ghost", + onClick = "onTabAnime", + }), + ui.button({ + text = tr("tab_manga"), + variant = if mediaTab == "MANGA" then "primary" else "ghost", + onClick = "onTabManga", + }), + })) + + table.insert(children, ui.scroll({ horizontal = true }, { + ui.row({ gap = 6 }, { + filterButton( + "current", + if mediaTab == "MANGA" then tr("filter_reading") else tr("filter_current"), + "CURRENT" + ), + filterButton( + "planning", + if mediaTab == "MANGA" then tr("filter_planning_manga") else tr("filter_planning"), + "PLANNING" + ), + filterButton("completed", tr("filter_completed"), "COMPLETED"), + filterButton("paused", tr("filter_paused"), "PAUSED"), + filterButton("dropped", tr("filter_dropped"), "DROPPED"), + filterButton("repeating", tr("filter_repeating"), "REPEATING"), + filterButton("all", tr("filter_all"), "ALL"), + }), + })) + + if snapshot.loading and not snapshot.viewer then + table.insert(children, ui.label({ text = tr("loading"), color = "on_surface_variant", padding = { top = 12 } })) + elseif snapshot.error ~= "" then + table.insert(children, ui.label({ text = tr("error", { message = snapshot.error }), color = "error", padding = { top = 12 } })) + elseif snapshot.refreshing then + table.insert(children, ui.label({ text = tr("refreshing"), color = "on_surface_variant", padding = { top = 8 } })) + elseif snapshot.busy then + table.insert(children, ui.label({ text = tr("updating"), color = "on_surface_variant", padding = { top = 8 } })) + end + + if (not snapshot.loading or snapshot.viewer) and #rows == 0 then + table.insert(children, ui.label({ text = tr("empty"), color = "on_surface_variant", padding = { top = 12 } })) + else + local listChildren = {} + local rowCap = listRowCap(rows) + for index = 1, rowCap do + local entry = rows[index] + local row = renderEntryRow(entry, index, loadingSet) + if row then + table.insert(listChildren, row) + end + end + if visibleRowLimit < rowCap then + table.insert(listChildren, ui.row({ gap = 8, align = "center", justify = "center", padding = { top = 4, bottom = 4 } }, { + ui.glyph({ name = "loader", size = 16, color = "on_surface_variant" }), + ui.label({ text = tr("loading_more"), fontSize = 12, color = "on_surface_variant" }), + })) + end + table.insert(children, ui.scroll({ flexGrow = 1, gap = 8 }, listChildren)) + syncVisibleCoverPriority(rows) + syncListLoadInterval(rows) + end + + panel.render(ui.column({ gap = 10, padding = 16, flexGrow = 1 }, children)) +end + +render = function() + if coverPreview then + panel.render(renderCoverPreview()) + dirty = false + return + end + if snapshot.viewer and snapshot.viewer.id then + renderLibrary() + else + renderLogin() + end + dirty = false +end + +noctalia.state.watch("anilist_snapshot", function(value) + if type(value) == "table" then + snapshot = value + dirty = true + render() + end +end) + +noctalia.state.watch("anilist_cover_loading", function() + dirty = true + render() +end) + +panel.setWantsSecondTicks(true) + +function onOpen(_context) + noctalia.state.set("anilist_open", true) + resetListView() + if snapshot.viewer and snapshot.viewer.id then + sendCommand("refresh", { mediaType = mediaTab }) + end + dirty = true + render() +end + +function onClose() + coverPreview = nil + noctalia.state.set("anilist_open", false) + noctalia.setUpdateInterval(LIST_IDLE_INTERVAL_MS) +end + +function update() + if snapshot.viewer and snapshot.viewer.id then + local rows = entriesForView() + if growVisibleRows(rows) then + dirty = true + end + syncListLoadInterval(rows) + end + if dirty then + render() + end +end + +function onClosePanel() + coverPreview = nil + panel.close() +end + +function onCloseCoverPreview() + coverPreview = nil + coverDownloadBusy = false + dirty = true + render() +end + +function onDownloadCoverPreview() + if coverPreview then + startCoverDownload(coverPreview) + end +end + +function onNoop() end + +function onOpenSettings() + noctalia.runAsync("noctalia msg settings-open plugins") +end + +function onConnect() + sendCommand("start_oauth") + dirty = true + render() +end + +function onLogout() + sendCommand("logout") +end + +function onRefresh() + sendCommand("refresh", { mediaType = mediaTab }) +end + +function onTabAnime() + mediaTab = "ANIME" + resetListView() + dirty = true + render() +end + +function onTabManga() + mediaTab = "MANGA" + resetListView() + dirty = true + render() +end + +render() diff --git a/anilist/plugin.toml b/anilist/plugin.toml new file mode 100644 index 0000000..5ef1a35 --- /dev/null +++ b/anilist/plugin.toml @@ -0,0 +1,67 @@ +id = "cleboost/anilist" +name = "AniList (UNOFFICIAL)" +version = "1.0.0" +plugin_api = 3 +author = "cleboost" +license = "MIT" +icon = "device-tv" +description = "Browse and update your AniList anime and manga lists from a quick panel without opening the site." +tags = ["media", "bar", "panel", "service", "network", "productivity"] +dependencies = ["python3", "xdg-open"] + +[[setting]] +key = "client_id" +type = "string" +label_key = "settings.client_id.label" +description_key = "settings.client_id.description" +default = "" + +[[setting]] +key = "client_secret" +type = "string" +label_key = "settings.client_secret.label" +description_key = "settings.client_secret.description" +default = "" + +[[setting]] +key = "access_token" +type = "string" +label_key = "settings.access_token.label" +description_key = "settings.access_token.description" +default = "" + +[[widget]] +id = "tracker" +entry = "widget.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + default = "device-tv" + + [[widget.setting]] + key = "count_mode" + type = "select" + label_key = "settings.count_mode.label" + description_key = "settings.count_mode.description" + default = "current" + options = [ + { value = "current", label_key = "settings.count_mode.options.current" }, + { value = "completed", label_key = "settings.count_mode.options.completed" }, + { value = "total", label_key = "settings.count_mode.options.total" }, + { value = "in_progress", label_key = "settings.count_mode.options.in_progress" }, + { value = "planning", label_key = "settings.count_mode.options.planning" } + ] + +[[panel]] +id = "library" +entry = "panel.luau" +width = 720 +height = 640 +placement = "floating" +position = "center" + +[[service]] +id = "api" +entry = "service.luau" diff --git a/anilist/scripts/oauth_login.py b/anilist/scripts/oauth_login.py new file mode 100755 index 0000000..9a65e89 --- /dev/null +++ b/anilist/scripts/oauth_login.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Temporary localhost OAuth callback server for AniList login.""" + +from __future__ import annotations + +import base64 +import json +import socket +import sys +import threading +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer + +HOST = "127.0.0.1" +PORT = 7823 +REDIRECT_URI = f"http://{HOST}:{PORT}/callback" +AUTH_URL = "https://anilist.co/api/v2/oauth/authorize" +TOKEN_URL = "https://anilist.co/api/v2/oauth/token" +TIMEOUT_SECONDS = 180 + + +def emit(payload: dict, result_path: str | None = None) -> None: + encoded = json.dumps(payload) + print(encoded, flush=True) + if result_path: + with open(result_path, "w", encoding="utf-8") as handle: + handle.write(encoded) + + +def load_credentials(path: str) -> tuple[str, str]: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + client_id = str(data.get("client_id", "")).strip() + client_secret = str(data.get("client_secret", "")).strip() + if not client_id or not client_secret: + raise ValueError("missing client_id or client_secret") + return client_id, client_secret + + +def exchange_code(client_id: str, client_secret: str, code: str) -> str: + body = urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + } + ).encode("utf-8") + credentials = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii") + request = urllib.request.Request( + TOKEN_URL, + data=body, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "Authorization": f"Basic {credentials}", + "User-Agent": "noctalia-anilist-plugin", + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.loads(response.read().decode("utf-8")) + token = payload.get("access_token") + if not token: + message = payload.get("error_description") or payload.get("message") or payload.get("error") + raise RuntimeError(message or "token response missing access_token") + return str(token) + + +def format_http_error(exc: urllib.error.HTTPError) -> str: + try: + detail = json.loads(exc.read().decode("utf-8")) + message = detail.get("error_description") or detail.get("message") or detail.get("error") + if message: + return str(message) + except Exception: # noqa: BLE001 + pass + return exc.reason or "token exchange failed" + + +def port_available(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((HOST, port)) + except OSError: + return False + return True + + +def main() -> int: + if len(sys.argv) not in (2, 3): + emit({"ok": False, "error": "usage: oauth_login.py [result.json]"}, None) + return 1 + + result_path = sys.argv[2] if len(sys.argv) == 3 else None + + if not port_available(PORT): + emit( + { + "ok": False, + "error": f"port {PORT} is already in use; close the other login attempt first", + }, + result_path, + ) + return 1 + + try: + client_id, client_secret = load_credentials(sys.argv[1]) + except Exception as exc: # noqa: BLE001 + emit({"ok": False, "error": str(exc)}, result_path) + return 1 + + result: dict[str, str] = {"status": "pending"} + done = threading.Event() + + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if done.is_set(): + self.send_error(404) + return + + parsed = urllib.parse.urlparse(self.path) + if parsed.path != "/callback": + self.send_error(404) + return + + params = urllib.parse.parse_qs(parsed.query) + error = params.get("error", [None])[0] + if error: + result["status"] = "error" + result["error"] = str(error) + self._success_page("Login failed", "Return to Noctalia and try again.") + done.set() + return + + code = params.get("code", [None])[0] + if not code: + result["status"] = "error" + result["error"] = "missing authorization code" + self._success_page("Login failed", "No authorization code was received.") + done.set() + return + + try: + token = exchange_code(client_id, client_secret, code) + except urllib.error.HTTPError as exc: + result["status"] = "error" + result["error"] = format_http_error(exc) + self._success_page("Login failed", "Could not finish login. Return to Noctalia and try again.") + done.set() + return + except Exception as exc: # noqa: BLE001 + result["status"] = "error" + result["error"] = str(exc) + self._success_page("Login failed", "Could not finish login. Return to Noctalia and try again.") + done.set() + return + + result["status"] = "ok" + result["access_token"] = token + self._success_page( + "Connected to AniList", + "You can close this tab and return to Noctalia.", + ) + done.set() + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + def _success_page(self, title: str, message: str) -> None: + html = f""" + + + + {title} + + + +
+

{title}

+

{message}

+
+ +""" + encoded = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + server = HTTPServer((HOST, PORT), CallbackHandler) + server.timeout = 1 + + authorize = ( + f"{AUTH_URL}?client_id={urllib.parse.quote(client_id)}" + f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}" + "&response_type=code" + ) + webbrowser.open(authorize) + + def serve_until_done() -> None: + while not done.is_set(): + server.handle_request() + + worker = threading.Thread(target=serve_until_done, daemon=True) + worker.start() + if not done.wait(TIMEOUT_SECONDS): + result["status"] = "error" + result["error"] = "login timed out" + + server.server_close() + + if result.get("status") == "ok" and result.get("access_token"): + emit({"ok": True, "access_token": result["access_token"]}, result_path) + return 0 + + emit({"ok": False, "error": result.get("error") or "login cancelled"}, result_path) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/anilist/service.luau b/anilist/service.luau new file mode 100644 index 0000000..e320bfb --- /dev/null +++ b/anilist/service.luau @@ -0,0 +1,1036 @@ +--!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() diff --git a/anilist/thumbnail.webp b/anilist/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..d258d37044ac6903f408b1098116b80bd2e3f021 GIT binary patch literal 48314 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�HdCv-{qbM$BG0L=2Xjm;g>%cA)a0xmH66_#Pg-mL)J(Y8; zuY|0N-o5(p21$F}w*ky?p4<3mg)`T2ET}yxy4C;2oxdmU|2isWTUu|t>z&b?Q$2Ej zLY`jXUuSk~iL|To&O3=&d#}!FKXb2P!;>FBTwnV>e%i2r{cdIf=jF{y?0T)E41WFb z`|`lWp~`6WX>^z-|Shu!=UXd|I^fuuUA 0 then + barWidget.setText(tostring(count)) + barWidget.setTooltip(tr(mode.tooltip, { count = count })) + else + barWidget.setText("") + barWidget.setTooltip(tr("tooltip_empty")) + end +end + +noctalia.state.watch("anilist_open", function(value) + open = value == true + render() +end) + +noctalia.state.watch("anilist_snapshot", function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + noctalia.runAsync("xdg-open 'https://anilist.co/home' >/dev/null 2>&1") +end + +render()