From 8a2184f6f39ca8f2f9da1d1afb2b06d5f0aab365 Mon Sep 17 00:00:00 2001 From: Dymas Date: Sat, 16 May 2026 11:19:46 +0200 Subject: [PATCH] Fix watchlist add and thumbnail API validation --- CHANGELOG.md | 6 ++++++ README.md | 4 +++- VERSION | 2 +- app_support.py | 2 +- http_handler.py | 42 ++++++++++++++++++++++++++++++++++++------ test_app.py | 31 +++++++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 745ab3a..131fde3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 - 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`. diff --git a/README.md b/README.md index a231f50..11312fa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A local web UI for a system-wide `ani-cli` install. -Current version: `0.29.2` +Current version: `0.29.3` ## Project Layout @@ -62,6 +62,7 @@ The app currently includes: - 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 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. - 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. @@ -215,6 +216,7 @@ Current thumbnail behavior: - 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. - 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. - 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. diff --git a/VERSION b/VERSION index 20f0687..5540b6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.29.2 +0.29.3 diff --git a/app_support.py b/app_support.py index c66db30..9a50553 100644 --- a/app_support.py +++ b/app_support.py @@ -16,7 +16,7 @@ from uuid import uuid4 PROJECT_ROOT = Path(__file__).resolve().parent ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" APP_NAME = "ani-cli-web" -VERSION = "0.29.2" +VERSION = "0.29.3" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/http_handler.py b/http_handler.py index db3cb65..8edbb14 100644 --- a/http_handler.py +++ b/http_handler.py @@ -242,15 +242,17 @@ class Handler(BaseHTTPRequestHandler): self.json(actions[action](job_id)) elif parsed.path in {"/api/watchlist", "/api/watchlist/add"}: 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": runtime = Handler._runtime(self) - payload = self.body_json() + payload = Handler.require_json_object(self, self.body_json()) self.json( runtime["watchlist"].ensure_thumbnails( - payload.get("show_ids") or [], + Handler.optional_list_field(self, payload, "show_ids") or [], 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": @@ -287,8 +289,7 @@ class Handler(BaseHTTPRequestHandler): self.json(context.get_config_snapshot()) def require_fields(self, payload, *names): - if not isinstance(payload, dict): - raise ValueError("Expected a JSON object") + payload = Handler.require_json_object(self, payload) missing = [name for name in names if not str(payload.get(name) or "").strip()] if missing: if len(missing) == 1: @@ -296,6 +297,35 @@ class Handler(BaseHTTPRequestHandler): raise ValueError(f"Missing required fields: {', '.join(missing)}") 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): try: length = int(self.headers.get("Content-Length", "0") or "0") diff --git a/test_app.py b/test_app.py index 82b41d2..ebdaa10 100644 --- a/test_app.py +++ b/test_app.py @@ -1116,6 +1116,37 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) 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): thumb_path = APP.THUMBNAIL_ROOT / "show-1.jpg" thumb_path.parent.mkdir(parents=True, exist_ok=True)