Fix watchlist add and thumbnail API validation

This commit is contained in:
Dymas
2026-05-16 11:19:46 +02:00
parent d5f81d15b7
commit 8a2184f6f3
6 changed files with 78 additions and 9 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.29.3 - 2026-05-16
- Fixed duplicate watchlist add responses so already-tracked shows now return `200 OK` with `created: false` instead of the misleading `201 Created`.
- Tightened `/api/watchlist/ensure-thumbnails` request validation so `show_ids` must be a JSON array and `force` is parsed as a real boolean value instead of relying on Python truthiness.
- Expanded regression coverage for duplicate watchlist add status handling and thumbnail warm-up payload validation.
## 0.29.2 - 2026-05-16 ## 0.29.2 - 2026-05-16
- Hardened the download queue again so unexpected post-launch worker exceptions now fail the active job cleanly instead of killing the worker thread and leaving the job stuck in `running`. - Hardened the download queue again so unexpected post-launch worker exceptions now fail the active job cleanly instead of killing the worker thread and leaving the job stuck in `running`.
+3 -1
View File
@@ -2,7 +2,7 @@
A local web UI for a system-wide `ani-cli` install. A local web UI for a system-wide `ani-cli` install.
Current version: `0.29.2` Current version: `0.29.3`
## Project Layout ## Project Layout
@@ -62,6 +62,7 @@ The app currently includes:
- A dedicated Config page for saved default folder, mode, and quality settings. - A dedicated Config page for saved default folder, mode, and quality settings.
- A paged SQLite-backed download queue with retry, cancel, remove, retry-failed, and clear-finished actions. - A paged SQLite-backed download queue with retry, cancel, remove, retry-failed, and clear-finished actions.
- A paged SQLite-backed watchlist with summary cards, per-show refresh, bulk refresh, source links, and thumbnail tools. - A paged SQLite-backed watchlist with summary cards, per-show refresh, bulk refresh, source links, and thumbnail tools.
- Duplicate watchlist adds now return `200 OK` with `created: false`, keeping the API status aligned with the no-op result.
- Watchlist category tabs for `Watching`, `Planned`, `Finished`, and `Dropped`, with per-anime category editing. - Watchlist category tabs for `Watching`, `Planned`, `Finished`, and `Dropped`, with per-anime category editing.
- AnimeSchedule-backed watchlist completion tracking that compares live sub/dub availability with the expected total episode count when known. - AnimeSchedule-backed watchlist completion tracking that compares live sub/dub availability with the expected total episode count when known.
- Automatic watchlist sync after successful downloads, including downloaded-vs-manual finished markers. - Automatic watchlist sync after successful downloads, including downloaded-vs-manual finished markers.
@@ -215,6 +216,7 @@ Current thumbnail behavior:
- Cached thumbnails render from the local project cache when available. - Cached thumbnails render from the local project cache when available.
- The page adds a fresh nonce to thumbnail URLs to avoid stale browser-cached misses. - The page adds a fresh nonce to thumbnail URLs to avoid stale browser-cached misses.
- Missing visible covers are warmed up in small batches instead of one request per card all at once. - Missing visible covers are warmed up in small batches instead of one request per card all at once.
- The thumbnail warm-up API now requires `show_ids` to be a JSON array and accepts explicit boolean-style `force` values such as `true` and `false`.
- The page only retries an unresolved cover warm-up once per thumbnail state change during the current browser session, so silent watchlist polling does not keep hammering the same missing poster. - The page only retries an unresolved cover warm-up once per thumbnail state change during the current browser session, so silent watchlist polling does not keep hammering the same missing poster.
- If a show disappears while a thumbnail warm-up batch is running, that entry is skipped cleanly instead of failing the whole batch request. - If a show disappears while a thumbnail warm-up batch is running, that entry is skipped cleanly instead of failing the whole batch request.
- Thumbnail image tags only hit the local route for already-cached files, so opening the watchlist no longer triggers a per-card remote lookup burst. - Thumbnail image tags only hit the local route for already-cached files, so opening the watchlist no longer triggers a per-card remote lookup burst.
+1 -1
View File
@@ -1 +1 @@
0.29.2 0.29.3
+1 -1
View File
@@ -16,7 +16,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.29.2" VERSION = "0.29.3"
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"
+36 -6
View File
@@ -242,15 +242,17 @@ class Handler(BaseHTTPRequestHandler):
self.json(actions[action](job_id)) self.json(actions[action](job_id))
elif parsed.path in {"/api/watchlist", "/api/watchlist/add"}: elif parsed.path in {"/api/watchlist", "/api/watchlist/add"}:
Handler._runtime(self) Handler._runtime(self)
self.json(Handler._context(self).add_to_watchlist(self.body_json()), HTTPStatus.CREATED) result = Handler._context(self).add_to_watchlist(self.body_json())
status = HTTPStatus.CREATED if result.get("created") else HTTPStatus.OK
self.json(result, status)
elif parsed.path == "/api/watchlist/ensure-thumbnails": elif parsed.path == "/api/watchlist/ensure-thumbnails":
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
payload = self.body_json() payload = Handler.require_json_object(self, self.body_json())
self.json( self.json(
runtime["watchlist"].ensure_thumbnails( runtime["watchlist"].ensure_thumbnails(
payload.get("show_ids") or [], Handler.optional_list_field(self, payload, "show_ids") or [],
limit=payload.get("limit", 6), limit=payload.get("limit", 6),
force=bool(payload.get("force")), force=Handler.optional_bool_field(self, payload, "force", default=False),
) )
) )
elif parsed.path == "/api/watchlist/update-status": elif parsed.path == "/api/watchlist/update-status":
@@ -287,8 +289,7 @@ class Handler(BaseHTTPRequestHandler):
self.json(context.get_config_snapshot()) self.json(context.get_config_snapshot())
def require_fields(self, payload, *names): def require_fields(self, payload, *names):
if not isinstance(payload, dict): payload = Handler.require_json_object(self, payload)
raise ValueError("Expected a JSON object")
missing = [name for name in names if not str(payload.get(name) or "").strip()] missing = [name for name in names if not str(payload.get(name) or "").strip()]
if missing: if missing:
if len(missing) == 1: if len(missing) == 1:
@@ -296,6 +297,35 @@ class Handler(BaseHTTPRequestHandler):
raise ValueError(f"Missing required fields: {', '.join(missing)}") raise ValueError(f"Missing required fields: {', '.join(missing)}")
return payload return payload
def require_json_object(self, payload):
if not isinstance(payload, dict):
raise ValueError("Expected a JSON object")
return payload
def optional_list_field(self, payload, name):
payload = Handler.require_json_object(self, payload)
if name not in payload or payload.get(name) is None:
return None
value = payload.get(name)
if not isinstance(value, list):
raise ValueError(f"Field '{name}' must be a JSON array")
return value
def optional_bool_field(self, payload, name, default=False):
payload = Handler.require_json_object(self, payload)
if name not in payload or payload.get(name) is None:
return default
value = payload.get(name)
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ValueError(f"Field '{name}' must be a boolean")
def body_json(self): def body_json(self):
try: try:
length = int(self.headers.get("Content-Length", "0") or "0") length = int(self.headers.get("Content-Length", "0") or "0")
+31
View File
@@ -1116,6 +1116,37 @@ class HandlerRouteTests(unittest.TestCase):
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
self.assertIn("data_url", handler.error_message) self.assertIn("data_url", handler.error_message)
def test_watchlist_add_duplicate_returns_ok_instead_of_created(self):
body = b'{"show_id":"show-1","title":"Show 1","category":"watching"}'
handler = DummyHandler("/api/watchlist/add", body=body, content_length=len(body))
result = {"message": "Already tracking Show 1.", "item": {"show_id": "show-1", "title": "Show 1"}, "created": False}
with mock.patch.object(APP.WATCHLIST, "add", return_value=result):
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_payload, result)
self.assertEqual(handler.json_status, HTTPStatus.OK)
def test_ensure_thumbnails_rejects_non_array_show_ids(self):
body = b'{"show_ids":"show-1"}'
handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body))
APP.Handler.do_POST(handler)
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
self.assertIn("show_ids", handler.error_message)
def test_ensure_thumbnails_parses_string_false_force(self):
body = b'{"show_ids":["show-1"],"force":"false"}'
handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body))
with mock.patch.object(APP.WATCHLIST, "ensure_thumbnails", return_value={"items": []}) as ensure_thumbnails:
APP.Handler.do_POST(handler)
ensure_thumbnails.assert_called_once_with(["show-1"], limit=6, force=False)
self.assertEqual(handler.json_status, HTTPStatus.OK)
def test_ensure_thumbnails_rejects_invalid_force_value(self):
body = b'{"show_ids":["show-1"],"force":"maybe"}'
handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body))
APP.Handler.do_POST(handler)
self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
self.assertIn("force", handler.error_message)
def test_thumbnail_route_serves_cached_file_without_fetching(self): def test_thumbnail_route_serves_cached_file_without_fetching(self):
thumb_path = APP.THUMBNAIL_ROOT / "show-1.jpg" thumb_path = APP.THUMBNAIL_ROOT / "show-1.jpg"
thumb_path.parent.mkdir(parents=True, exist_ok=True) thumb_path.parent.mkdir(parents=True, exist_ok=True)