Harden queue matching and retry behavior
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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"])
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user