Refine queue page polling
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user