fix(anilist): paginate large libraries to avoid CPU budget kills (#184)

* fix(anilist): paginate large libraries to avoid CPU budget kills

Chunk GraphQL fetches and defer merge, sort, and cover work to update()
ticks so 1000+ anime and manga entries load without blocking callbacks.

* fix(anilist): handle OAuth callback immediately and simplify service helpers

Process browser login tokens synchronously from runStream and file poll, keep a 500ms update interval during OAuth, and avoid duplicate OAuth result emissions from Python.
This commit is contained in:
Cleboost
2026-07-31 17:12:24 -04:00
committed by GitHub
parent dffd46ab4d
commit be208b42c5
7 changed files with 682 additions and 198 deletions
+24 -7
View File
@@ -15,6 +15,7 @@ local visibleRowLimit = INITIAL_VISIBLE_ROWS
local snapshot = noctalia.state.get("anilist_snapshot") or { local snapshot = noctalia.state.get("anilist_snapshot") or {
revision = 0, revision = 0,
oauthLoading = false,
loading = false, loading = false,
refreshing = false, refreshing = false,
busy = false, busy = false,
@@ -22,6 +23,7 @@ local snapshot = noctalia.state.get("anilist_snapshot") or {
viewer = nil, viewer = nil,
anime = {}, anime = {},
manga = {}, manga = {},
loadProgress = nil,
} }
local mediaTab = "ANIME" local mediaTab = "ANIME"
@@ -229,6 +231,17 @@ local function settingsButton()
}) })
end end
local function loadProgressText()
local progress = snapshot.loadProgress
if type(progress) ~= "table" then
return tr("loading")
end
return tr("loading_progress", {
anime = progress.animeLoaded or 0,
manga = progress.mangaLoaded or 0,
})
end
local function renderLogin() local function renderLogin()
local children = { local children = {
ui.row({ align = "center", justify = "space_between", gap = 8 }, { ui.row({ align = "center", justify = "space_between", gap = 8 }, {
@@ -239,8 +252,10 @@ local function renderLogin()
ui.label({ text = tr("login_help"), fontSize = 12, color = "on_surface_variant", maxLines = 8 }), ui.label({ text = tr("login_help"), fontSize = 12, color = "on_surface_variant", maxLines = 8 }),
} }
if snapshot.loading then if snapshot.oauthLoading then
table.insert(children, ui.label({ text = tr("login_waiting"), color = "primary", fontSize = 13 })) table.insert(children, ui.label({ text = tr("login_waiting"), color = "primary", fontSize = 13 }))
elseif snapshot.loading then
table.insert(children, ui.label({ text = loadProgressText(), color = "primary", fontSize = 13 }))
else else
table.insert(children, ui.button({ table.insert(children, ui.button({
text = tr("connect"), text = tr("connect"),
@@ -578,17 +593,17 @@ local function renderLibrary()
}), }),
})) }))
if snapshot.loading and not snapshot.viewer then if snapshot.loading then
table.insert(children, ui.label({ text = tr("loading"), color = "on_surface_variant", padding = { top = 12 } })) table.insert(children, ui.label({ text = loadProgressText(), color = "on_surface_variant", padding = { top = 12 } }))
elseif snapshot.error ~= "" then elseif snapshot.error ~= "" then
table.insert(children, ui.label({ text = tr("error", { message = snapshot.error }), color = "error", padding = { top = 12 } })) table.insert(children, ui.label({ text = tr("error", { message = snapshot.error }), color = "error", padding = { top = 12 } }))
elseif snapshot.refreshing then elseif snapshot.refreshing and #rows == 0 then
table.insert(children, ui.label({ text = tr("refreshing"), color = "on_surface_variant", padding = { top = 8 } })) table.insert(children, ui.label({ text = tr("refreshing"), color = "on_surface_variant", padding = { top = 8 } }))
elseif snapshot.busy then elseif snapshot.busy then
table.insert(children, ui.label({ text = tr("updating"), color = "on_surface_variant", padding = { top = 8 } })) table.insert(children, ui.label({ text = tr("updating"), color = "on_surface_variant", padding = { top = 8 } }))
end end
if (not snapshot.loading or snapshot.viewer) and #rows == 0 then if not snapshot.loading and #rows == 0 then
table.insert(children, ui.label({ text = tr("empty"), color = "on_surface_variant", padding = { top = 12 } })) table.insert(children, ui.label({ text = tr("empty"), color = "on_surface_variant", padding = { top = 12 } }))
else else
local listChildren = {} local listChildren = {}
@@ -647,7 +662,7 @@ function onOpen(_context)
noctalia.state.set("anilist_open", true) noctalia.state.set("anilist_open", true)
resetListView() resetListView()
if snapshot.viewer and snapshot.viewer.id then if snapshot.viewer and snapshot.viewer.id then
sendCommand("refresh", { mediaType = mediaTab }) sendCommand("refresh", { mediaType = mediaTab, silent = true })
end end
dirty = true dirty = true
render() render()
@@ -656,7 +671,9 @@ end
function onClose() function onClose()
coverPreview = nil coverPreview = nil
noctalia.state.set("anilist_open", false) noctalia.state.set("anilist_open", false)
noctalia.setUpdateInterval(LIST_IDLE_INTERVAL_MS) if not snapshot.oauthLoading then
noctalia.setUpdateInterval(LIST_IDLE_INTERVAL_MS)
end
end end
function update() function update()
+1 -1
View File
@@ -1,6 +1,6 @@
id = "cleboost/anilist" id = "cleboost/anilist"
name = "AniList (UNOFFICIAL)" name = "AniList (UNOFFICIAL)"
version = "1.1.0" version = "1.1.1"
plugin_api = 15 plugin_api = 15
author = "Cleboost" author = "Cleboost"
license = "MIT" license = "MIT"
+17 -2
View File
@@ -113,9 +113,15 @@ def main() -> int:
emit({"ok": False, "error": str(exc)}, result_path) emit({"ok": False, "error": str(exc)}, result_path)
return 1 return 1
result: dict[str, str] = {"status": "pending"} result: dict[str, str | bool] = {"status": "pending"}
emitted = False
done = threading.Event() done = threading.Event()
def report(payload: dict) -> None:
nonlocal emitted
emit(payload, result_path)
emitted = True
class CallbackHandler(BaseHTTPRequestHandler): class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 def do_GET(self) -> None: # noqa: N802
if done.is_set(): if done.is_set():
@@ -132,6 +138,7 @@ def main() -> int:
if error: if error:
result["status"] = "error" result["status"] = "error"
result["error"] = str(error) result["error"] = str(error)
report({"ok": False, "error": str(error)})
self._success_page("Login failed", "Return to Noctalia and try again.") self._success_page("Login failed", "Return to Noctalia and try again.")
done.set() done.set()
return return
@@ -140,6 +147,7 @@ def main() -> int:
if not code: if not code:
result["status"] = "error" result["status"] = "error"
result["error"] = "missing authorization code" result["error"] = "missing authorization code"
report({"ok": False, "error": "missing authorization code"})
self._success_page("Login failed", "No authorization code was received.") self._success_page("Login failed", "No authorization code was received.")
done.set() done.set()
return return
@@ -147,20 +155,24 @@ def main() -> int:
try: try:
token = exchange_code(client_id, client_secret, code) token = exchange_code(client_id, client_secret, code)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
message = format_http_error(exc)
result["status"] = "error" result["status"] = "error"
result["error"] = format_http_error(exc) result["error"] = message
report({"ok": False, "error": message})
self._success_page("Login failed", "Could not finish login. Return to Noctalia and try again.") self._success_page("Login failed", "Could not finish login. Return to Noctalia and try again.")
done.set() done.set()
return return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
result["status"] = "error" result["status"] = "error"
result["error"] = str(exc) result["error"] = str(exc)
report({"ok": False, "error": str(exc)})
self._success_page("Login failed", "Could not finish login. Return to Noctalia and try again.") self._success_page("Login failed", "Could not finish login. Return to Noctalia and try again.")
done.set() done.set()
return return
result["status"] = "ok" result["status"] = "ok"
result["access_token"] = token result["access_token"] = token
report({"ok": True, "access_token": token})
self._success_page( self._success_page(
"Connected to AniList", "Connected to AniList",
"You can close this tab and return to Noctalia.", "You can close this tab and return to Noctalia.",
@@ -233,6 +245,9 @@ def main() -> int:
server.server_close() server.server_close()
if emitted:
return 0 if result.get("status") == "ok" else 1
if result.get("status") == "ok" and result.get("access_token"): if result.get("status") == "ok" and result.get("access_token"):
emit({"ok": True, "access_token": result["access_token"]}, result_path) emit({"ok": True, "access_token": result["access_token"]}, result_path)
return 0 return 0
+630 -186
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -23,6 +23,7 @@
"increment": "Next episode/chapter", "increment": "Next episode/chapter",
"loading": "Loading your lists…", "loading": "Loading your lists…",
"loading_more": "Loading more entries…", "loading_more": "Loading more entries…",
"loading_progress": "Loading lists… {anime} anime, {manga} manga",
"logged_in_as": "Signed in as {name}", "logged_in_as": "Signed in as {name}",
"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.", "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.",
"login_title": "Connect to AniList", "login_title": "Connect to AniList",
@@ -44,6 +45,8 @@
"updating": "Updating…" "updating": "Updating…"
}, },
"service": { "service": {
"empty_response": "AniList returned an empty response.",
"invalid_response": "AniList returned an unreadable response.",
"invalid_token": "Invalid or expired access token.", "invalid_token": "Invalid or expired access token.",
"mutation_failed": "Update failed.", "mutation_failed": "Update failed.",
"network_error": "Could not reach AniList.", "network_error": "Could not reach AniList.",
@@ -87,6 +90,7 @@
"tooltip_error": "AniList — {error}", "tooltip_error": "AniList — {error}",
"tooltip_in_progress": "{count} in progress (anime + manga)", "tooltip_in_progress": "{count} in progress (anime + manga)",
"tooltip_loading": "AniList — loading…", "tooltip_loading": "AniList — loading…",
"tooltip_oauth": "AniList — waiting for browser login…",
"tooltip_planning": "{count} anime planned", "tooltip_planning": "{count} anime planned",
"tooltip_total": "{count} anime on your list" "tooltip_total": "{count} anime on your list"
} }
+4
View File
@@ -23,6 +23,7 @@
"increment": "Épisode ou chapitre suivant", "increment": "Épisode ou chapitre suivant",
"loading": "Chargement de vos listes…", "loading": "Chargement de vos listes…",
"loading_more": "Chargement de la liste…", "loading_more": "Chargement de la liste…",
"loading_progress": "Chargement… {anime} anime, {manga} manga",
"logged_in_as": "Connecté en tant que {name}", "logged_in_as": "Connecté en tant que {name}",
"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.", "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.",
"login_title": "Se connecter à AniList", "login_title": "Se connecter à AniList",
@@ -44,6 +45,8 @@
"updating": "Mise à jour…" "updating": "Mise à jour…"
}, },
"service": { "service": {
"empty_response": "AniList a renvoyé une réponse vide.",
"invalid_response": "AniList a renvoyé une réponse illisible.",
"invalid_token": "Jeton d'accès invalide ou expiré.", "invalid_token": "Jeton d'accès invalide ou expiré.",
"mutation_failed": "La mise à jour a échoué.", "mutation_failed": "La mise à jour a échoué.",
"network_error": "Impossible de joindre AniList.", "network_error": "Impossible de joindre AniList.",
@@ -87,6 +90,7 @@
"tooltip_error": "AniList — {error}", "tooltip_error": "AniList — {error}",
"tooltip_in_progress": "{count} en cours (anime + manga)", "tooltip_in_progress": "{count} en cours (anime + manga)",
"tooltip_loading": "AniList — chargement…", "tooltip_loading": "AniList — chargement…",
"tooltip_oauth": "AniList — en attente de la connexion navigateur…",
"tooltip_planning": "{count} anime prévus", "tooltip_planning": "{count} anime prévus",
"tooltip_total": "{count} anime sur votre liste" "tooltip_total": "{count} anime sur votre liste"
} }
+2 -2
View File
@@ -76,9 +76,9 @@ local function render()
barWidget.setGlyph(glyph) barWidget.setGlyph(glyph)
barWidget.setGlyphColor(if open then "primary" else "on_surface") barWidget.setGlyphColor(if open then "primary" else "on_surface")
if snapshot.loading then if snapshot.oauthLoading or snapshot.loading then
barWidget.setText("") barWidget.setText("")
barWidget.setTooltip(tr("tooltip_loading")) barWidget.setTooltip(if snapshot.oauthLoading then tr("tooltip_oauth") else tr("tooltip_loading"))
return return
end end