Add watchlist download-all action

This commit is contained in:
Dymas
2026-05-23 14:30:03 +02:00
parent 5bec8713a5
commit 91691244d7
7 changed files with 130 additions and 8 deletions
+4
View File
@@ -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.
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.35.2
0.36.0
+42
View File
@@ -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,
+5
View File
@@ -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")
+43
View File
@@ -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)
+33 -5
View File
@@ -601,7 +601,7 @@ WATCHLIST_HTML = r"""<!doctype html>
<h2>How it works</h2>
<div class="stack muted">
<p>Use Refresh to pull the latest sub and dub episode counts for a single show.</p>
<p>Use Open In Search to jump back to the main page with the title pre-filled.</p>
<p>Use Download all to queue every currently available episode after choosing sub or dub.</p>
<p>Refresh All updates your whole tracked list in one pass.</p>
</div>
</section>
@@ -1007,7 +1007,7 @@ WATCHLIST_HTML = r"""<!doctype html>
<div class="muted card-date"></div>
<div class="card-actions">
<button class="primary" type="button">Refresh</button>
<button class="ghost" type="button">Open</button>
<button class="ghost" type="button">Download all</button>
<button class="danger" type="button">Remove</button>
</div>
`;
@@ -1071,9 +1071,7 @@ WATCHLIST_HTML = r"""<!doctype html>
}
});
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"""<!doctype html>
}
}
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;