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)