diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f44ba2..7f32a61 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 0.35.0 - 2026-05-22
+
+- Split the download queue out of the Search page into a dedicated `/queue` page so searching and queue management are separate workflows.
+- Added a Queue navigation tab and moved queue polling, logs, paging, retry, cancel, and finished-job cleanup controls onto the new page.
+
## 0.34.0 - 2026-05-21
- Added a Discord webhook notification event for completed downloads so successful queue jobs can report the resolved anime title, saved files, and cached thumbnail.
diff --git a/README.md b/README.md
index 16e77f3..250ee27 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.34.0`
+Current version: `0.35.0`
## Project Layout
@@ -18,6 +18,7 @@ ani-cli-web/
http_handler.py HTTP routing and access control.
web_templates.py Shared page aggregation.
search_page.py Search page template.
+ queue_page.py Queue page template.
config_page.py Config page template.
watchlist_page.py Watchlist page template.
docker-entrypoint.sh Container startup wrapper.
@@ -143,9 +144,27 @@ Use it to:
Highlights:
-- Live queue view with automatic polling.
-- Queue log shows newest lines first.
-- Search polling avoids overlapping requests and stale responses.
+- Search stays focused on finding shows, loading episodes, and queueing new downloads.
+- Added-to-queue notices now point to the dedicated Queue page for progress tracking.
+- Search request guards still avoid stale responses overwriting newer results.
+
+### Queue Page
+
+Path: `/queue`
+
+Use it to:
+
+1. Monitor active, pending, failed, and finished download jobs.
+2. Inspect recent per-job logs with newest lines first.
+3. Retry failed jobs.
+4. Remove finished jobs.
+5. Cancel running or pending jobs.
+
+Highlights:
+
+- Automatic queue polling every 2.5 seconds.
+- Dedicated queue pagination and cleanup controls.
+- Search and queue workflows are now separated into distinct pages.
### Config Page
@@ -256,7 +275,9 @@ The app stores the token in an HTTP-only cookie and redirects to the clean URL.
## Download Queue
-The queue is stored in SQLite and shown 10 jobs at a time.
+Path: `/queue`
+
+The queue is stored in SQLite, shown 10 jobs at a time, and managed from its dedicated page.
Available actions:
diff --git a/VERSION b/VERSION
index 85e60ed..7b52f5e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.34.0
+0.35.0
diff --git a/app.py b/app.py
index a13760a..47cf74e 100644
--- a/app.py
+++ b/app.py
@@ -67,7 +67,7 @@ from title_matching import (
title_match_variants,
)
from watchlist_identity import resolve_job_watchlist_identity as resolve_job_watchlist_identity_impl
-from web_templates import CONFIG_HTML, INDEX_HTML, PAGE_LINKS, WATCHLIST_HTML, render_page_links, render_sidebar_brand
+from web_templates import CONFIG_HTML, INDEX_HTML, PAGE_LINKS, QUEUE_HTML, WATCHLIST_HTML, render_page_links, render_sidebar_brand
RUNTIME_LOCK = threading.RLock()
@@ -1975,6 +1975,7 @@ Handler = build_handler_class(
get_watchlist_refresh_status=get_watchlist_refresh_status,
http_error=HttpError,
index_html=INDEX_HTML,
+ queue_html=QUEUE_HTML,
remove_from_watchlist=remove_from_watchlist,
runtime_state=runtime_state,
save_runtime_config=save_runtime_config,
diff --git a/app_support.py b/app_support.py
index 28da342..3815b84 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.34.0"
+VERSION = "0.35.0"
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 3bc25dd..b28eeb4 100644
--- a/http_handler.py
+++ b/http_handler.py
@@ -42,6 +42,7 @@ class HandlerContext:
get_watchlist_refresh_status: object
http_error: object
index_html: str
+ queue_html: str
remove_from_watchlist: object
runtime_state: object
save_runtime_config: object
@@ -211,6 +212,8 @@ class Handler(BaseHTTPRequestHandler):
self.ensure_client_access()
if parsed.path == "/":
self.html(Handler._context(self).index_html)
+ elif parsed.path == "/queue":
+ self.html(Handler._context(self).queue_html)
elif parsed.path == "/config":
self.html(Handler._context(self).config_html)
elif parsed.path == "/watchlist":
diff --git a/queue_page.py b/queue_page.py
new file mode 100644
index 0000000..f588fb9
--- /dev/null
+++ b/queue_page.py
@@ -0,0 +1,465 @@
+#!/usr/bin/env python3
+
+"""Queue page template for ani-cli-web."""
+
+from template_helpers import BROWSER_API_HELPER, BROWSER_POLL_HELPER, render_page_links, render_sidebar_brand
+
+
+QUEUE_HTML = r"""
+
+
+
+
+ ani-cli web queue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
diff --git a/search_page.py b/search_page.py
index 6120945..8a607db 100644
--- a/search_page.py
+++ b/search_page.py
@@ -2,7 +2,7 @@
"""Search page template for ani-cli-web."""
-from template_helpers import BROWSER_API_HELPER, BROWSER_POLL_HELPER, render_page_links, render_sidebar_brand
+from template_helpers import BROWSER_API_HELPER, render_page_links, render_sidebar_brand
INDEX_HTML = r"""
@@ -232,12 +232,12 @@ INDEX_HTML = r"""
color: var(--text);
font-weight: 700;
}
- .results, .queue {
+ .results {
display: grid;
gap: 10px;
min-width: 0;
}
- .result, .job, .settings, .episodes-panel, .deps {
+ .result, .settings, .episodes-panel, .deps {
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
@@ -291,57 +291,6 @@ INDEX_HTML = r"""
}
.badge.ok { color: var(--good); border-color: rgba(149, 228, 186, 0.24); }
.badge.bad { color: #ffd0da; border-color: rgba(255, 144, 164, 0.22); }
- .job {
- padding: 18px;
- display: grid;
- gap: 14px;
- transition: border-color 0.16s ease, transform 0.16s ease;
- }
- .job:hover {
- border-color: var(--line-strong);
- transform: translateY(-1px);
- }
- .job-head {
- display: grid;
- grid-template-columns: 1fr auto;
- gap: 12px;
- align-items: start;
- min-width: 0;
- }
- .job-title {
- font-weight: 800;
- overflow-wrap: anywhere;
- }
- .status {
- border-radius: 999px;
- padding: 6px 10px;
- font-size: 12px;
- font-weight: 800;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- background: rgba(255, 255, 255, 0.06);
- color: var(--muted);
- }
- .status.running {
- color: #fcfaff;
- background: linear-gradient(135deg, var(--accent), var(--accent-strong));
- }
- .status.done { color: #082312; background: var(--good); }
- .status.failed { color: #2d0910; background: var(--bad); }
- .status.canceled { color: #2d1c00; background: var(--warn); }
- pre {
- margin: 0;
- max-height: 180px;
- overflow: auto;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- padding: 12px;
- border-radius: 16px;
- border: 1px solid rgba(255, 255, 255, 0.05);
- background: rgba(7, 5, 12, 0.86);
- color: #d6d0e7;
- font-size: 12px;
- }
.empty {
border: 1px dashed rgba(157, 123, 255, 0.22);
border-radius: 22px;
@@ -364,7 +313,6 @@ INDEX_HTML = r"""
.app { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); }
.controls, .row, .toolbar { align-items: stretch; flex-direction: column; }
- .job-head { grid-template-columns: 1fr; }
}
@@ -446,18 +394,6 @@ INDEX_HTML = r"""
-
-
@@ -466,17 +402,12 @@ INDEX_HTML = r"""
config: { mode: "sub", quality: "best", download_dir: "" },
selected: null,
episodes: [],
- queuePage: 1,
- queuePages: 1,
- queueTotal: 0,
- queueTimer: null,
- queueRequestId: 0,
searchRequestId: 0,
episodeRequestId: 0
};
const $ = (id) => document.getElementById(id);
-""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r"""
+""" + BROWSER_API_HELPER + r"""
function setNotice(text) {
const notice = $("notice");
@@ -610,128 +541,12 @@ INDEX_HTML = r"""
};
try {
await api("/api/queue", { method: "POST", body: JSON.stringify(payload) });
- setNotice("Added to queue.");
- await loadQueue();
+ setNotice("Added to queue. Open Queue to monitor progress.");
} catch (error) {
setNotice(error.message);
}
}
- 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 = 'Queue is empty.
';
- renderQueuePager();
- return;
- }
- for (const job of jobs) {
- const item = document.createElement("article");
- item.className = "job";
- const log = [...(job.log || [])].slice(-28).reverse().join("\n");
- item.innerHTML = `
-
-
-
- `;
- 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();
- }
-
- function renderQueuePager() {
- const el = $("queuePager");
- const start = state.queueTotal ? ((state.queuePage - 1) * 10) + 1 : 0;
- const end = Math.min(state.queuePage * 10, state.queueTotal);
- el.innerHTML = `
-
-
- `;
- el.querySelector(".muted").textContent =
- `${start}-${end} of ${state.queueTotal} · page ${state.queuePage} of ${state.queuePages}`;
- const controls = el.querySelector(".row");
- const prev = actionButton("Previous", () => {
- if (state.queuePage > 1) loadQueue(state.queuePage - 1);
- });
- const next = actionButton("Next", () => {
- if (state.queuePage < state.queuePages) loadQueue(state.queuePage + 1);
- });
- prev.disabled = state.queuePage <= 1;
- next.disabled = state.queuePage >= state.queuePages;
- controls.append(prev, next);
- }
-
- function actionButton(text, handler, extra = "") {
- const btn = document.createElement("button");
- btn.type = "button";
- btn.textContent = text;
- btn.className = extra;
- btn.addEventListener("click", handler);
- return btn;
- }
-
- async function queueAction(id, action) {
- try {
- await api(`/api/queue/${id}/${action}`, { method: "POST", body: "{}" });
- await loadQueue(state.queuePage);
- } catch (error) {
- setNotice(error.message);
- }
- }
-
- async function queueBulkAction(path, message) {
- try {
- const data = await api(path, { method: "POST", body: "{}" });
- setNotice(`${message}: ${data.count || 0}`);
- await loadQueue(state.queuePage);
- } catch (error) {
- setNotice(error.message);
- }
- }
-
- async function loadQueue(page = state.queuePage || 1) {
- const requestId = ++state.queueRequestId;
- const data = await api(`/api/queue?page=${page}&per_page=10`);
- if (requestId !== state.queueRequestId) return data;
- if (data.page > data.pages && data.pages > 0) return loadQueue(data.pages);
- renderQueue(data);
- return data;
- }
-
async function loadConfig() {
state.config = await api("/api/config");
$("jobFolder").value = state.config.download_dir;
@@ -746,8 +561,6 @@ INDEX_HTML = r"""
$("search-mode").addEventListener("change", (event) => setMode(event.target.value));
$("trackBtn").addEventListener("click", addSelectedToWatchlist);
$("addBtn").addEventListener("click", addSelected);
- $("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs"));
- $("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
$("search-quality").addEventListener("change", () => { state.config.quality = $("search-quality").value; });
$("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; });
$("latestEpBtn").addEventListener("click", () => {
@@ -765,9 +578,7 @@ INDEX_HTML = r"""
const query = (params.get("query") || "").trim();
if (query) $("search-query").value = query;
await loadConfig();
- await loadQueue();
if (query) await search();
- state.queueTimer = startSerialPoll(() => loadQueue(), 2500);
} catch (error) {
setNotice(error.message);
}
diff --git a/template_helpers.py b/template_helpers.py
index a1f08ec..e77c274 100644
--- a/template_helpers.py
+++ b/template_helpers.py
@@ -4,6 +4,7 @@
PAGE_LINKS = (
("search", "Search", "/"),
+ ("queue", "Queue", "/queue"),
("config", "Config", "/config"),
("watchlist", "Watchlist", "/watchlist"),
)
diff --git a/test_app.py b/test_app.py
index 0d0f15e..c6f6e6d 100644
--- a/test_app.py
+++ b/test_app.py
@@ -1610,6 +1610,7 @@ class TemplateHelperTests(unittest.TestCase):
def test_page_links_marks_active_page(self):
markup = APP.render_page_links("config")
self.assertIn('class="page-link active" href="/config"', markup)
+ self.assertIn('class="page-link" href="/queue"', markup)
self.assertIn('class="page-link" href="/watchlist"', markup)
def test_sidebar_brand_can_attach_optional_badge_id(self):
@@ -1617,25 +1618,26 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn('id="serverBadge"', markup)
self.assertIn("Shared note", markup)
- def test_search_page_queue_loader_guards_against_stale_responses(self):
- self.assertIn("queueRequestId", APP.INDEX_HTML)
- self.assertIn("if (requestId !== state.queueRequestId) return data;", APP.INDEX_HTML)
+ def test_queue_page_queue_loader_guards_against_stale_responses(self):
+ self.assertIn("queueRequestId", APP.QUEUE_HTML)
+ self.assertIn("if (requestId !== state.queueRequestId) return data;", APP.QUEUE_HTML)
- def test_search_page_renders_queue_logs_newest_first(self):
- self.assertIn('[...(job.log || [])].slice(-28).reverse().join("\\n")', APP.INDEX_HTML)
+ def test_queue_page_renders_queue_logs_newest_first(self):
+ self.assertIn('[...(job.log || [])].slice(-28).reverse().join("\\n")', APP.QUEUE_HTML)
def test_pages_share_browser_api_helper(self):
self.assertIn("async function api(path, options = {})", APP.INDEX_HTML)
+ self.assertIn("async function api(path, options = {})", APP.QUEUE_HTML)
self.assertIn("async function api(path, options = {})", APP.CONFIG_HTML)
self.assertIn("async function api(path, options = {})", APP.WATCHLIST_HTML)
def test_pages_share_serial_polling_helper(self):
- self.assertIn("function startSerialPoll(callback, intervalMs)", APP.INDEX_HTML)
+ self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML)
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)
- self.assertIn("startSerialPoll(() => loadQueue(), 2500)", APP.INDEX_HTML)
+ self.assertIn("startSerialPoll(() => loadQueue(), 2500)", APP.QUEUE_HTML)
self.assertIn("startSerialPoll(async () => {", APP.WATCHLIST_HTML)
- self.assertIn("window.clearTimeout(timer);", APP.INDEX_HTML)
- self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.INDEX_HTML)
+ self.assertIn("window.clearTimeout(timer);", APP.QUEUE_HTML)
+ self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.QUEUE_HTML)
self.assertIn('window.addEventListener("beforeunload", stop, { once: true });', APP.WATCHLIST_HTML)
def test_watchlist_page_polls_when_queued_items_exist(self):
diff --git a/web_templates.py b/web_templates.py
index f5b74f8..4e433f1 100644
--- a/web_templates.py
+++ b/web_templates.py
@@ -3,6 +3,7 @@
"""Aggregated template exports for ani-cli-web."""
from config_page import CONFIG_HTML
+from queue_page import QUEUE_HTML
from search_page import INDEX_HTML
from template_helpers import PAGE_LINKS, render_page_links, render_sidebar_brand
from watchlist_page import WATCHLIST_HTML
@@ -11,6 +12,7 @@ __all__ = [
"CONFIG_HTML",
"INDEX_HTML",
"PAGE_LINKS",
+ "QUEUE_HTML",
"WATCHLIST_HTML",
"render_page_links",
"render_sidebar_brand",