From 292bbedd6be2915d201730ecca94ab260d86acd8 Mon Sep 17 00:00:00 2001 From: Dymas Date: Thu, 9 Jul 2026 17:20:55 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 4 +++ README.md | 4 +-- VERSION | 2 +- http_handler.py | 3 +++ queue_jobs.py | 5 ++++ queue_page.py | 2 ++ test_app.py | 71 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 88 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12237bb..9265b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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 - 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`. diff --git a/README.md b/README.md index b52ab37..722189c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local web UI for a system-wide `ani-cli` install. -Current version: `0.47.1` +Current version: `0.48.0` ## What it does @@ -19,7 +19,7 @@ Current version: `0.47.1` ## Pages - `/`: 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 - `/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 diff --git a/VERSION b/VERSION index 650298f..a758a09 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.47.1 +0.48.0 diff --git a/http_handler.py b/http_handler.py index 8af15e2..c8d7c85 100644 --- a/http_handler.py +++ b/http_handler.py @@ -704,6 +704,9 @@ class Handler(BaseHTTPRequestHandler): elif parsed.path == "/api/queue/clear-finished": runtime = Handler._runtime(self) 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/"): runtime = Handler._runtime(self) parts = parsed.path.strip("/").split("/") diff --git a/queue_jobs.py b/queue_jobs.py index 7665bf6..1c5beaa 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -1291,6 +1291,11 @@ class DownloadQueue: cursor = conn.execute("DELETE FROM jobs WHERE status IN ('done', 'canceled')") 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): with self._connect() as conn: row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() diff --git a/queue_page.py b/queue_page.py index 36c0129..ae4ee9c 100644 --- a/queue_page.py +++ b/queue_page.py @@ -317,6 +317,7 @@ QUEUE_HTML = r"""

Monitor active downloads, inspect recent output, and clean up finished jobs without leaving the queue view.

+
@@ -548,6 +549,7 @@ QUEUE_HTML = r""" } $("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() { diff --git a/test_app.py b/test_app.py index 526cd08..09e7ef8 100644 --- a/test_app.py +++ b/test_app.py @@ -466,6 +466,45 @@ class QueueApiTests(unittest.TestCase): self.assertEqual(retried["status"], "pending") 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): created = self.queue.add( { @@ -2817,6 +2856,33 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual(handler.json_status, HTTPStatus.OK) 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): handler = DummyHandler("/api/changelog") APP.Handler.do_GET(handler) @@ -3366,6 +3432,11 @@ class TemplateHelperTests(unittest.TestCase): def test_search_page_sends_watchlist_auto_download_series(self): 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): self.assertIn('query: state.selected.title,', APP.INDEX_HTML) self.assertNotIn('index: state.selected.index,', APP.INDEX_HTML)