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 + + + +
+ + +
+
+
+

Download queue

+

Newest log lines are shown first for each job.

+
+
+
+ +
+
+
+
+ + + + +""" 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""" - -
-
-

Download queue

-
- - -
-
-
-
-
@@ -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",