Add clear failed queue action

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.
This commit is contained in:
Dymas
2026-07-09 17:20:55 +02:00
parent 0257924785
commit 292bbedd6b
7 changed files with 88 additions and 3 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.48.0 - 2026-07-09
- Added a `Clear failed` bulk action to the Queue page so failed jobs can be removed without touching pending, running, finished, or canceled downloads.
## 0.47.1 - 2026-07-09 ## 0.47.1 - 2026-07-09
- Fixed `anipy-cli` fallback renaming for queued TV downloads so retried jobs now keep the requested episode numbers from the queue, avoiding cases like a retried `S02E11` being renamed as `S02E01`. - Fixed `anipy-cli` fallback renaming for queued TV downloads so retried jobs now keep the requested episode numbers from the queue, avoiding cases like a retried `S02E11` being renamed as `S02E01`.
+2 -2
View File
@@ -2,7 +2,7 @@
Local web UI for a system-wide `ani-cli` install. Local web UI for a system-wide `ani-cli` install.
Current version: `0.47.1` Current version: `0.48.0`
## What it does ## What it does
@@ -19,7 +19,7 @@ Current version: `0.47.1`
## Pages ## Pages
- `/`: Search and queue downloads, with poster-card search results below the selection box and alternate English titles under the main name when available - `/`: Search and queue downloads, with poster-card search results below the selection box and alternate English titles under the main name when available
- `/queue`: Monitor jobs, inspect logs, retry failed jobs, remove finished jobs - `/queue`: Monitor jobs, inspect logs, retry failed jobs, clear failed jobs, and remove finished jobs
- `/watchlist`: Track shows, refresh them, download all available episodes, and manage auto-download settings such as `Source name`, library `Name`, season, and episode offset - `/watchlist`: Track shows, refresh them, download all available episodes, and manage auto-download settings such as `Source name`, library `Name`, season, and episode offset
- `/config`: Save defaults, toggle `anipy-cli` fallback retries, schedule refreshes, configure Jellyfin and Discord, monitor Jellyfin handoff progress, and view runtime info and the changelog - `/config`: Save defaults, toggle `anipy-cli` fallback retries, schedule refreshes, configure Jellyfin and Discord, monitor Jellyfin handoff progress, and view runtime info and the changelog
+1 -1
View File
@@ -1 +1 @@
0.47.1 0.48.0
+3
View File
@@ -704,6 +704,9 @@ class Handler(BaseHTTPRequestHandler):
elif parsed.path == "/api/queue/clear-finished": elif parsed.path == "/api/queue/clear-finished":
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
self.json(runtime["download_queue"].clear_finished()) self.json(runtime["download_queue"].clear_finished())
elif parsed.path == "/api/queue/clear-failed":
runtime = Handler._runtime(self)
self.json(runtime["download_queue"].clear_failed())
elif parsed.path.startswith("/api/queue/"): elif parsed.path.startswith("/api/queue/"):
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
parts = parsed.path.strip("/").split("/") parts = parsed.path.strip("/").split("/")
+5
View File
@@ -1291,6 +1291,11 @@ class DownloadQueue:
cursor = conn.execute("DELETE FROM jobs WHERE status IN ('done', 'canceled')") cursor = conn.execute("DELETE FROM jobs WHERE status IN ('done', 'canceled')")
return {"ok": True, "count": cursor.rowcount} return {"ok": True, "count": cursor.rowcount}
def clear_failed(self):
with self.lock, self._connect() as conn:
cursor = conn.execute("DELETE FROM jobs WHERE status = 'failed'")
return {"ok": True, "count": cursor.rowcount}
def _find(self, job_id): def _find(self, job_id):
with self._connect() as conn: with self._connect() as conn:
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
+2
View File
@@ -317,6 +317,7 @@ QUEUE_HTML = r"""<!doctype html>
<p class="muted">Monitor active downloads, inspect recent output, and clean up finished jobs without leaving the queue view.</p> <p class="muted">Monitor active downloads, inspect recent output, and clean up finished jobs without leaving the queue view.</p>
<div class="row"> <div class="row">
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button> <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> <button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
</div> </div>
</section> </section>
@@ -548,6 +549,7 @@ QUEUE_HTML = r"""<!doctype html>
} }
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished and canceled jobs")); $("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")); $("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
(async function init() { (async function init() {
+71
View File
@@ -466,6 +466,45 @@ class QueueApiTests(unittest.TestCase):
self.assertEqual(retried["status"], "pending") self.assertEqual(retried["status"], "pending")
self.assertFalse(retried["cancel_requested"]) self.assertFalse(retried["cancel_requested"])
def test_clear_failed_removes_only_failed_jobs(self):
failed = self.queue.add(
{
"query": "Failed Show",
"title": "Failed Show",
"anime_name": "Failed Show",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"season": "1",
}
)
done = self.queue.add(
{
"query": "Done Show",
"title": "Done Show",
"anime_name": "Done Show",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"season": "1",
}
)
with self.queue.lock:
failed_job = self.queue._find(failed["id"])
failed_job["status"] = "failed"
self.queue._save_job_locked(failed_job)
done_job = self.queue._find(done["id"])
done_job["status"] = "done"
self.queue._save_job_locked(done_job)
result = self.queue.clear_failed()
listing = self.queue.list()
self.assertEqual(result["count"], 1)
self.assertEqual([job["id"] for job in listing["jobs"]], [done["id"]])
def test_detach_show_clears_queue_coverage_and_sync_binding(self): def test_detach_show_clears_queue_coverage_and_sync_binding(self):
created = self.queue.add( created = self.queue.add(
{ {
@@ -2817,6 +2856,33 @@ class HandlerRouteTests(unittest.TestCase):
self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertIn("anipy-cli", handler.json_payload) self.assertIn("anipy-cli", handler.json_payload)
def test_clear_failed_route_removes_failed_jobs(self):
with APP.DOWNLOAD_QUEUE._connect() as conn:
conn.execute("DELETE FROM jobs")
job = APP.DOWNLOAD_QUEUE.add(
{
"query": "Failed Route Show",
"title": "Failed Route Show",
"anime_name": "Failed Route Show",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"season": "1",
}
)
with APP.DOWNLOAD_QUEUE.lock:
stored = APP.DOWNLOAD_QUEUE._find(job["id"])
stored["status"] = "failed"
APP.DOWNLOAD_QUEUE._save_job_locked(stored)
handler = DummyHandler("/api/queue/clear-failed", body=b"{}", content_length=2)
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["count"], 1)
self.assertEqual(APP.DOWNLOAD_QUEUE.list()["total"], 0)
def test_changelog_route_returns_project_changelog_content(self): def test_changelog_route_returns_project_changelog_content(self):
handler = DummyHandler("/api/changelog") handler = DummyHandler("/api/changelog")
APP.Handler.do_GET(handler) APP.Handler.do_GET(handler)
@@ -3366,6 +3432,11 @@ class TemplateHelperTests(unittest.TestCase):
def test_search_page_sends_watchlist_auto_download_series(self): def test_search_page_sends_watchlist_auto_download_series(self):
self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML) self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML)
def test_queue_page_includes_clear_failed_bulk_action(self):
self.assertIn('id="clearFailedBtn"', APP.QUEUE_HTML)
self.assertIn('/api/queue/clear-failed', APP.QUEUE_HTML)
self.assertIn('Removed failed jobs', APP.QUEUE_HTML)
def test_search_page_queues_selected_title_without_result_index(self): def test_search_page_queues_selected_title_without_result_index(self):
self.assertIn('query: state.selected.title,', APP.INDEX_HTML) self.assertIn('query: state.selected.title,', APP.INDEX_HTML)
self.assertNotIn('index: state.selected.index,', APP.INDEX_HTML) self.assertNotIn('index: state.selected.index,', APP.INDEX_HTML)