diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f75d4..eba9165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.36.3 - 2026-05-24 + +- Changed the Queue page to stop re-rendering the full job list on every poll and refresh only visible non-terminal job cards in place. +- Added single-job queue reads so active job logs and status can update every 2.5 seconds while `done`, `failed`, and `canceled` jobs stop polling automatically. + ## 0.36.2 - 2026-05-23 - Replaced the watchlist `Download all` text prompt with an inline dropdown-style picker that lets you choose `Sub` or `Dub` directly on the card. diff --git a/README.md b/README.md index 398e84e..871249a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Small local web UI for a system-wide `ani-cli` install. -Current version: `0.36.2` +Current version: `0.36.3` ## Project Layout @@ -163,7 +163,8 @@ Use it to: Highlights: -- Automatic queue polling every 2.5 seconds. +- The queue list loads once, then only visible pending or running jobs are refreshed every 2.5 seconds. +- Terminal jobs (`done`, `failed`, `canceled`) stop log polling automatically. - Dedicated queue pagination and cleanup controls. - Search and queue workflows are now separated into distinct pages. - Each queue job card can be collapsed, and finished jobs start collapsed by default to keep the page compact. diff --git a/VERSION b/VERSION index d647c8a..1d3b40e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.36.2 +0.36.3 diff --git a/app_support.py b/app_support.py index 76eddfe..bebb7a7 100644 --- a/app_support.py +++ b/app_support.py @@ -19,7 +19,7 @@ from uuid import uuid4 PROJECT_ROOT = Path(__file__).resolve().parent ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" APP_NAME = "ani-cli-web" -VERSION = "0.36.2" +VERSION = "0.36.3" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/http_handler.py b/http_handler.py index d600c88..5cf056a 100644 --- a/http_handler.py +++ b/http_handler.py @@ -408,6 +408,14 @@ class Handler(BaseHTTPRequestHandler): page = (params.get("page") or ["1"])[0] per_page = (params.get("per_page") or ["10"])[0] self.json(runtime["download_queue"].list(page=page, per_page=per_page)) + elif parsed.path.startswith("/api/queue/"): + runtime = Handler._runtime(self) + parts = parsed.path.strip("/").split("/") + if len(parts) != 3: + self.error(HTTPStatus.NOT_FOUND, "Not found") + return + _, _, job_id = parts + self.json(runtime["download_queue"].get(job_id)) elif parsed.path.startswith("/api/watchlist/thumb/"): runtime = Handler._runtime(self) show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip() diff --git a/queue_jobs.py b/queue_jobs.py index b69c9dc..6a099a0 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -798,6 +798,10 @@ class DownloadQueue: "pages": pages, } + def get(self, job_id): + with self.lock: + return self._find(job_id) + def add(self, payload): job = build_job(payload, self.config_getter()) if self.job_prepare_fn is not None: diff --git a/queue_page.py b/queue_page.py index d471536..f8d1730 100644 --- a/queue_page.py +++ b/queue_page.py @@ -326,7 +326,7 @@ QUEUE_HTML = r"""

What you can do here

Search stays focused on finding shows and adding new jobs. Queue now handles status, logs, retries, and cleanup as a separate workflow.

- Auto refresh every 2.5s + Active jobs update every 2.5s @@ -351,7 +351,8 @@ QUEUE_HTML = r""" queueTotal: 0, queueRequestId: 0, queueTimer: null, - collapsedJobs: {} + collapsedJobs: {}, + jobCards: {} }; const $ = (id) => document.getElementById(id); @@ -382,11 +383,87 @@ QUEUE_HTML = r""" state.collapsedJobs[jobId] = collapsed; } + function isTerminalStatus(status) { + return ["done", "failed", "canceled"].includes(status); + } + + function buildJobSummary(job) { + const libraryName = job.anime_name || job.title; + const season = String(job.season || "1").padStart(2, "0"); + const seasonFolder = `Season ${season}`; + const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`; + return `${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`; + } + + function buildJobLog(job) { + return [...(job.log || [])].slice(-28).reverse().join("\n"); + } + + function renderJobActions(job, actions) { + actions.innerHTML = ""; + if (job.status === "running" || job.status === "pending") { + actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger")); + return; + } + actions.append(actionButton("Retry", () => queueAction(job.id, "retry"))); + actions.append(actionButton("Remove", () => queueAction(job.id, "remove"))); + } + + function updateJobCard(item, job) { + item.dataset.jobId = job.id; + item.dataset.jobStatus = job.status; + item.querySelector(".job-title").textContent = job.title; + item.querySelector(".job-head .muted").textContent = buildJobSummary(job); + const status = item.querySelector(".status"); + status.textContent = job.status; + status.className = "status"; + status.classList.add(job.status); + item.querySelector("pre").textContent = buildJobLog(job) || "Waiting..."; + item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`; + renderJobActions(job, item.querySelector(".row")); + state.jobCards[job.id] = item; + } + + function createJobCard(job) { + const item = document.createElement("article"); + item.className = "job"; + const collapsed = isCollapsed(job); + if (collapsed) item.classList.add("collapsed"); + item.innerHTML = ` +
+ +
+
+

+
+ +
+
+

+          
+ +
+
+
+ `; + const toggle = item.querySelector(".job-toggle"); + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + toggle.addEventListener("click", () => { + const nextCollapsed = !item.classList.contains("collapsed"); + item.classList.toggle("collapsed", nextCollapsed); + toggle.setAttribute("aria-expanded", nextCollapsed ? "false" : "true"); + setCollapsed(job.id, nextCollapsed); + }); + updateJobCard(item, job); + return item; + } + function renderQueue(data) { const jobs = data.jobs || []; state.queuePage = data.page || 1; state.queuePages = data.pages || 1; state.queueTotal = data.total || 0; + state.jobCards = {}; const el = $("queue"); el.innerHTML = ""; if (!jobs.length) { @@ -395,58 +472,7 @@ QUEUE_HTML = r""" return; } for (const job of jobs) { - const item = document.createElement("article"); - item.className = "job"; - const collapsed = isCollapsed(job); - if (collapsed) item.classList.add("collapsed"); - const log = [...(job.log || [])].slice(-28).reverse().join("\n"); - item.innerHTML = ` -
- -
-
-

-
- -
-
-

-            
- -
-
-
- `; - const toggle = item.querySelector(".job-toggle"); - toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); - toggle.addEventListener("click", () => { - const nextCollapsed = !item.classList.contains("collapsed"); - item.classList.toggle("collapsed", nextCollapsed); - toggle.setAttribute("aria-expanded", nextCollapsed ? "false" : "true"); - setCollapsed(job.id, nextCollapsed); - }); - item.querySelector(".job-title").textContent = job.title; - const libraryName = job.anime_name || job.title; - const season = String(job.season || "1").padStart(2, "0"); - const seasonFolder = `Season ${season}`; - const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`; - item.querySelector(".job-head .muted").textContent = - `${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`; - const status = item.querySelector(".status"); - status.textContent = job.status; - status.classList.add(job.status); - item.querySelector("pre").textContent = log || "Waiting..."; - item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`; - const actions = item.querySelector(".row"); - if (job.status === "running") { - actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger")); - } else if (job.status === "pending") { - actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger")); - } else { - actions.append(actionButton("Retry", () => queueAction(job.id, "retry"))); - actions.append(actionButton("Remove", () => queueAction(job.id, "remove"))); - } - el.appendChild(item); + el.appendChild(createJobCard(job)); } renderQueuePager(); } @@ -501,13 +527,31 @@ QUEUE_HTML = r""" return data; } + async function refreshVisibleJob(jobId) { + const item = state.jobCards[jobId]; + if (!item) return null; + const job = await api(`/api/queue/${jobId}`); + if (!state.jobCards[jobId]) return job; + updateJobCard(state.jobCards[jobId], job); + return job; + } + + async function pollVisibleJobs() { + const activeJobIds = Object.entries(state.jobCards) + .filter(([, item]) => item && !isTerminalStatus(item.dataset.jobStatus || "")) + .map(([jobId]) => jobId); + for (const jobId of activeJobIds) { + await refreshVisibleJob(jobId); + } + } + $("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs")); $("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs")); (async function init() { try { await loadQueue(); - state.queueTimer = startSerialPoll(() => loadQueue(), 2500); + state.queueTimer = startSerialPoll(() => pollVisibleJobs(), 2500); } catch (error) { setNotice(error.message); } diff --git a/test_app.py b/test_app.py index 49b53fa..4790da0 100644 --- a/test_app.py +++ b/test_app.py @@ -259,6 +259,54 @@ class DownloadQueueCancelTests(unittest.TestCase): self.assertEqual(len(saved), 1) self.assertEqual(saved[0][-1], "line flush") + +class QueueApiTests(unittest.TestCase): + def setUp(self): + APP.reset_runtime(wait=True) + APP.initialize_runtime(start_workers=False) + self.queue = APP.DOWNLOAD_QUEUE + with self.queue._connect() as conn: + conn.execute("DELETE FROM jobs") + + def test_download_queue_get_returns_single_job(self): + created = self.queue.add( + { + "query": "Example Show", + "title": "Example Show", + "anime_name": "Example Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + + fetched = self.queue.get(created["id"]) + + self.assertEqual(fetched["id"], created["id"]) + self.assertEqual(fetched["title"], "Example Show") + + def test_queue_item_get_route_returns_single_job_payload(self): + created = self.queue.add( + { + "query": "Route Show", + "title": "Route Show", + "anime_name": "Route Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + handler = DummyHandler(f"/api/queue/{created['id']}") + + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["id"], created["id"]) + def test_save_job_splits_log_and_extra_payload_columns(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) job = { @@ -1707,7 +1755,7 @@ class TemplateHelperTests(unittest.TestCase): def test_pages_share_serial_polling_helper(self): self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML) self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML) - self.assertIn("startSerialPoll(() => loadQueue(), 2500)", APP.QUEUE_HTML) + self.assertIn("startSerialPoll(() => pollVisibleJobs(), 2500)", APP.QUEUE_HTML) self.assertIn("startSerialPoll(async () => {", APP.WATCHLIST_HTML) self.assertIn("window.clearTimeout(timer);", APP.QUEUE_HTML) self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.QUEUE_HTML)