Add watchlist download-all action
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
Small local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.35.2`
|
Current version: `0.36.0`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ Main behavior:
|
|||||||
|
|
||||||
1. `Refresh` updates one show.
|
1. `Refresh` updates one show.
|
||||||
2. `Refresh All` runs in the background.
|
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.
|
4. Clicking the title opens the source page.
|
||||||
5. `Remove` deletes the entry and cached poster.
|
5. `Remove` deletes the entry and cached poster.
|
||||||
6. Each card has an inline category selector.
|
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}
|
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):
|
def update_watchlist_category(show_id, category):
|
||||||
ensure_runtime()
|
ensure_runtime()
|
||||||
item = WATCHLIST.update_category(show_id, category)
|
item = WATCHLIST.update_category(show_id, category)
|
||||||
@@ -1968,6 +2009,7 @@ Handler = build_handler_class(
|
|||||||
HandlerContext(
|
HandlerContext(
|
||||||
add_to_watchlist=add_to_watchlist,
|
add_to_watchlist=add_to_watchlist,
|
||||||
config_html=CONFIG_HTML,
|
config_html=CONFIG_HTML,
|
||||||
|
download_watchlist_item=download_watchlist_item,
|
||||||
ensure_runtime=ensure_runtime,
|
ensure_runtime=ensure_runtime,
|
||||||
episode_list=episode_list,
|
episode_list=episode_list,
|
||||||
get_config_snapshot=get_config_snapshot,
|
get_config_snapshot=get_config_snapshot,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from app_support import (
|
|||||||
class HandlerContext:
|
class HandlerContext:
|
||||||
add_to_watchlist: object
|
add_to_watchlist: object
|
||||||
config_html: str
|
config_html: str
|
||||||
|
download_watchlist_item: object
|
||||||
ensure_runtime: object
|
ensure_runtime: object
|
||||||
episode_list: object
|
episode_list: object
|
||||||
get_config_snapshot: object
|
get_config_snapshot: object
|
||||||
@@ -330,6 +331,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
Handler._runtime(self)
|
Handler._runtime(self)
|
||||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||||
self.json(Handler._context(self).update_watchlist_status(payload["show_id"], payload.get("mode")))
|
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":
|
elif parsed.path == "/api/watchlist/update-category":
|
||||||
Handler._runtime(self)
|
Handler._runtime(self)
|
||||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
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["show_id"], "show-42")
|
||||||
self.assertEqual(prepared["title"], "Queue Show Season 2")
|
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):
|
def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(self):
|
||||||
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
|
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_payload, {"message": "Queued refresh for Show 1.", "item": queued_item})
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
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):
|
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
||||||
handler = DummyHandler("/api/config")
|
handler = DummyHandler("/api/config")
|
||||||
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
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("const isFullyReady = item.sub_complete && item.dub_complete;", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("card.className = watchlistCardClassName(item);", 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):
|
def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self):
|
||||||
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
|
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("function thumbnailWarmupToken(item)", 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>
|
<h2>How it works</h2>
|
||||||
<div class="stack muted">
|
<div class="stack muted">
|
||||||
<p>Use Refresh to pull the latest sub and dub episode counts for a single show.</p>
|
<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>
|
<p>Refresh All updates your whole tracked list in one pass.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1007,7 +1007,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
<div class="muted card-date"></div>
|
<div class="muted card-date"></div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="primary" type="button">Refresh</button>
|
<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>
|
<button class="danger" type="button">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1071,9 +1071,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id));
|
refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id));
|
||||||
searchBtn.addEventListener("click", () => {
|
searchBtn.addEventListener("click", () => downloadWatchlistItem(item));
|
||||||
window.location.href = `/?query=${encodeURIComponent(item.title || item.show_id)}`;
|
|
||||||
});
|
|
||||||
removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title));
|
removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title));
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
applyThumbnail(card, item, false);
|
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) {
|
async function removeWatchlistItem(showId, title) {
|
||||||
if (!window.confirm(`Remove ${title || "this anime"} from the watchlist?`)) {
|
if (!window.confirm(`Remove ${title || "this anime"} from the watchlist?`)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user