From 34c77ed9bb32c0cb4387f8ac39b49f530a639e5e Mon Sep 17 00:00:00 2001 From: Dymas Date: Thu, 9 Jul 2026 17:38:24 +0200 Subject: [PATCH] Reuse failed queue jobs for duplicate retries --- CHANGELOG.md | 4 +++ README.md | 9 ++++-- VERSION | 2 +- queue_jobs.py | 61 ++++++++++++++++++++++++++++++++++------- test_app.py | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9265b7f..6d08b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.48.1 - 2026-07-09 + +- Changed queue inserts so adding a download that exactly matches an existing failed job now reuses that failed entry and resets it to pending instead of creating another duplicate failed row. + ## 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. diff --git a/README.md b/README.md index 722189c..658c7f7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local web UI for a system-wide `ani-cli` install. -Current version: `0.48.0` +Current version: `0.48.1` ## What it does @@ -19,7 +19,7 @@ Current version: `0.48.0` ## 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, clear failed jobs, and remove finished jobs +- `/queue`: Monitor jobs, inspect logs, retry failed jobs, avoid duplicate re-queues for the same failed download, 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 @@ -119,6 +119,11 @@ Docker notes: - Fallback retries keep the queued episode numbers when the downloaded `anipy-cli` filenames are renumbered from `1` - Docker images install `anipy-cli` automatically; non-Docker installs should provide it in `PATH` themselves +### Queue retries + +- Adding a download that exactly matches an existing failed queue job reuses that failed entry and sets it back to `pending` +- This avoids stacking multiple failed rows for the same episode when you queue the same job again + ### Jellyfin handoff - Optional and configured from `/config` diff --git a/VERSION b/VERSION index a758a09..cffa44c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.48.0 +0.48.1 diff --git a/queue_jobs.py b/queue_jobs.py index 1c5beaa..bff323c 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -1136,6 +1136,51 @@ class DownloadQueue: with self.lock: return self._find(job_id) + def _job_identity_fields(self, job): + return ( + str(job.get("show_id") or "").strip(), + str(job.get("title") or "").strip(), + str(job.get("anime_name") or "").strip(), + str(job.get("media_type") or "").strip(), + str(job.get("season") or "").strip(), + str(job.get("episode_offset") or "").strip(), + str(job.get("query") or "").strip(), + job.get("result_index"), + str(job.get("mode") or "").strip().lower(), + str(job.get("quality") or "").strip().lower(), + str(job.get("episodes") or "").strip(), + str(job.get("download_dir") or "").strip(), + ) + + def _find_reusable_failed_job_locked(self, job): + expected = self._job_identity_fields(job) + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * + FROM jobs + WHERE status = 'failed' + ORDER BY created_at DESC + """ + ).fetchall() + for row in rows: + candidate = self._job_from_row(row) + if self._job_identity_fields(candidate) == expected: + return candidate + return None + + def _reset_retryable_job_locked(self, job): + job["status"] = "pending" + job["exit_code"] = None + job["pid"] = None + job["started_at"] = None + job["finished_at"] = None + job["updated_at"] = now_iso() + job["cancel_requested"] = False + job["log"] = [] + self._save_job_locked(job) + return job + def add(self, payload): job = build_job(payload, self.config_getter()) if self.job_prepare_fn is not None: @@ -1143,7 +1188,11 @@ class DownloadQueue: if prepared is not None: job = prepared with self.lock: - self._save_job_locked(job) + reusable = self._find_reusable_failed_job_locked(job) + if reusable is not None: + job = self._reset_retryable_job_locked(reusable) + else: + self._save_job_locked(job) self.wakeup.set() return job @@ -1221,15 +1270,7 @@ class DownloadQueue: raise ValueError("Running jobs cannot be retried") if job["status"] not in {"failed", "canceled"}: raise ValueError("Only failed or canceled jobs can be retried") - job["status"] = "pending" - job["exit_code"] = None - job["pid"] = None - job["started_at"] = None - job["finished_at"] = None - job["updated_at"] = now_iso() - job["cancel_requested"] = False - job["log"] = [] - self._save_job_locked(job) + job = self._reset_retryable_job_locked(job) self.wakeup.set() return job diff --git a/test_app.py b/test_app.py index 09e7ef8..0ea4dc1 100644 --- a/test_app.py +++ b/test_app.py @@ -466,6 +466,82 @@ class QueueApiTests(unittest.TestCase): self.assertEqual(retried["status"], "pending") self.assertFalse(retried["cancel_requested"]) + def test_add_reuses_matching_failed_job_instead_of_creating_duplicate(self): + payload = { + "show_id": "show-42", + "query": "Failed Show", + "title": "Failed Show", + "anime_name": "Failed Show", + "media_type": "tv", + "mode": "dub", + "quality": "best", + "episodes": "11", + "download_dir": "/tmp/example", + "season": "2", + "episode_offset": "13", + } + created = self.queue.add(payload) + with self.queue.lock: + job = self.queue._find(created["id"]) + job["status"] = "failed" + job["exit_code"] = 1 + job["cancel_requested"] = True + job["started_at"] = APP.now_iso() + job["finished_at"] = APP.now_iso() + job["log"] = ["Download failed"] + self.queue._save_job_locked(job) + + retried = self.queue.add(payload) + listing = self.queue.list(per_page=10) + + self.assertEqual(retried["id"], created["id"]) + self.assertEqual(retried["status"], "pending") + self.assertEqual(retried["log"], []) + self.assertIsNone(retried["exit_code"]) + self.assertFalse(retried["cancel_requested"]) + self.assertEqual(listing["total"], 1) + + def test_add_keeps_new_job_when_failed_job_differs(self): + created = self.queue.add( + { + "show_id": "show-42", + "query": "Failed Show", + "title": "Failed Show", + "anime_name": "Failed Show", + "media_type": "tv", + "mode": "dub", + "quality": "best", + "episodes": "11", + "download_dir": "/tmp/example", + "season": "2", + "episode_offset": "13", + } + ) + with self.queue.lock: + job = self.queue._find(created["id"]) + job["status"] = "failed" + self.queue._save_job_locked(job) + + new_job = self.queue.add( + { + "show_id": "show-42", + "query": "Failed Show", + "title": "Failed Show", + "anime_name": "Failed Show", + "media_type": "tv", + "mode": "dub", + "quality": "720", + "episodes": "11", + "download_dir": "/tmp/example", + "season": "2", + "episode_offset": "13", + } + ) + listing = self.queue.list(per_page=10) + + self.assertNotEqual(new_job["id"], created["id"]) + self.assertEqual(listing["total"], 2) + def test_clear_failed_removes_only_failed_jobs(self): failed = self.queue.add( {