Refine queue page polling
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
Small local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.36.2`
|
Current version: `0.36.3`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -163,7 +163,8 @@ Use it to:
|
|||||||
|
|
||||||
Highlights:
|
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.
|
- Dedicated queue pagination and cleanup controls.
|
||||||
- Search and queue workflows are now separated into distinct pages.
|
- 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.
|
- 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
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||||
APP_NAME = "ani-cli-web"
|
APP_NAME = "ani-cli-web"
|
||||||
VERSION = "0.36.2"
|
VERSION = "0.36.3"
|
||||||
ALLANIME_BASE = "allanime.day"
|
ALLANIME_BASE = "allanime.day"
|
||||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||||
ALLANIME_REFERER = "https://allmanga.to"
|
ALLANIME_REFERER = "https://allmanga.to"
|
||||||
|
|||||||
@@ -408,6 +408,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
page = (params.get("page") or ["1"])[0]
|
page = (params.get("page") or ["1"])[0]
|
||||||
per_page = (params.get("per_page") or ["10"])[0]
|
per_page = (params.get("per_page") or ["10"])[0]
|
||||||
self.json(runtime["download_queue"].list(page=page, per_page=per_page))
|
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/"):
|
elif parsed.path.startswith("/api/watchlist/thumb/"):
|
||||||
runtime = Handler._runtime(self)
|
runtime = Handler._runtime(self)
|
||||||
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
|
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
|
||||||
|
|||||||
@@ -798,6 +798,10 @@ class DownloadQueue:
|
|||||||
"pages": pages,
|
"pages": pages,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get(self, job_id):
|
||||||
|
with self.lock:
|
||||||
|
return self._find(job_id)
|
||||||
|
|
||||||
def add(self, payload):
|
def add(self, payload):
|
||||||
job = build_job(payload, self.config_getter())
|
job = build_job(payload, self.config_getter())
|
||||||
if self.job_prepare_fn is not None:
|
if self.job_prepare_fn is not None:
|
||||||
|
|||||||
+99
-55
@@ -326,7 +326,7 @@ QUEUE_HTML = r"""<!doctype html>
|
|||||||
<h2>What you can do here</h2>
|
<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>
|
<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>
|
</div>
|
||||||
<span class="badge">Auto refresh every 2.5s</span>
|
<span class="badge">Active jobs update every 2.5s</span>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -351,7 +351,8 @@ QUEUE_HTML = r"""<!doctype html>
|
|||||||
queueTotal: 0,
|
queueTotal: 0,
|
||||||
queueRequestId: 0,
|
queueRequestId: 0,
|
||||||
queueTimer: null,
|
queueTimer: null,
|
||||||
collapsedJobs: {}
|
collapsedJobs: {},
|
||||||
|
jobCards: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -382,11 +383,87 @@ QUEUE_HTML = r"""<!doctype html>
|
|||||||
state.collapsedJobs[jobId] = collapsed;
|
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 = `
|
||||||
|
<div class="job-head">
|
||||||
|
<button class="job-toggle ghost" type="button" aria-label="Toggle job details" aria-expanded="true"></button>
|
||||||
|
<div>
|
||||||
|
<div class="job-title"></div>
|
||||||
|
<p class="muted"></p>
|
||||||
|
</div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="job-body">
|
||||||
|
<pre></pre>
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="muted"></span>
|
||||||
|
<div class="row" style="flex:0 0 auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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) {
|
function renderQueue(data) {
|
||||||
const jobs = data.jobs || [];
|
const jobs = data.jobs || [];
|
||||||
state.queuePage = data.page || 1;
|
state.queuePage = data.page || 1;
|
||||||
state.queuePages = data.pages || 1;
|
state.queuePages = data.pages || 1;
|
||||||
state.queueTotal = data.total || 0;
|
state.queueTotal = data.total || 0;
|
||||||
|
state.jobCards = {};
|
||||||
const el = $("queue");
|
const el = $("queue");
|
||||||
el.innerHTML = "";
|
el.innerHTML = "";
|
||||||
if (!jobs.length) {
|
if (!jobs.length) {
|
||||||
@@ -395,58 +472,7 @@ QUEUE_HTML = r"""<!doctype html>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
const item = document.createElement("article");
|
el.appendChild(createJobCard(job));
|
||||||
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>
|
|
||||||
<div>
|
|
||||||
<div class="job-title"></div>
|
|
||||||
<p class="muted"></p>
|
|
||||||
</div>
|
|
||||||
<span class="status"></span>
|
|
||||||
</div>
|
|
||||||
<div class="job-body">
|
|
||||||
<pre></pre>
|
|
||||||
<div class="toolbar">
|
|
||||||
<span class="muted"></span>
|
|
||||||
<div class="row" style="flex:0 0 auto"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
renderQueuePager();
|
renderQueuePager();
|
||||||
}
|
}
|
||||||
@@ -501,13 +527,31 @@ QUEUE_HTML = r"""<!doctype html>
|
|||||||
return data;
|
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"));
|
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs"));
|
||||||
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
|
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
|
||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
try {
|
try {
|
||||||
await loadQueue();
|
await loadQueue();
|
||||||
state.queueTimer = startSerialPoll(() => loadQueue(), 2500);
|
state.queueTimer = startSerialPoll(() => pollVisibleJobs(), 2500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNotice(error.message);
|
setNotice(error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-1
@@ -259,6 +259,54 @@ class DownloadQueueCancelTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(saved), 1)
|
self.assertEqual(len(saved), 1)
|
||||||
self.assertEqual(saved[0][-1], "line flush")
|
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):
|
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)
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
||||||
job = {
|
job = {
|
||||||
@@ -1707,7 +1755,7 @@ class TemplateHelperTests(unittest.TestCase):
|
|||||||
def test_pages_share_serial_polling_helper(self):
|
def test_pages_share_serial_polling_helper(self):
|
||||||
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML)
|
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML)
|
||||||
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_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("startSerialPoll(async () => {", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("window.clearTimeout(timer);", APP.QUEUE_HTML)
|
self.assertIn("window.clearTimeout(timer);", APP.QUEUE_HTML)
|
||||||
self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.QUEUE_HTML)
|
self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.QUEUE_HTML)
|
||||||
|
|||||||
Reference in New Issue
Block a user