Refine queue page polling

This commit is contained in:
Dymas
2026-05-24 11:47:38 +02:00
parent 93446c5e92
commit 643408b19d
8 changed files with 170 additions and 60 deletions
+5
View File
@@ -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.
+3 -2
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.36.2
0.36.3
+1 -1
View File
@@ -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"
+8
View File
@@ -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()
+4
View File
@@ -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:
+80 -36
View File
@@ -326,7 +326,7 @@ QUEUE_HTML = r"""<!doctype html>
<h2>What you can do here</h2>
<p class="muted">Search stays focused on finding shows and adding new jobs. Queue now handles status, logs, retries, and cleanup as a separate workflow.</p>
</div>
<span class="badge">Auto refresh every 2.5s</span>
<span class="badge">Active jobs update every 2.5s</span>
</section>
</aside>
@@ -351,7 +351,8 @@ QUEUE_HTML = r"""<!doctype html>
queueTotal: 0,
queueRequestId: 0,
queueTimer: null,
collapsedJobs: {}
collapsedJobs: {},
jobCards: {}
};
const $ = (id) => document.getElementById(id);
@@ -382,24 +383,52 @@ QUEUE_HTML = r"""<!doctype html>
state.collapsedJobs[jobId] = collapsed;
}
function renderQueue(data) {
const jobs = data.jobs || [];
state.queuePage = data.page || 1;
state.queuePages = data.pages || 1;
state.queueTotal = data.total || 0;
const el = $("queue");
el.innerHTML = "";
if (!jobs.length) {
el.innerHTML = '<div class="empty">Queue is empty.</div>';
renderQueuePager();
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;
}
for (const job of jobs) {
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");
const log = [...(job.log || [])].slice(-28).reverse().join("\n");
item.innerHTML = `
<div class="job-head">
<button class="job-toggle ghost" type="button" aria-label="Toggle job details" aria-expanded="true"></button>
@@ -425,28 +454,25 @@ QUEUE_HTML = r"""<!doctype html>
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")));
updateJobCard(item, job);
return item;
}
el.appendChild(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) {
el.innerHTML = '<div class="empty">Queue is empty.</div>';
renderQueuePager();
return;
}
for (const job of jobs) {
el.appendChild(createJobCard(job));
}
renderQueuePager();
}
@@ -501,13 +527,31 @@ QUEUE_HTML = r"""<!doctype html>
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);
}
+49 -1
View File
@@ -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)