Add watchlist download-all action
This commit is contained in:
@@ -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,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user