From 91691244d76305208af2231bcde633d96d45744f Mon Sep 17 00:00:00 2001 From: Dymas Date: Sat, 23 May 2026 14:30:03 +0200 Subject: [PATCH] Add watchlist download-all action --- CHANGELOG.md | 4 ++++ README.md | 4 ++-- VERSION | 2 +- app.py | 42 ++++++++++++++++++++++++++++++++++++++++++ http_handler.py | 5 +++++ test_app.py | 43 +++++++++++++++++++++++++++++++++++++++++++ watchlist_page.py | 38 +++++++++++++++++++++++++++++++++----- 7 files changed, 130 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30df754..b6c0743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.36.0 - 2026-05-23 + +- Replaced the watchlist card `Open` action with `Download all`, which prompts for `sub` or `dub` and then queues all currently available episodes directly from the watchlist. + ## 0.35.2 - 2026-05-23 - Made queue job cards collapsible and defaulted finished jobs to collapsed so long queue histories take less space while running and pending jobs stay expanded. diff --git a/README.md b/README.md index 35380ea..61adafe 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.35.2` +Current version: `0.36.0` ## Project Layout @@ -213,7 +213,7 @@ Main behavior: 1. `Refresh` updates one show. 2. `Refresh All` runs in the background. -3. `Open` jumps back to Search. +3. `Download all` prompts for `sub` or `dub` and queues every currently available episode directly. 4. Clicking the title opens the source page. 5. `Remove` deletes the entry and cached poster. 6. Each card has an inline category selector. diff --git a/VERSION b/VERSION index abc2aab..93d4c1e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.35.2 +0.36.0 diff --git a/app.py b/app.py index 47cf74e..82a51e6 100644 --- a/app.py +++ b/app.py @@ -1740,6 +1740,47 @@ def update_watchlist_status(show_id, _mode=None): return {"message": f"Queued refresh for {item['title']}.", "item": item} +def download_watchlist_item(show_id, mode): + runtime = ensure_runtime() + item = runtime["watchlist"].get(show_id) + normalized_mode = str(mode or "").strip().lower() + if normalized_mode not in MODE_CHOICES: + raise ValueError("Mode must be sub or dub") + + query = str(item.get("title") or item.get("show_id") or "").strip() + 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']}.") + episode_spec = episodes[0] if len(episodes) == 1 else f"{episodes[0]}-{episodes[-1]}" + + payload = { + "show_id": item["show_id"], + "query": match.get("query") or query, + "title": match.get("title") or item["title"], + "anime_name": item["title"], + "season": "1", + "index": match.get("index", 1), + "mode": normalized_mode, + "quality": runtime["config"]["quality"], + "episodes": episode_spec, + "download_dir": runtime["config"]["download_dir"], + } + job = runtime["download_queue"].add(payload) + return { + "message": f"Queued {item['title']} for {normalized_mode} download.", + "job": job, + "item": item, + } + + def update_watchlist_category(show_id, category): ensure_runtime() item = WATCHLIST.update_category(show_id, category) @@ -1968,6 +2009,7 @@ Handler = build_handler_class( HandlerContext( add_to_watchlist=add_to_watchlist, config_html=CONFIG_HTML, + download_watchlist_item=download_watchlist_item, ensure_runtime=ensure_runtime, episode_list=episode_list, get_config_snapshot=get_config_snapshot, diff --git a/http_handler.py b/http_handler.py index b28eeb4..2da5d93 100644 --- a/http_handler.py +++ b/http_handler.py @@ -35,6 +35,7 @@ from app_support import ( class HandlerContext: add_to_watchlist: object config_html: str + download_watchlist_item: object ensure_runtime: object episode_list: object get_config_snapshot: object @@ -330,6 +331,10 @@ class Handler(BaseHTTPRequestHandler): Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id") self.json(Handler._context(self).update_watchlist_status(payload["show_id"], payload.get("mode"))) + elif parsed.path == "/api/watchlist/download-all": + Handler._runtime(self) + payload = Handler.require_fields(self, self.body_json(), "show_id", "mode") + self.json(Handler._context(self).download_watchlist_item(payload["show_id"], payload["mode"]), HTTPStatus.CREATED) elif parsed.path == "/api/watchlist/update-category": Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id") diff --git a/test_app.py b/test_app.py index c6f6e6d..37e0dde 100644 --- a/test_app.py +++ b/test_app.py @@ -954,6 +954,35 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(prepared["show_id"], "show-42") self.assertEqual(prepared["title"], "Queue Show Season 2") + def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self): + item = {"show_id": "show-42", "title": "Queue Show"} + 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"] + ): + runtime = APP.ensure_runtime() + runtime["download_queue"].add.return_value = job + result = APP.download_watchlist_item("show-42", "dub") + + runtime["download_queue"].add.assert_called_once_with( + { + "show_id": "show-42", + "query": "Queue Show", + "title": "Queue Show", + "anime_name": "Queue Show", + "season": "1", + "index": 3, + "mode": "dub", + "quality": "best", + "episodes": "1-12", + "download_dir": "/tmp/downloads", + } + ) + self.assertEqual(result["job"], job) + self.assertIn("Queued Queue Show for dub download.", result["message"]) + def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(self): job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"} @@ -1571,6 +1600,15 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual(handler.json_payload, {"message": "Queued refresh for Show 1.", "item": queued_item}) self.assertEqual(handler.json_status, HTTPStatus.OK) + def test_watchlist_download_all_post_returns_created_job(self): + body = b'{"show_id":"show-1","mode":"sub"}' + handler = DummyHandler("/api/watchlist/download-all", body=body, content_length=len(body)) + payload = {"message": "Queued Show 1 for sub download.", "job": {"id": "job-1"}, "item": {"show_id": "show-1"}} + handler.handler_context = mock.Mock(download_watchlist_item=mock.Mock(return_value=payload)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.CREATED) + def test_generic_handler_exception_skips_traceback_when_debug_disabled(self): handler = DummyHandler("/api/config") with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object( @@ -1650,6 +1688,11 @@ class TemplateHelperTests(unittest.TestCase): self.assertIn("const isFullyReady = item.sub_complete && item.dub_complete;", APP.WATCHLIST_HTML) self.assertIn("card.className = watchlistCardClassName(item);", APP.WATCHLIST_HTML) + def test_watchlist_page_download_all_replaces_open_action(self): + self.assertIn("Download all", APP.WATCHLIST_HTML) + self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML) + self.assertIn("Enter sub or dub", APP.WATCHLIST_HTML) + def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self): self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML) self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML) diff --git a/watchlist_page.py b/watchlist_page.py index 003c59c..919552b 100644 --- a/watchlist_page.py +++ b/watchlist_page.py @@ -601,7 +601,7 @@ WATCHLIST_HTML = r"""

How it works

Use Refresh to pull the latest sub and dub episode counts for a single show.

-

Use Open In Search to jump back to the main page with the title pre-filled.

+

Use Download all to queue every currently available episode after choosing sub or dub.

Refresh All updates your whole tracked list in one pass.

@@ -1007,7 +1007,7 @@ WATCHLIST_HTML = r"""
- +
`; @@ -1071,9 +1071,7 @@ WATCHLIST_HTML = r""" } }); refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id)); - searchBtn.addEventListener("click", () => { - window.location.href = `/?query=${encodeURIComponent(item.title || item.show_id)}`; - }); + searchBtn.addEventListener("click", () => downloadWatchlistItem(item)); removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title)); container.appendChild(card); applyThumbnail(card, item, false); @@ -1168,6 +1166,36 @@ WATCHLIST_HTML = r""" } } + function chooseWatchlistDownloadMode(item) { + const fallback = item.dub_count > 0 && item.sub_count < 1 ? "dub" : "sub"; + const answer = window.prompt( + `Download all available episodes for ${item.title || "this anime"}.\nEnter sub or dub:`, + fallback, + ); + if (answer === null) return ""; + const mode = String(answer || "").trim().toLowerCase(); + if (mode === "sub" || mode === "dub") { + return mode; + } + setNotice("Enter sub or dub to queue a watchlist download."); + return ""; + } + + async function downloadWatchlistItem(item) { + const mode = chooseWatchlistDownloadMode(item); + if (!mode) return; + setNotice(`Queueing ${item.title || "selected anime"} for ${mode} download...`); + try { + const data = await api("/api/watchlist/download-all", { + method: "POST", + body: JSON.stringify({ show_id: item.show_id, mode }) + }); + setNotice(data.message || "Added to queue."); + } catch (error) { + setNotice(error.message); + } + } + async function removeWatchlistItem(showId, title) { if (!window.confirm(`Remove ${title || "this anime"} from the watchlist?`)) { return;