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 0000000..d258d37 Binary files /dev/null and b/anilist/thumbnail.webp differ diff --git a/anilist/translations/en.json b/anilist/translations/en.json new file mode 100644 index 0000000..e7bbb5a --- /dev/null +++ b/anilist/translations/en.json @@ -0,0 +1,93 @@ +{ + "settings": { + "client_id": { + "label": "Client ID", + "description": "Your AniList developer app client ID (anilist.co/settings/developer). Redirect URL must be http://127.0.0.1:7823/callback" + }, + "client_secret": { + "label": "Client secret", + "description": "The client secret from the same AniList app. Never share it publicly — each user should use their own app." + }, + "access_token": { + "label": "Access token (optional)", + "description": "Skip browser login if you already have a bearer token. Usually left empty." + }, + "glyph": { + "label": "Bar glyph" + }, + "count_mode": { + "label": "Bar count", + "description": "Which number to show next to the bar glyph.", + "options": { + "current": "Watching (anime in progress)", + "completed": "Completed (anime)", + "total": "Total (all anime)", + "in_progress": "In progress (anime + manga)", + "planning": "Planning (anime)" + } + } + }, + "widget": { + "tooltip_current": "{count} anime in progress", + "tooltip_completed": "{count} anime completed", + "tooltip_total": "{count} anime on your list", + "tooltip_in_progress": "{count} in progress (anime + manga)", + "tooltip_planning": "{count} anime planned", + "tooltip_empty": "AniList — open library", + "tooltip_loading": "AniList — loading…", + "tooltip_error": "AniList — {error}" + }, + "panel": { + "title": "AniList (UNOFFICIAL)", + "login_title": "Connect to AniList", + "login_help": "Set your client ID and client secret in plugin settings, then click Connect. Your browser opens, you approve AniList, and the plugin finishes login automatically.", + "connect": "Connect with AniList", + "login_waiting": "Waiting for browser login…", + "settings": "Plugin settings", + "logout": "Log out", + "refresh": "Reload list", + "refreshing": "Refreshing…", + "close": "Close", + "loading": "Loading your lists…", + "loading_more": "Loading more entries…", + "error": "Error: {message}", + "empty": "No entries in this list.", + "tab_anime": "Anime", + "tab_manga": "Manga", + "filter_current": "Watching", + "filter_reading": "Reading", + "filter_planning": "Planning", + "filter_planning_manga": "Plan to Read", + "filter_completed": "Completed", + "filter_paused": "Paused", + "filter_dropped": "Dropped", + "filter_repeating": "Repeating", + "filter_all": "All", + "progress_anime": "Ep {current}/{total}", + "progress_anime_unknown": "Ep {current}", + "progress_manga": "Ch {current}/{total}", + "progress_manga_unknown": "Ch {current}", + "next_episode": "Ep {episode} soon", + "open_anilist": "Open on AniList", + "cover_preview": "View cover", + "close_preview": "Close preview", + "download_cover": "Download cover", + "download_cover_success": "Cover saved to {path}", + "download_cover_failed": "Could not save the cover image.", + "download_cover_unavailable": "Install zenity or kdialog to choose a save location.", + "mark_complete": "Complete", + "decrement": "Previous episode/chapter", + "increment": "Next episode/chapter", + "updating": "Updating…", + "logged_in_as": "Signed in as {name}" + }, + "service": { + "invalid_token": "Invalid or expired access token.", + "network_error": "Could not reach AniList.", + "mutation_failed": "Update failed.", + "not_configured": "Set AniList client ID and client secret in plugin settings.", + "oauth_failed": "Browser login failed.", + "oauth_busy": "A login is already in progress.", + "oauth_unavailable": "Could not start the local login helper. Is python3 installed?" + } +} diff --git a/anilist/translations/fr.json b/anilist/translations/fr.json new file mode 100644 index 0000000..f2cef70 --- /dev/null +++ b/anilist/translations/fr.json @@ -0,0 +1,93 @@ +{ + "settings": { + "client_id": { + "label": "ID client", + "description": "ID client de votre application développeur AniList (anilist.co/settings/developer). L'URL de redirection doit être http://127.0.0.1:7823/callback" + }, + "client_secret": { + "label": "Secret client", + "description": "Secret client de la même application AniList. Ne le partagez jamais publiquement — chaque utilisateur doit utiliser sa propre application." + }, + "access_token": { + "label": "Jeton d'accès (optionnel)", + "description": "Ignore la connexion navigateur si vous avez déjà un jeton bearer. Laissez vide en temps normal." + }, + "glyph": { + "label": "Icône de la barre" + }, + "count_mode": { + "label": "Compteur barre", + "description": "Quel nombre afficher à côté de l'icône dans la barre.", + "options": { + "current": "En cours (anime)", + "completed": "Terminé (anime)", + "total": "Total (tous les anime)", + "in_progress": "En cours (anime + manga)", + "planning": "Prévu (anime)" + } + } + }, + "widget": { + "tooltip_current": "{count} anime en cours", + "tooltip_completed": "{count} anime terminés", + "tooltip_total": "{count} anime sur votre liste", + "tooltip_in_progress": "{count} en cours (anime + manga)", + "tooltip_planning": "{count} anime prévus", + "tooltip_empty": "AniList — ouvrir la bibliothèque", + "tooltip_loading": "AniList — chargement…", + "tooltip_error": "AniList — {error}" + }, + "panel": { + "title": "AniList (UNOFFICIEL)", + "login_title": "Se connecter à AniList", + "login_help": "Renseignez votre ID client et votre secret client dans les paramètres du plugin, puis cliquez sur Se connecter. Votre navigateur s'ouvre, vous autorisez AniList, et le plugin termine la connexion automatiquement.", + "connect": "Se connecter avec AniList", + "login_waiting": "En attente de la connexion dans le navigateur…", + "settings": "Paramètres du plugin", + "logout": "Se déconnecter", + "refresh": "Actualiser la liste", + "refreshing": "Actualisation…", + "close": "Fermer", + "loading": "Chargement de vos listes…", + "loading_more": "Chargement de la liste…", + "error": "Erreur : {message}", + "empty": "Aucune entrée dans cette liste.", + "tab_anime": "Anime", + "tab_manga": "Manga", + "filter_current": "En cours", + "filter_reading": "En lecture", + "filter_planning": "À voir", + "filter_planning_manga": "À lire", + "filter_completed": "Terminé", + "filter_paused": "En pause", + "filter_dropped": "Abandonné", + "filter_repeating": "En reprise", + "filter_all": "Tout", + "progress_anime": "Ép. {current}/{total}", + "progress_anime_unknown": "Ép. {current}", + "progress_manga": "Ch. {current}/{total}", + "progress_manga_unknown": "Ch. {current}", + "next_episode": "Ép. {episode} bientôt", + "open_anilist": "Ouvrir sur AniList", + "cover_preview": "Voir la pochette", + "close_preview": "Fermer l'aperçu", + "download_cover": "Télécharger la pochette", + "download_cover_success": "Pochette enregistrée dans {path}", + "download_cover_failed": "Impossible d'enregistrer la pochette.", + "download_cover_unavailable": "Installez zenity ou kdialog pour choisir où enregistrer l'image.", + "mark_complete": "Terminer", + "decrement": "Épisode ou chapitre précédent", + "increment": "Épisode ou chapitre suivant", + "updating": "Mise à jour…", + "logged_in_as": "Connecté en tant que {name}" + }, + "service": { + "invalid_token": "Jeton d'accès invalide ou expiré.", + "network_error": "Impossible de joindre AniList.", + "mutation_failed": "La mise à jour a échoué.", + "not_configured": "Renseignez l'ID client et le secret client AniList dans les paramètres du plugin.", + "oauth_failed": "La connexion via le navigateur a échoué.", + "oauth_busy": "Une connexion est déjà en cours.", + "oauth_unavailable": "Impossible de démarrer l'assistant de connexion local. python3 est-il installé ?" + } +} diff --git a/anilist/widget.luau b/anilist/widget.luau new file mode 100644 index 0000000..5bd8bcf --- /dev/null +++ b/anilist/widget.luau @@ -0,0 +1,126 @@ +--!nonstrict +-- AniList (UNOFFICIAL) bar widget: opens the library panel and shows a configurable list count. + +local PANEL_ID = "cleboost/anilist:library" + +local open = false +local snapshot = noctalia.state.get("anilist_snapshot") or {} + +local function tr(key, subst) + return noctalia.tr("widget." .. key, subst) +end + +local function matchesStatus(entry, statuses) + for _, status in ipairs(statuses) do + if entry.status == status then + return true + end + end + return false +end + +local function countEntries(rows, statuses) + local n = 0 + for _, entry in ipairs(rows or {}) do + if not statuses or matchesStatus(entry, statuses) then + n += 1 + end + end + return n +end + +local COUNT_MODES = { + current = { + tooltip = "tooltip_current", + count = function(anime, _) + return countEntries(anime, { "CURRENT", "REPEATING" }) + end, + }, + completed = { + tooltip = "tooltip_completed", + count = function(anime, _) + return countEntries(anime, { "COMPLETED" }) + end, + }, + total = { + tooltip = "tooltip_total", + count = function(anime, _) + return #anime + end, + }, + in_progress = { + tooltip = "tooltip_in_progress", + count = function(anime, manga) + local statuses = { "CURRENT", "REPEATING" } + return countEntries(anime, statuses) + countEntries(manga, statuses) + end, + }, + planning = { + tooltip = "tooltip_planning", + count = function(anime, _) + return countEntries(anime, { "PLANNING" }) + end, + }, +} + +local function activeCountMode() + local mode = noctalia.getConfig("count_mode") + if type(mode) ~= "string" or mode == "" then + mode = "current" + end + return COUNT_MODES[mode] or COUNT_MODES.current +end + +local function render() + local glyph = noctalia.getConfig("glyph") or "device-tv" + barWidget.setGlyph(glyph) + barWidget.setGlyphColor(if open then "primary" else "on_surface") + + if snapshot.loading then + barWidget.setText("") + barWidget.setTooltip(tr("tooltip_loading")) + return + end + + if snapshot.error and snapshot.error ~= "" and not snapshot.viewer then + barWidget.setText("") + barWidget.setTooltip(tr("tooltip_error", { error = snapshot.error })) + return + end + + local mode = activeCountMode() + local count = mode.count(snapshot.anime or {}, snapshot.manga or {}) + if count > 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()