diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b63087..fe98598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.45.7 - 2026-05-25 + +- Removed the remaining backend `ani-cli -S` dependency so queued downloads no longer trust caller-supplied search result ordinals, including older persisted jobs that still carry a legacy `result_index`. +- Simplified watchlist `Download all` and auto-download queueing to use the stored watchlist `show_id` plus direct episode lookups, avoiding unnecessary `search_anime()` match failures when provider search results drift. +- Restricted queue retries to failed or canceled jobs, which prevents finished downloads from being retried and accidentally redownloading episodes or resending completion side effects. + ## 0.45.6 - 2026-05-25 - Hardened Search page queueing so explicit downloads now submit the selected anime title directly instead of replaying a fragile web-result ordinal as `ani-cli -S`, which avoids cross-search mismatches between the API and `ani-cli`. diff --git a/README.md b/README.md index 4bb6c39..34c81ed 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.45.6` +Current version: `0.45.7` ## Project Layout @@ -162,7 +162,7 @@ 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. +3. Retry failed or canceled jobs. 4. Remove finished jobs. 5. Cancel running or pending jobs. @@ -170,6 +170,7 @@ Highlights: - The queue list loads once, then only visible pending or running jobs are refreshed every 2.5 seconds. - Terminal jobs (`done`, `failed`, `canceled`) stop log polling automatically. +- Retry actions are only shown for `failed` and `canceled` jobs, so finished downloads cannot be re-queued accidentally from the UI. - Dedicated queue pagination and cleanup controls. - Search and queue workflows are now separated into distinct pages. - Each queue job card can be collapsed, and finished jobs start collapsed by default to keep the page compact. @@ -210,6 +211,10 @@ Jellyfin handoff: - Existing Jellyfin targets are only treated as already moved when the destination library still contains the expected media files. - The manual `Run now` action on `/config` scans the whole watchlist and moves anything already eligible. +Watchlist queueing: + +- Manual `Download all` and automatic watchlist downloads now queue directly from the stored watchlist title and `show_id`, so they no longer fail just because a fresh provider search returns a shifted or incomplete result list. + Discord notification events: - Download completed. diff --git a/VERSION b/VERSION index 830a435..68016cd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.6 +0.45.7 diff --git a/app.py b/app.py index 89c8f47..4da441c 100644 --- a/app.py +++ b/app.py @@ -2328,11 +2328,6 @@ def download_watchlist_item(show_id, mode): if not query: raise ValueError("Watchlist item is missing a searchable title.") - results = search_anime(query, normalized_mode) - match = next((candidate for candidate in results if str(candidate.get("id") or "").strip() == item["show_id"]), None) - if match is None: - raise ValueError(f"Could not match {item['title']} in {normalized_mode} search results.") - episodes = episode_list(item["show_id"], normalized_mode) if not episodes: raise ValueError(f"No {normalized_mode} episodes are currently available for {item['title']}.") @@ -2340,8 +2335,8 @@ def download_watchlist_item(show_id, mode): payload = { "show_id": item["show_id"], - "query": match.get("query") or query, - "title": match.get("title") or item["title"], + "query": query, + "title": item["title"], "anime_name": item.get("auto_download_name") or item["title"], "media_type": item.get("media_type") or "tv", "season": item.get("auto_download_series") or "1", @@ -2392,14 +2387,10 @@ def queue_watchlist_auto_download(item, refresh_source="manual"): query = str(item.get("title") or item.get("show_id") or "").strip() if not query: return {"queued": False, "reason": "missing_title"} - results = search_anime(query, mode) - match = next((candidate for candidate in results if str(candidate.get("id") or "").strip() == str(item.get("show_id") or "").strip()), None) - if match is None: - return {"queued": False, "reason": f"search_match_missing:{mode}"} anime_name = sanitize_path_component( - item.get("auto_download_name") or item.get("title") or match.get("title") or "Anime", - sanitize_path_component(item.get("title") or match.get("title") or "Anime", "Anime"), + item.get("auto_download_name") or item.get("title") or "Anime", + sanitize_path_component(item.get("title") or "Anime", "Anime"), ) series = normalize_season(item.get("auto_download_series") or "1") episode_offset = normalize_episode_offset(item.get("auto_download_offset")) @@ -2408,8 +2399,8 @@ def queue_watchlist_auto_download(item, refresh_source="manual"): job = runtime["download_queue"].add( { "show_id": item["show_id"], - "query": match.get("query") or query, - "title": match.get("title") or item["title"], + "query": query, + "title": item["title"], "anime_name": anime_name, "media_type": item.get("media_type") or "tv", "season": series, diff --git a/app_support.py b/app_support.py index 24cd983..8ca0798 100644 --- a/app_support.py +++ b/app_support.py @@ -20,7 +20,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.45.6" +VERSION = "0.45.7" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" @@ -941,16 +941,6 @@ def build_job(payload, config): anime_name = sanitize_path_component(payload.get("anime_name") or title) if not query: raise ValueError("Missing search query") - raw_index = payload.get("index") - if raw_index in (None, ""): - index = None - else: - try: - index = int(raw_index) - except (TypeError, ValueError) as exc: - raise ValueError("Invalid result index") from exc - if index < 1: - raise ValueError("Result index must be positive") mode = str(payload.get("mode") or config["mode"]).lower() quality = str(payload.get("quality") or config["quality"]).lower() @@ -970,7 +960,7 @@ def build_job(payload, config): "season": normalize_season(payload.get("season")), "episode_offset": normalize_episode_offset(payload.get("episode_offset")), "query": query, - "result_index": index, + "result_index": None, "mode": mode, "quality": quality, "episodes": validate_episode_spec(payload.get("episodes")), @@ -995,8 +985,6 @@ def command_for_job(job): "-e", job["episodes"], ] - if job.get("result_index"): - command.extend(["-S", str(job["result_index"])]) if job["mode"] == "dub": command.append("--dub") command.append(job["query"]) diff --git a/queue_jobs.py b/queue_jobs.py index d4c748f..d9c706e 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -876,12 +876,15 @@ class DownloadQueue: job = self._find(job_id) if job["status"] == "running": 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) self.wakeup.set() diff --git a/queue_page.py b/queue_page.py index f8d1730..83c0631 100644 --- a/queue_page.py +++ b/queue_page.py @@ -405,7 +405,9 @@ QUEUE_HTML = r""" actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger")); return; } - actions.append(actionButton("Retry", () => queueAction(job.id, "retry"))); + if (job.status === "failed" || job.status === "canceled") { + actions.append(actionButton("Retry", () => queueAction(job.id, "retry"))); + } actions.append(actionButton("Remove", () => queueAction(job.id, "remove"))); } diff --git a/test_app.py b/test_app.py index 6ccb67e..7dec9d2 100644 --- a/test_app.py +++ b/test_app.py @@ -301,6 +301,51 @@ class QueueApiTests(unittest.TestCase): self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_payload["id"], created["id"]) + def test_retry_rejects_done_jobs(self): + created = 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: + job = self.queue._find(created["id"]) + job["status"] = "done" + self.queue._save_job_locked(job) + + with self.assertRaisesRegex(ValueError, "Only failed or canceled jobs can be retried"): + self.queue.retry(created["id"]) + + def test_retry_allows_canceled_jobs(self): + created = self.queue.add( + { + "query": "Canceled Show", + "title": "Canceled Show", + "anime_name": "Canceled Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + with self.queue.lock: + job = self.queue._find(created["id"]) + job["status"] = "canceled" + job["cancel_requested"] = True + self.queue._save_job_locked(job) + + retried = self.queue.retry(created["id"]) + + self.assertEqual(retried["status"], "pending") + self.assertFalse(retried["cancel_requested"]) + def test_save_job_splits_log_and_extra_payload_columns(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) job = { @@ -1326,6 +1371,19 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertNotIn("-S", APP.app_support.command_for_job(job)) + def test_command_for_job_ignores_legacy_result_index(self): + command = APP.app_support.command_for_job( + { + "query": "Queue Show", + "quality": "best", + "episodes": "1-10", + "mode": "dub", + "result_index": 2, + } + ) + + self.assertNotIn("-S", command) + def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self): item = { "show_id": "show-42", @@ -1337,9 +1395,7 @@ class WatchlistCompletionTests(unittest.TestCase): job = {"id": "job-1", "show_id": "show-42", "episodes": "1-12", "mode": "dub"} with mock.patch.object(APP, "ensure_runtime", return_value={"watchlist": APP.WATCHLIST, "download_queue": mock.Mock(), "config": {"quality": "best", "download_dir": "/tmp/downloads"}}), mock.patch.object( APP.WATCHLIST, "get", return_value=item - ), mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show", "query": "Queue Show", "index": 3}]), mock.patch.object( - APP, "episode_list", return_value=["1", "2", "12"] - ): + ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "12"]): runtime = APP.ensure_runtime() runtime["download_queue"].add.return_value = job result = APP.download_watchlist_item("show-42", "dub") @@ -1362,6 +1418,30 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(result["job"], job) self.assertIn("Queued Queue Show for dub download.", result["message"]) + def test_download_watchlist_item_does_not_require_search_match(self): + item = { + "show_id": "show-42", + "title": "Queue Show", + "auto_download_name": "Queue Show", + "auto_download_series": "1", + "media_type": "tv", + } + runtime = { + "watchlist": APP.WATCHLIST, + "download_queue": mock.Mock(), + "config": {"quality": "best", "download_dir": "/tmp/downloads"}, + } + runtime["download_queue"].add.return_value = {"id": "job-1"} + + with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( + APP.WATCHLIST, "get", return_value=item + ), mock.patch.object(APP, "search_anime", side_effect=AssertionError("search should not be required")), mock.patch.object( + APP, "episode_list", return_value=["1", "2"] + ): + result = APP.download_watchlist_item("show-42", "dub") + + self.assertEqual(result["job"]["id"], "job-1") + class JellyfinSyncTests(unittest.TestCase): def setUp(self): @@ -2048,7 +2128,7 @@ class AutoDownloadQueueTests(unittest.TestCase): with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( APP, "search_anime", - return_value=[{"id": "show-42", "title": "Queue Show", "query": "Queue Show", "index": 2}], + side_effect=AssertionError("search should not be required"), ): result = APP.queue_watchlist_auto_download(item, refresh_source="scheduled") @@ -2102,7 +2182,7 @@ class AutoDownloadQueueTests(unittest.TestCase): with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( APP, "search_anime", - return_value=[{"id": "show-99", "title": "Backlog Show", "query": "Backlog Show", "index": 1}], + side_effect=AssertionError("search should not be required"), ): result = APP.queue_watchlist_auto_download(item, refresh_source="manual") @@ -2156,7 +2236,7 @@ class AutoDownloadQueueTests(unittest.TestCase): with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( APP, "search_anime", - return_value=[{"id": "show-77", "title": "Split Season Show", "query": "Split Season Show", "index": 1}], + side_effect=AssertionError("search should not be required"), ): result = APP.queue_watchlist_auto_download(item, refresh_source="manual") @@ -2177,7 +2257,6 @@ class AutoDownloadQueueTests(unittest.TestCase): } ) - class StartupBehaviorTests(unittest.TestCase): def test_main_does_not_eagerly_initialize_runtime(self): server = mock.Mock()