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:
@@ -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**.
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
Executable
+245
@@ -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())
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -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?"
|
||||
}
|
||||
}
|
||||
@@ -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é ?"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user