Add a Queue page bulk action and API route to remove all failed jobs, cover it with regression tests, and update the version, changelog, and README for the new queue workflow.
567 lines
17 KiB
Python
567 lines
17 KiB
Python
#!/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"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>ani-cli web queue</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #090611;
|
|
--bg-2: #140d21;
|
|
--panel: rgba(25, 18, 41, 0.82);
|
|
--panel-strong: rgba(18, 13, 31, 0.92);
|
|
--panel-2: rgba(157, 123, 255, 0.12);
|
|
--line: rgba(255, 255, 255, 0.08);
|
|
--line-strong: rgba(157, 123, 255, 0.26);
|
|
--text: #f6f2ff;
|
|
--muted: #b8b0cf;
|
|
--accent: #9d7bff;
|
|
--accent-strong: #7b5cff;
|
|
--accent-soft: rgba(157, 123, 255, 0.14);
|
|
--good: #95e4ba;
|
|
--warn: #f0c67a;
|
|
--bad: #ff90a4;
|
|
--focus: #b296ff;
|
|
--shadow: 0 24px 60px rgba(5, 2, 15, 0.42);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html { scroll-behavior: smooth; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
|
|
radial-gradient(circle at top right, rgba(117, 201, 255, 0.12), transparent 24%),
|
|
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
|
|
color: var(--text);
|
|
font: 15px/1.5 "Segoe UI", Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
}
|
|
body::before {
|
|
content: "";
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
|
|
opacity: 0.5;
|
|
}
|
|
button {
|
|
font: inherit;
|
|
border: 1px solid var(--line);
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
min-height: 40px;
|
|
border-radius: 14px;
|
|
padding: 0 14px;
|
|
cursor: pointer;
|
|
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
|
|
}
|
|
button:hover {
|
|
border-color: var(--line-strong);
|
|
background: rgba(157, 123, 255, 0.18);
|
|
transform: translateY(-1px);
|
|
}
|
|
button.primary {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
|
border-color: transparent;
|
|
color: #fcfaff;
|
|
font-weight: 700;
|
|
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
|
|
}
|
|
button.ghost {
|
|
background: transparent;
|
|
}
|
|
button.danger {
|
|
border-color: rgba(255, 144, 164, 0.3);
|
|
background: rgba(255, 144, 164, 0.12);
|
|
color: #ffd7df;
|
|
}
|
|
button.danger:hover {
|
|
border-color: rgba(255, 144, 164, 0.48);
|
|
background: rgba(255, 144, 164, 0.18);
|
|
}
|
|
.app {
|
|
display: grid;
|
|
grid-template-columns: minmax(320px, 380px) 1fr;
|
|
min-height: 100vh;
|
|
}
|
|
aside, main {
|
|
padding: 22px;
|
|
}
|
|
aside {
|
|
border-right: 1px solid var(--line);
|
|
background: linear-gradient(180deg, rgba(25, 18, 41, 0.94), rgba(14, 10, 24, 0.9));
|
|
display: grid;
|
|
align-content: start;
|
|
gap: 20px;
|
|
backdrop-filter: blur(18px) saturate(170%);
|
|
}
|
|
main {
|
|
display: grid;
|
|
grid-template-rows: auto auto 1fr;
|
|
gap: 20px;
|
|
min-width: 0;
|
|
}
|
|
h1, h2, h3, p { margin: 0; }
|
|
h1 {
|
|
font-size: 29px;
|
|
line-height: 1.05;
|
|
letter-spacing: -0.04em;
|
|
}
|
|
h2 {
|
|
font-size: 17px;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.topline, .row, .toolbar {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
.topline, .toolbar { justify-content: space-between; }
|
|
.brand-wrap {
|
|
display: grid;
|
|
gap: 6px;
|
|
}
|
|
.brand-word {
|
|
color: var(--accent);
|
|
}
|
|
.shell-note {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
max-width: 28ch;
|
|
}
|
|
.row > * { flex: 1; }
|
|
.page-links {
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 6px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 18px;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
.page-link {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
width: 100%;
|
|
min-height: 40px;
|
|
border: 0;
|
|
border-radius: 12px;
|
|
padding: 0 14px;
|
|
color: var(--muted);
|
|
background: transparent;
|
|
font-weight: 700;
|
|
text-decoration: none;
|
|
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
|
|
}
|
|
.page-link.active {
|
|
background: var(--accent-soft);
|
|
color: var(--text);
|
|
box-shadow: inset 0 0 0 1px rgba(157, 123, 255, 0.22);
|
|
}
|
|
.page-link:hover {
|
|
color: var(--text);
|
|
background: rgba(255, 255, 255, 0.05);
|
|
}
|
|
.queue, .highlights {
|
|
display: grid;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
.panel, .job {
|
|
border: 1px solid var(--line);
|
|
border-radius: 22px;
|
|
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(18px);
|
|
}
|
|
.panel {
|
|
padding: 18px;
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
.muted {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 999px;
|
|
padding: 6px 10px;
|
|
color: var(--muted);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
.job {
|
|
padding: 18px;
|
|
display: grid;
|
|
gap: 14px;
|
|
transition: border-color 0.16s ease, transform 0.16s ease;
|
|
}
|
|
.job.collapsed {
|
|
gap: 0;
|
|
}
|
|
.job:hover {
|
|
border-color: var(--line-strong);
|
|
transform: translateY(-1px);
|
|
}
|
|
.job-head {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto;
|
|
gap: 12px;
|
|
align-items: start;
|
|
min-width: 0;
|
|
}
|
|
.job-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
min-width: 40px;
|
|
padding: 0;
|
|
border-radius: 12px;
|
|
font-size: 18px;
|
|
line-height: 1;
|
|
}
|
|
.job-toggle::before {
|
|
content: "▾";
|
|
}
|
|
.job.collapsed .job-toggle::before {
|
|
content: "▸";
|
|
}
|
|
.job-title {
|
|
font-weight: 800;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.job-body {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
.job.collapsed .job-body {
|
|
display: none;
|
|
}
|
|
.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: 220px;
|
|
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;
|
|
color: var(--muted);
|
|
padding: 24px;
|
|
text-align: center;
|
|
background: rgba(157, 123, 255, 0.06);
|
|
}
|
|
.notice {
|
|
min-height: 20px;
|
|
color: #ddd0ff;
|
|
font-size: 13px;
|
|
}
|
|
@media (max-width: 900px) {
|
|
.app { grid-template-columns: 1fr; }
|
|
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
.toolbar, .row { align-items: stretch; flex-direction: column; }
|
|
.job-head { grid-template-columns: auto 1fr; }
|
|
.job-head .status { grid-column: 2; justify-self: start; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<aside>
|
|
""" + render_sidebar_brand(
|
|
"Dedicated queue board for active, pending, and finished downloads.",
|
|
"Queue",
|
|
badge_id="serverBadge",
|
|
) + render_page_links("queue") + r"""
|
|
|
|
<section class="panel">
|
|
<h2>Queue controls</h2>
|
|
<p class="muted">Monitor active downloads, inspect recent output, and clean up finished jobs without leaving the queue view.</p>
|
|
<div class="row">
|
|
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button>
|
|
<button class="ghost" id="clearFailedBtn" type="button">Clear failed</button>
|
|
<button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel highlights">
|
|
<div>
|
|
<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">Active jobs update every 2.5s</span>
|
|
</section>
|
|
</aside>
|
|
|
|
<main>
|
|
<section class="toolbar">
|
|
<div>
|
|
<h2>Download queue</h2>
|
|
<p class="muted">Newest log lines are shown first for each job.</p>
|
|
</div>
|
|
<div class="notice" id="notice"></div>
|
|
</section>
|
|
|
|
<section class="queue" id="queue"></section>
|
|
<section class="toolbar" id="queuePager"></section>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
const state = {
|
|
queuePage: 1,
|
|
queuePages: 1,
|
|
queueTotal: 0,
|
|
queueRequestId: 0,
|
|
queueTimer: null,
|
|
collapsedJobs: {},
|
|
jobCards: {}
|
|
};
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r"""
|
|
|
|
function setNotice(text) {
|
|
const notice = $("notice");
|
|
if (notice) notice.textContent = text || "";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function isCollapsed(job) {
|
|
if (Object.prototype.hasOwnProperty.call(state.collapsedJobs, job.id)) {
|
|
return state.collapsedJobs[job.id];
|
|
}
|
|
return job.status === "done";
|
|
}
|
|
|
|
function setCollapsed(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;
|
|
}
|
|
if (job.status === "failed" || job.status === "canceled") {
|
|
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) {
|
|
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();
|
|
}
|
|
|
|
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 = `
|
|
<span class="muted"></span>
|
|
<div class="row" style="flex:0 0 auto"></div>
|
|
`;
|
|
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);
|
|
}
|
|
|
|
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 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 and canceled jobs"));
|
|
$("clearFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-failed", "Removed failed jobs"));
|
|
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
|
|
|
|
(async function init() {
|
|
try {
|
|
await loadQueue();
|
|
state.queueTimer = startSerialPoll(() => pollVisibleJobs(), 2500);
|
|
} catch (error) {
|
|
setNotice(error.message);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|