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 # 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 ## 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`. - 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. Small local web UI for a system-wide `ani-cli` install.
Current version: `0.45.6` Current version: `0.45.7`
## Project Layout ## Project Layout
@@ -162,7 +162,7 @@ Use it to:
1. Monitor active, pending, failed, and finished download jobs. 1. Monitor active, pending, failed, and finished download jobs.
2. Inspect recent per-job logs with newest lines first. 2. Inspect recent per-job logs with newest lines first.
3. Retry failed jobs. 3. Retry failed or canceled jobs.
4. Remove finished jobs. 4. Remove finished jobs.
5. Cancel running or pending 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. - 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. - 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. - Dedicated queue pagination and cleanup controls.
- Search and queue workflows are now separated into distinct pages. - 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. - 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. - 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. - 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: Discord notification events:
- Download completed. - 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: if not query:
raise ValueError("Watchlist item is missing a searchable title.") 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) episodes = episode_list(item["show_id"], normalized_mode)
if not episodes: if not episodes:
raise ValueError(f"No {normalized_mode} episodes are currently available for {item['title']}.") 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 = { payload = {
"show_id": item["show_id"], "show_id": item["show_id"],
"query": match.get("query") or query, "query": query,
"title": match.get("title") or item["title"], "title": item["title"],
"anime_name": item.get("auto_download_name") or item["title"], "anime_name": item.get("auto_download_name") or item["title"],
"media_type": item.get("media_type") or "tv", "media_type": item.get("media_type") or "tv",
"season": item.get("auto_download_series") or "1", "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() query = str(item.get("title") or item.get("show_id") or "").strip()
if not query: if not query:
return {"queued": False, "reason": "missing_title"} 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( anime_name = sanitize_path_component(
item.get("auto_download_name") or item.get("title") or match.get("title") or "Anime", item.get("auto_download_name") or item.get("title") or "Anime",
sanitize_path_component(item.get("title") or match.get("title") or "Anime", "Anime"), sanitize_path_component(item.get("title") or "Anime", "Anime"),
) )
series = normalize_season(item.get("auto_download_series") or "1") series = normalize_season(item.get("auto_download_series") or "1")
episode_offset = normalize_episode_offset(item.get("auto_download_offset")) 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( job = runtime["download_queue"].add(
{ {
"show_id": item["show_id"], "show_id": item["show_id"],
"query": match.get("query") or query, "query": query,
"title": match.get("title") or item["title"], "title": item["title"],
"anime_name": anime_name, "anime_name": anime_name,
"media_type": item.get("media_type") or "tv", "media_type": item.get("media_type") or "tv",
"season": series, "season": series,
+2 -14
View File
@@ -20,7 +20,7 @@ from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web" APP_NAME = "ani-cli-web"
VERSION = "0.45.6" VERSION = "0.45.7"
ALLANIME_BASE = "allanime.day" ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to" 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) anime_name = sanitize_path_component(payload.get("anime_name") or title)
if not query: if not query:
raise ValueError("Missing search 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() mode = str(payload.get("mode") or config["mode"]).lower()
quality = str(payload.get("quality") or config["quality"]).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")), "season": normalize_season(payload.get("season")),
"episode_offset": normalize_episode_offset(payload.get("episode_offset")), "episode_offset": normalize_episode_offset(payload.get("episode_offset")),
"query": query, "query": query,
"result_index": index, "result_index": None,
"mode": mode, "mode": mode,
"quality": quality, "quality": quality,
"episodes": validate_episode_spec(payload.get("episodes")), "episodes": validate_episode_spec(payload.get("episodes")),
@@ -995,8 +985,6 @@ def command_for_job(job):
"-e", "-e",
job["episodes"], job["episodes"],
] ]
if job.get("result_index"):
command.extend(["-S", str(job["result_index"])])
if job["mode"] == "dub": if job["mode"] == "dub":
command.append("--dub") command.append("--dub")
command.append(job["query"]) command.append(job["query"])
+3
View File
@@ -876,12 +876,15 @@ class DownloadQueue:
job = self._find(job_id) job = self._find(job_id)
if job["status"] == "running": if job["status"] == "running":
raise ValueError("Running jobs cannot be retried") 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["status"] = "pending"
job["exit_code"] = None job["exit_code"] = None
job["pid"] = None job["pid"] = None
job["started_at"] = None job["started_at"] = None
job["finished_at"] = None job["finished_at"] = None
job["updated_at"] = now_iso() job["updated_at"] = now_iso()
job["cancel_requested"] = False
job["log"] = [] job["log"] = []
self._save_job_locked(job) self._save_job_locked(job)
self.wakeup.set() self.wakeup.set()
+3 -1
View File
@@ -405,7 +405,9 @@ QUEUE_HTML = r"""<!doctype html>
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger")); actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
return; 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"))); 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_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["id"], created["id"]) 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): 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) queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
job = { job = {
@@ -1326,6 +1371,19 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertNotIn("-S", APP.app_support.command_for_job(job)) 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): def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self):
item = { item = {
"show_id": "show-42", "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"} 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( 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 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( ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "12"]):
APP, "episode_list", return_value=["1", "2", "12"]
):
runtime = APP.ensure_runtime() runtime = APP.ensure_runtime()
runtime["download_queue"].add.return_value = job runtime["download_queue"].add.return_value = job
result = APP.download_watchlist_item("show-42", "dub") result = APP.download_watchlist_item("show-42", "dub")
@@ -1362,6 +1418,30 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(result["job"], job) self.assertEqual(result["job"], job)
self.assertIn("Queued Queue Show for dub download.", result["message"]) 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): class JellyfinSyncTests(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -2048,7 +2128,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
APP, APP,
"search_anime", "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") 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( with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
APP, APP,
"search_anime", "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") 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( with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
APP, APP,
"search_anime", "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") result = APP.queue_watchlist_auto_download(item, refresh_source="manual")
@@ -2177,7 +2257,6 @@ class AutoDownloadQueueTests(unittest.TestCase):
} }
) )
class StartupBehaviorTests(unittest.TestCase): class StartupBehaviorTests(unittest.TestCase):
def test_main_does_not_eagerly_initialize_runtime(self): def test_main_does_not_eagerly_initialize_runtime(self):
server = mock.Mock() server = mock.Mock()