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.
This commit is contained in:
Cleboost
2026-07-23 17:55:08 -04:00
committed by GitHub
parent 1916c1a643
commit 0162521282
9 changed files with 2465 additions and 0 deletions
+80
View File
@@ -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 "<jwt-access-token>"
```
## 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**.
+725
View File
@@ -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()
+67
View File
@@ -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"
+245
View File
@@ -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 <credentials.json> [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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
<style>
body {{
font-family: system-ui, sans-serif;
background: #0f172a;
color: #e2e8f0;
display: grid;
place-items: center;
min-height: 100vh;
margin: 0;
}}
.card {{
background: #1e293b;
border-radius: 12px;
padding: 32px;
max-width: 420px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,.35);
}}
h1 {{ margin-top: 0; color: #38bdf8; }}
</style>
</head>
<body>
<div class="card">
<h1>{title}</h1>
<p>{message}</p>
</div>
</body>
</html>"""
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())
+1036
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+93
View File
@@ -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?"
}
}
+93
View File
@@ -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é ?"
}
}
+126
View File
@@ -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()