Harden queue matching and retry behavior

This commit is contained in:
Dymas
2026-05-26 00:02:02 +02:00
parent 1ceb773f99
commit b72d93268c
8 changed files with 114 additions and 40 deletions
+6
View File
@@ -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`.
+7 -2
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.45.6
0.45.7
+6 -15
View File
@@ -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,
+2 -14
View File
@@ -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"])
+3
View File
@@ -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()
+2
View File
@@ -405,7 +405,9 @@ QUEUE_HTML = r"""<!doctype html>
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")));
}
+86 -7
View File
@@ -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()