diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1188a..5abdbf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 0.12.0 - 2026-05-13 + +- Deferred runtime initialization until the server starts or a runtime-backed API/helper is first used, removing the earlier worker/thread startup and state writes from plain module import. +- Persisted watchlist refresh history in SQLite, restore interrupted `pending` and `running` refresh jobs as `interrupted` after restart, and block duplicate queued refresh submissions. +- Hardened thumbnail in-flight event cleanup so unexpected errors during cache updates do not leave later requests waiting on a stuck per-show event. +- Return `400 Bad Request` for non-UTF-8 JSON payloads instead of surfacing them as internal server errors. +- Expanded regression coverage for deferred bootstrap, duplicate refresh protection, interrupted refresh recovery, UTF-8 body validation, and thumbnail event cleanup. + +## 0.11.0 - 2026-05-13 + +- Moved the large inline page templates and shared page-shell helpers into `web_templates.py`, reducing `app.py` to server and runtime logic. +- Changed the shared SQLite file from `queue.sqlite3` to `state.sqlite3` and added automatic migration from the previous project-local database filename. +- Made watchlist poster image routes cache-only so opening the watchlist no longer fans out synchronous remote cover lookups from the browser image tags. +- Moved `Refresh All` onto a background watchlist-refresh worker with status polling so large watchlists no longer block a request thread until every show finishes updating. +- Added an HTTP JSON body-size guard to reject oversized uploads before the server reads unbounded request bodies into memory. +- Reworked the test harness to run against an isolated temporary state root and added handler-level coverage for thumbnail serving, background refresh status, and oversized POST rejection. + +## 0.10.0 - 2026-05-13 + +- Fixed running-job cancellation so the worker now consistently finishes canceled downloads as `canceled` instead of drifting into failed or completed final states. +- Fixed watchlist `Refresh All` to iterate every tracked show instead of silently stopping at the paged list limit. +- Added loopback-only request protection by default, with `ANI_CLI_WEB_ALLOW_REMOTE=1` available when remote/LAN access is intentionally desired. +- Extracted shared sidebar branding and page-link helpers to reduce repeated inline page chrome across Search, Config, and Watchlist. +- Added focused regression tests for cancellation propagation, full watchlist refresh coverage, local-access helpers, and shared template helpers. + +## 0.9.0 - 2026-05-13 + +- Reworked Search, Config, and Watchlist with a Tsuki-inspired visual refresh using violet accents, glassy surfaces, softer gradients, and calmer card styling. +- Kept the existing ani-cli-web behavior and routing intact while unifying the refreshed design language across the whole web UI. + +## 0.8.16 - 2026-05-13 + +- Unified the left sidebar layout across Search, Config, and Watchlist so the page chrome now uses the same panel width, spacing, and structure everywhere. +- Kept the Config page compact by constraining its main settings content while restoring the shared sidebar pattern. + +## 0.8.15 - 2026-05-13 + +- Removed the dependency status panel from the Search page so the main route stays focused on search, episode selection, and queue management. +- Reworked the Config page into a centered compact settings layout instead of the earlier stretched split-screen view. + +## 0.8.14 - 2026-05-13 + +- Added a standalone `/config` page for saved defaults. +- Moved the Search page defaults section onto the new Config page and added Config navigation across Search and Watchlist. +- Kept dependency status visible while moving saved folder, mode, and quality management into the dedicated settings view. + ## 0.8.13 - 2026-05-12 - Added a manual thumbnail upload button for watchlist cards that still have no poster, saving the selected image into the local thumbnail cache for that anime. diff --git a/README.md b/README.md index 0a1bd2c..adcc296 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,16 @@ A local web UI for a system-wide `ani-cli` install. -Current version: `0.8.13` +Current version: `0.12.0` ## Project Layout ```text ani-cli-web/ - app.py Web server, queue, watchlist, thumbnail cache, and post-download file naming. + app.py Web server, runtime wiring, request handlers, and CLI entrypoint. + web_templates.py Shared Search, Config, and Watchlist page markup plus page-shell helpers. ani-cli-web Launcher script. + test_app.py Regression tests with isolated temp-state setup. VERSION Project version. CHANGELOG.md Change history. README.md Project documentation. @@ -41,16 +43,21 @@ By default the server binds to `127.0.0.1:8421`. ## Overview -`ani-cli-web` keeps search and download management on `/` and exposes the watchlist on `/watchlist`. +`ani-cli-web` keeps search and download management on `/`, saved defaults on `/config`, and the watchlist on `/watchlist`, all with the same sidebar navigation layout and a Tsuki-inspired visual style. The app currently includes: - Search against AllAnime with `sub` or `dub` mode selection. - Episode loading for the selected result. - Queueing downloads with editable library name, season, episode range, quality, and target folder. +- 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. +- Background `Refresh All` execution with status polling instead of a long blocking HTTP request. +- Persistent watchlist refresh history that survives restarts and marks interrupted bulk refresh jobs clearly. - Local thumbnail caching plus manual thumbnail upload when automatic cover lookup fails. +- A refreshed glassy dark UI inspired by the Tsuki frontend design language. +- Shared page templates moved out of `app.py` plus broader regression tests for higher-risk queue, watchlist, and handler flows. - Project-local runtime state under `.ani-cli-web/`. ## Search Page @@ -66,10 +73,34 @@ Workflow: The page also includes: -- A Defaults panel for the saved download folder, mode, and quality. -- A dependency status panel so you can see which required tools are available. - A live queue view that refreshes automatically. +## Config Page + +The Config page at `/config` is where saved defaults now live. + +Use it to: + +1. Set the default download folder. +2. Choose the default `sub` or `dub` search mode. +3. Choose the default search quality used to prefill the Search page. + +Those saved defaults are written into the project-local `config.json` and loaded automatically when the Search page opens. + +The Config page also shows the current app version plus dependency status in a compact centered layout. + +## Access Guard + +By default, `ani-cli-web` only serves loopback clients such as `127.0.0.1` and `::1`. + +If you intentionally want LAN or remote access, set: + +```sh +ANI_CLI_WEB_ALLOW_REMOTE=1 +``` + +This keeps the queue, config, and watchlist mutation APIs safer by default when the app is rebound away from localhost. + ## Download Queue The queue is stored in SQLite and shown 10 jobs at a time. @@ -100,7 +131,7 @@ You can add entries in two ways: Watchlist features: 1. `Refresh` updates one tracked show. -2. `Refresh All` updates the entire watchlist. +2. `Refresh All` runs in the background, rejects duplicate queueing while a refresh is already pending or running, and preserves interrupted job status across restarts. 3. `Open` jumps back to the Search page with the title pre-filled. 4. Clicking the anime title opens the AllManga source page at `bangumi/` in a new tab. 5. `Remove` deletes the watchlist entry and clears its cached poster file. @@ -116,6 +147,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. +- 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. - AnimeSchedule is tried first for cover lookup. - If a season-labeled title fails as-is, lookup retries with simplified base-title queries. - AniDB is used as a fallback when AnimeSchedule does not resolve a cover. @@ -140,7 +172,7 @@ ani-cli-web/.ani-cli-web/ That folder currently holds: - `config.json` -- `queue.sqlite3` +- `state.sqlite3` - legacy `queue.json` migration input when present - legacy `watchlist.json` migration input when present - `thumbnails/` @@ -148,6 +180,7 @@ That folder currently holds: - staging files for active downloads Legacy files from `~/.config/ani-cli-web/` and `~/.local/state/ani-cli-web/` are migrated into the project-local folder automatically on startup. +The earlier project-local `queue.sqlite3` filename is also migrated automatically into `state.sqlite3`. ## Configuration @@ -159,11 +192,15 @@ Environment variables: - `ANI_CLI_QUALITY`: initial quality such as `best`, `1080`, or `720`. - `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`. - `ANI_CLI_WEB_PORT`: server port, default `8421`. +- `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients when set to `1`, `true`, `yes`, or `on`. - `ANI_CLI_WEB_DEBUG`: enable debug logging when set to `1`, `true`, `yes`, or `on`. +- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory path; useful for isolated test runs or custom storage locations. Saved defaults from the UI are written into the project-local `config.json`. ## Notes -- Watchlist and queue data are stored in the same project-local SQLite database. +- Importing `app.py` no longer boots the runtime worker threads automatically; runtime setup is deferred until startup or first use. +- Watchlist and queue data are stored in the same project-local SQLite database file, `state.sqlite3`. - Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode cache directories at any depth are ignored by git. +- Focused regression tests live in `test_app.py`, run against an isolated temporary state root, and can be started with `python3 -m unittest test_app.py`. diff --git a/VERSION b/VERSION index c2f73c6..ac454c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.13 +0.12.0 diff --git a/app.py b/app.py index 3b14cfa..94e5057 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import base64 import gzip +import ipaddress import json import mimetypes import os @@ -24,11 +25,13 @@ import sys from urllib.parse import parse_qs, quote, unquote, urljoin, urlparse from uuid import uuid4 +from web_templates import CONFIG_HTML, INDEX_HTML, PAGE_LINKS, WATCHLIST_HTML, render_page_links, render_sidebar_brand + 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.8.13" +VERSION = "0.12.0" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" @@ -40,14 +43,50 @@ ANIDB_ANIME_PAGE = "https://anidb.net/anime/{aid}" AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) DEBUG_MODE = False +RUNTIME_LOCK = threading.RLock() QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"} MODE_CHOICES = {"sub", "dub"} MAX_LOG_LINES = 240 +MAX_JSON_BODY_BYTES = 12 * 1024 * 1024 + + +def env_flag(name, default=False): + raw = os.environ.get(name) + if raw is None: + return default + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + +class HttpError(Exception): + def __init__(self, status, message): + super().__init__(message) + self.status = status + self.message = message def debug_enabled(): - return DEBUG_MODE or str(os.environ.get("ANI_CLI_WEB_DEBUG") or "").strip().lower() in {"1", "true", "yes", "on"} + return DEBUG_MODE or env_flag("ANI_CLI_WEB_DEBUG") + + +def remote_access_allowed(): + return env_flag("ANI_CLI_WEB_ALLOW_REMOTE") + + +def background_workers_enabled(): + return not env_flag("ANI_CLI_WEB_DISABLE_WORKER") + + +def client_address_is_local(host): + text = str(host or "").strip() + if not text: + return False + if text == "localhost": + return True + try: + return ipaddress.ip_address(text).is_loopback + except ValueError: + return False def debug_log(event, **fields): @@ -82,8 +121,19 @@ def now_iso(): return datetime.now(timezone.utc).isoformat() -PROJECT_APP_ROOT = PROJECT_ROOT / ".ani-cli-web" -PROJECT_APP_ROOT.mkdir(parents=True, exist_ok=True) +def project_app_root(): + configured = str(os.environ.get("ANI_CLI_WEB_STATE_ROOT") or "").strip() + if configured: + return Path(configured).expanduser() + return PROJECT_ROOT / ".ani-cli-web" + + +PROJECT_APP_ROOT = project_app_root() + + +def ensure_project_app_root(): + PROJECT_APP_ROOT.mkdir(parents=True, exist_ok=True) + return PROJECT_APP_ROOT def legacy_state_root(): @@ -95,15 +145,11 @@ def legacy_config_root(): def project_state_path(*parts): - path = PROJECT_APP_ROOT.joinpath(*parts) - path.parent.mkdir(parents=True, exist_ok=True) - return path + return PROJECT_APP_ROOT.joinpath(*parts) def project_state_dir(*parts): - path = PROJECT_APP_ROOT.joinpath(*parts) - path.mkdir(parents=True, exist_ok=True) - return path + return PROJECT_APP_ROOT.joinpath(*parts) def migrate_legacy_file(project_path, legacy_path, label): @@ -155,7 +201,7 @@ LEGACY_ANIDB_TITLES_PATH = LEGACY_STATE_ROOT / "anidb-anime-titles.xml.gz" CONFIG_PATH = project_state_path("config.json") QUEUE_PATH = project_state_path("queue.json") -QUEUE_DB_PATH = project_state_path("queue.sqlite3") +STATE_DB_PATH = project_state_path("state.sqlite3") STAGING_ROOT = project_state_dir("staging") WATCHLIST_JSON_PATH = project_state_path("watchlist.json") THUMBNAIL_ROOT = project_state_dir("thumbnails") @@ -165,9 +211,11 @@ ANIDB_TITLES_CACHE = {"mtime": None, "entries": None} def migrate_legacy_state_storage(): + ensure_project_app_root() migrate_legacy_file(CONFIG_PATH, LEGACY_CONFIG_PATH, "config.json") migrate_legacy_file(QUEUE_PATH, LEGACY_QUEUE_PATH, "queue.json") - migrate_legacy_file(QUEUE_DB_PATH, LEGACY_QUEUE_DB_PATH, "queue.sqlite3") + migrate_legacy_file(STATE_DB_PATH, PROJECT_APP_ROOT / "queue.sqlite3", "queue.sqlite3") + migrate_legacy_file(STATE_DB_PATH, LEGACY_QUEUE_DB_PATH, "queue.sqlite3") migrate_legacy_file(WATCHLIST_JSON_PATH, LEGACY_WATCHLIST_JSON_PATH, "watchlist.json") migrate_legacy_file(ANIDB_TITLES_PATH, LEGACY_ANIDB_TITLES_PATH, "anidb-anime-titles.xml.gz") migrate_legacy_dir(STAGING_ROOT, LEGACY_STAGING_ROOT, "staging") @@ -187,6 +235,7 @@ def load_json(path, fallback): def write_json(path, data): + path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") with tmp.open("w", encoding="utf-8") as handle: json.dump(data, handle, indent=2, sort_keys=True) @@ -210,8 +259,10 @@ def normalize_config(data): return config -CONFIG = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG)) -write_json(CONFIG_PATH, CONFIG) +CONFIG = None +WATCHLIST = None +DOWNLOAD_QUEUE = None +WATCHLIST_REFRESH = None def graph_request(query, variables): @@ -591,6 +642,7 @@ def anidb_titles_cache_due(): def ensure_anidb_titles_dump(force=False): if not force and not anidb_titles_cache_due(): return ANIDB_TITLES_PATH + ANIDB_TITLES_PATH.parent.mkdir(parents=True, exist_ok=True) debug_log("anidb.titles.request", url=ANIDB_TITLES_URL, force=force) request = urllib.request.Request( ANIDB_TITLES_URL, @@ -921,12 +973,12 @@ class WatchlistStore: self._migrate_legacy_json() def _connect(self): - conn = sqlite3.connect(QUEUE_DB_PATH, timeout=30) + conn = sqlite3.connect(STATE_DB_PATH, timeout=30) conn.row_factory = sqlite3.Row return conn def _init_db(self): - QUEUE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + STATE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) with self._connect() as conn: conn.execute( """ @@ -1132,6 +1184,11 @@ class WatchlistStore: raise KeyError("Anime not found in watchlist.") return self._row_to_item(row) + def all_show_ids(self): + with self.lock, self._connect() as conn: + rows = conn.execute("SELECT show_id FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall() + return [str(row["show_id"]).strip() for row in rows if str(row["show_id"]).strip()] + def add(self, payload): show_id = str(payload.get("show_id") or "").strip() title = str(payload.get("title") or "Unknown Anime").strip() or "Unknown Anime" @@ -1225,8 +1282,7 @@ class WatchlistStore: return self.get(show_id) def refresh_all(self): - items = self.list(page=1, per_page=10000)["items"] - refreshed = [self.refresh(item["show_id"]) for item in items] + refreshed = [self.refresh(show_id) for show_id in self.all_show_ids()] return {"count": len(refreshed), "items": refreshed, "message": f"Refreshed {len(refreshed)} watchlist entries."} def remove(self, show_id): @@ -1286,10 +1342,11 @@ class WatchlistStore: event = self.thumbnail_events.get(show_id) if event: wait_event = event + owns_event = False else: wait_event = threading.Event() self.thumbnail_events[show_id] = wait_event - event = None + owns_event = True if event: wait_event.wait(timeout=20) @@ -1302,75 +1359,84 @@ class WatchlistStore: cover = None checked_at = now_iso() try: - stored_route = str(item.get("animeschedule_route") or "").strip() - stored_schedule_title = str(item.get("animeschedule_title") or item.get("title") or "").strip() - if stored_route: - try: - debug_log("thumbnail.ensure.try_route", source="AnimeSchedule", show_id=show_id, route=stored_route) - cover = fetch_animeschedule_cover_by_route(stored_route, fallback_title=stored_schedule_title) - except Exception: - debug_log("thumbnail.ensure.route_failed", source="AnimeSchedule", show_id=show_id, route=stored_route) - if not force: - raise - if cover is None: - try: - debug_log("thumbnail.ensure.try_title", source="AnimeSchedule", show_id=show_id, title=item["title"]) - cover = fetch_animeschedule_cover(item["title"]) - except Exception: - debug_log("thumbnail.ensure.title_failed", source="AnimeSchedule", show_id=show_id, title=item["title"]) - cover = None - stored_aid = str(item.get("anidb_aid") or "").strip() - stored_title = str(item.get("anidb_title") or item.get("title") or "").strip() - if cover is None and stored_aid: - try: - debug_log("thumbnail.ensure.try_route", source="AniDB", show_id=show_id, aid=stored_aid) - cover = fetch_anidb_cover_by_aid(stored_aid, fallback_title=stored_title) - except Exception: - debug_log("thumbnail.ensure.route_failed", source="AniDB", show_id=show_id, aid=stored_aid) - if not force: - raise - if cover is None: - debug_log("thumbnail.ensure.try_title", source="AniDB", show_id=show_id, title=item["title"]) - cover = fetch_anidb_cover(item["title"]) - debug_log("thumbnail.ensure.cover_selected", show_id=show_id, source="AnimeSchedule" if cover.get("route") else "AniDB", cover=cover) - cached_name = cache_thumbnail_image(item["show_id"], cover["url"]) - except Exception as exc: - debug_log("thumbnail.ensure.failed", show_id=show_id, error=exc) - cached_name = None - checked_at = item.get("thumbnail_checked_at") + try: + stored_route = str(item.get("animeschedule_route") or "").strip() + stored_schedule_title = str(item.get("animeschedule_title") or item.get("title") or "").strip() + if stored_route: + try: + debug_log("thumbnail.ensure.try_route", source="AnimeSchedule", show_id=show_id, route=stored_route) + cover = fetch_animeschedule_cover_by_route(stored_route, fallback_title=stored_schedule_title) + except Exception: + debug_log("thumbnail.ensure.route_failed", source="AnimeSchedule", show_id=show_id, route=stored_route) + if not force: + raise + if cover is None: + try: + debug_log("thumbnail.ensure.try_title", source="AnimeSchedule", show_id=show_id, title=item["title"]) + cover = fetch_animeschedule_cover(item["title"]) + except Exception: + debug_log("thumbnail.ensure.title_failed", source="AnimeSchedule", show_id=show_id, title=item["title"]) + cover = None + stored_aid = str(item.get("anidb_aid") or "").strip() + stored_title = str(item.get("anidb_title") or item.get("title") or "").strip() + if cover is None and stored_aid: + try: + debug_log("thumbnail.ensure.try_route", source="AniDB", show_id=show_id, aid=stored_aid) + cover = fetch_anidb_cover_by_aid(stored_aid, fallback_title=stored_title) + except Exception: + debug_log("thumbnail.ensure.route_failed", source="AniDB", show_id=show_id, aid=stored_aid) + if not force: + raise + if cover is None: + debug_log("thumbnail.ensure.try_title", source="AniDB", show_id=show_id, title=item["title"]) + cover = fetch_anidb_cover(item["title"]) + debug_log( + "thumbnail.ensure.cover_selected", + show_id=show_id, + source="AnimeSchedule" if cover.get("route") else "AniDB", + cover=cover, + ) + cached_name = cache_thumbnail_image(item["show_id"], cover["url"]) + except Exception as exc: + debug_log("thumbnail.ensure.failed", show_id=show_id, error=exc) + cached_name = None + checked_at = item.get("thumbnail_checked_at") - with self.lock, self._connect() as conn: - conn.execute( - """ - UPDATE watchlist - SET thumbnail_path = ?, thumbnail_checked_at = ?, - animeschedule_route = ?, animeschedule_title = ?, - anidb_aid = ?, anidb_title = ? - WHERE show_id = ? - """, - ( - cached_name, - checked_at, - (cover or {}).get("route"), - (cover or {}).get("title") if (cover or {}).get("route") else item.get("animeschedule_title"), - (cover or {}).get("aid"), - (cover or {}).get("title") if (cover or {}).get("aid") else item.get("anidb_title"), - item["show_id"], - ), + with self.lock, self._connect() as conn: + conn.execute( + """ + UPDATE watchlist + SET thumbnail_path = ?, thumbnail_checked_at = ?, + animeschedule_route = ?, animeschedule_title = ?, + anidb_aid = ?, anidb_title = ? + WHERE show_id = ? + """, + ( + cached_name, + checked_at, + (cover or {}).get("route"), + (cover or {}).get("title") if (cover or {}).get("route") else item.get("animeschedule_title"), + (cover or {}).get("aid"), + (cover or {}).get("title") if (cover or {}).get("aid") else item.get("anidb_title"), + item["show_id"], + ), + ) + debug_log( + "thumbnail.ensure.finish", + show_id=show_id, + cached_name=cached_name, + route=(cover or {}).get("route"), + aid=(cover or {}).get("aid"), + checked_at=checked_at, ) - inflight = self.thumbnail_events.get(show_id) - if inflight is wait_event: - inflight.set() - self.thumbnail_events.pop(show_id, None) - debug_log( - "thumbnail.ensure.finish", - show_id=show_id, - cached_name=cached_name, - route=(cover or {}).get("route"), - aid=(cover or {}).get("aid"), - checked_at=checked_at, - ) - return thumbnail_file_path(cached_name) + return thumbnail_file_path(cached_name) + finally: + if owns_event: + with self.lock: + inflight = self.thumbnail_events.get(show_id) + if inflight is wait_event: + inflight.set() + self.thumbnail_events.pop(show_id, None) def ensure_thumbnails(self, show_ids, limit=6, force=False): normalized = [] @@ -1392,53 +1458,296 @@ class WatchlistStore: return {"items": updated} -WATCHLIST = WatchlistStore() - - def add_to_watchlist(payload): + ensure_runtime() return WATCHLIST.add(payload) def get_watchlist(page=1, per_page=30): + ensure_runtime() return WATCHLIST.list(page=page, per_page=per_page) def update_watchlist_status(show_id, _mode=None): + ensure_runtime() item = WATCHLIST.refresh(show_id) return {"message": f"Updated {item['title']}.", "item": item} def update_all_watchlist_statuses(): - return WATCHLIST.refresh_all() + ensure_runtime() + return WATCHLIST_REFRESH.start() + + +def get_watchlist_refresh_status(): + ensure_runtime() + return WATCHLIST_REFRESH.status() def remove_from_watchlist(show_id): + ensure_runtime() return WATCHLIST.remove(show_id) def upload_watchlist_thumbnail(payload): + ensure_runtime() return WATCHLIST.set_manual_thumbnail(payload["show_id"], payload.get("filename"), payload.get("data_url")) +class WatchlistRefreshJobs: + def __init__(self, store, start_worker=True): + self.store = store + self.lock = threading.RLock() + self.wakeup = threading.Event() + self.pending = [] + self.jobs = [] + self.current_job_id = None + self._init_db() + self._restore_interrupted_jobs() + self._load_jobs() + self.worker = None + if start_worker: + self.ensure_worker() + + def _connect(self): + conn = sqlite3.connect(STATE_DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + return conn + + def ensure_worker(self): + with self.lock: + if self.worker is not None and self.worker.is_alive(): + return + self.worker = threading.Thread(target=self._run, daemon=True) + self.worker.start() + + def _init_db(self): + STATE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS watchlist_refresh_jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + payload TEXT NOT NULL + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_refresh_created_at ON watchlist_refresh_jobs(created_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_refresh_status ON watchlist_refresh_jobs(status)") + + def _job_from_row(self, row): + payload = row["payload"] if isinstance(row, sqlite3.Row) else row[0] + return json.loads(payload) + + def _upsert_job_conn(self, conn, job): + conn.execute( + """ + INSERT INTO watchlist_refresh_jobs (id, status, created_at, updated_at, payload) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + updated_at = excluded.updated_at, + payload = excluded.payload + """, + ( + job["id"], + job["status"], + job["created_at"], + job["updated_at"], + json.dumps(job, sort_keys=True), + ), + ) + + def _trim_history_conn(self, conn): + rows = conn.execute( + "SELECT id FROM watchlist_refresh_jobs ORDER BY created_at DESC LIMIT -1 OFFSET 10" + ).fetchall() + if rows: + conn.executemany("DELETE FROM watchlist_refresh_jobs WHERE id = ?", [(row["id"],) for row in rows]) + + def _save_job_locked(self, job): + with self._connect() as conn: + self._upsert_job_conn(conn, job) + self._trim_history_conn(conn) + + def _load_jobs(self): + with self.lock, self._connect() as conn: + rows = conn.execute( + "SELECT payload FROM watchlist_refresh_jobs ORDER BY created_at DESC LIMIT 10" + ).fetchall() + self.jobs = [self._job_from_row(row) for row in rows] + + def _restore_interrupted_jobs(self): + with self.lock, self._connect() as conn: + rows = conn.execute( + "SELECT payload FROM watchlist_refresh_jobs WHERE status IN ('pending', 'running')" + ).fetchall() + for row in rows: + job = self._job_from_row(row) + job["status"] = "interrupted" + job["finished_at"] = now_iso() + job["updated_at"] = job["finished_at"] + job["current_title"] = None + job["message"] = "Interrupted by web app restart." + self._upsert_job_conn(conn, job) + self._trim_history_conn(conn) + + def _copy_job(self, job): + return json.loads(json.dumps(job)) + + def _find_locked(self, job_id): + for job in self.jobs: + if job["id"] == job_id: + return job + return None + + def status(self): + with self.lock: + job = next((candidate for candidate in self.jobs if candidate["status"] in {"pending", "running"}), None) + if job is None: + job = self.jobs[0] if self.jobs else None + active = bool(job and job["status"] in {"pending", "running"}) + return {"job": self._copy_job(job) if job else None, "running": active} + + def start(self): + with self.lock: + job = next((candidate for candidate in self.jobs if candidate["status"] in {"pending", "running"}), None) + if job is not None: + return { + "message": "Watchlist refresh already queued." if job["status"] == "pending" else "Watchlist refresh already running.", + "job": self._copy_job(job) if job else None, + "created": False, + } + now = now_iso() + job = { + "id": uuid4().hex, + "status": "pending", + "created_at": now, + "updated_at": now, + "started_at": None, + "finished_at": None, + "total": 0, + "completed": 0, + "errors": 0, + "current_title": None, + "message": "Queued watchlist refresh.", + } + self.jobs.insert(0, job) + self.jobs = self.jobs[:10] + self._save_job_locked(job) + self.pending.append(job["id"]) + self.wakeup.set() + return {"message": "Watchlist refresh started.", "job": self._copy_job(job), "created": True} + + def _next_pending(self): + with self.lock: + while self.pending: + job_id = self.pending.pop(0) + job = self._find_locked(job_id) + if job is not None: + self.current_job_id = job_id + return job + return None + + def _run(self): + while True: + job = self._next_pending() + if not job: + self.wakeup.wait(2) + self.wakeup.clear() + continue + self._run_job(job) + + def _run_job(self, job): + try: + show_ids = self.store.all_show_ids() + started_at = now_iso() + with self.lock: + job["status"] = "running" + job["started_at"] = started_at + job["updated_at"] = started_at + job["total"] = len(show_ids) + job["completed"] = 0 + job["errors"] = 0 + job["current_title"] = None + job["message"] = f"Refreshing 0/{len(show_ids)} watchlist entries." + self._save_job_locked(job) + + for index, show_id in enumerate(show_ids, start=1): + title = show_id + errored = False + try: + item = self.store.refresh(show_id) + title = item.get("title") or title + errored = item.get("status") == "error" + except Exception: + errored = True + with self.lock: + job["completed"] = index + job["errors"] += 1 if errored else 0 + job["current_title"] = title + job["updated_at"] = now_iso() + job["message"] = f"Refreshing {index}/{len(show_ids)}: {title}" + self._save_job_locked(job) + + finished_at = now_iso() + with self.lock: + job["status"] = "done" + job["finished_at"] = finished_at + job["updated_at"] = finished_at + job["current_title"] = None + if job["errors"]: + job["message"] = ( + f"Refreshed {job['completed']} watchlist entries with {job['errors']} refresh errors." + ) + else: + job["message"] = f"Refreshed {job['completed']} watchlist entries." + self._save_job_locked(job) + except Exception as exc: + finished_at = now_iso() + with self.lock: + job["status"] = "failed" + job["finished_at"] = finished_at + job["updated_at"] = finished_at + job["current_title"] = None + job["message"] = f"Watchlist refresh failed: {exc}" + self._save_job_locked(job) + finally: + with self.lock: + self.current_job_id = None + + class DownloadQueue: - def __init__(self): + def __init__(self, start_worker=True): self.lock = threading.RLock() self.wakeup = threading.Event() self.current_process = None self.current_job_id = None + self.current_job = None self._init_db() self._migrate_json_queue() self._restore_interrupted_jobs() - self.worker = threading.Thread(target=self._run, daemon=True) - self.worker.start() + self.worker = None + if start_worker: + self.ensure_worker() def _connect(self): - conn = sqlite3.connect(QUEUE_DB_PATH, timeout=30) + conn = sqlite3.connect(STATE_DB_PATH, timeout=30) conn.row_factory = sqlite3.Row return conn + def ensure_worker(self): + with self.lock: + if self.worker is not None and self.worker.is_alive(): + return + self.worker = threading.Thread(target=self._run, daemon=True) + self.worker.start() + def _init_db(self): - QUEUE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + STATE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) with self._connect() as conn: conn.execute( """ @@ -1585,8 +1894,12 @@ class DownloadQueue: os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM) except OSError: self.current_process.terminate() + updated_at = now_iso() job["cancel_requested"] = True - job["updated_at"] = now_iso() + job["updated_at"] = updated_at + if self.current_job is not None and self.current_job.get("id") == job_id: + self.current_job["cancel_requested"] = True + self.current_job["updated_at"] = updated_at self._save_job_locked(job) return job raise ValueError("Could not find the running process") @@ -1685,6 +1998,7 @@ class DownloadQueue: with self.lock: self.current_process = process self.current_job_id = job["id"] + self.current_job = job job["pid"] = process.pid self._save_job_locked(job) @@ -1722,6 +2036,7 @@ class DownloadQueue: job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.") self.current_process = None self.current_job_id = None + self.current_job = None self._save_job_locked(job) @@ -1898,7 +2213,52 @@ def command_for_job(job): return command -DOWNLOAD_QUEUE = DownloadQueue() +def build_runtime(start_workers=None): + migrate_legacy_state_storage() + worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) + config = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG)) + write_json(CONFIG_PATH, config) + watchlist = WatchlistStore() + download_queue = DownloadQueue(start_worker=worker_state) + watchlist_refresh = WatchlistRefreshJobs(watchlist, start_worker=worker_state) + return { + "config": config, + "watchlist": watchlist, + "download_queue": download_queue, + "watchlist_refresh": watchlist_refresh, + } + + +def initialize_runtime(start_workers=None): + global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH + with RUNTIME_LOCK: + if CONFIG is not None and WATCHLIST is not None and DOWNLOAD_QUEUE is not None and WATCHLIST_REFRESH is not None: + if start_workers: + DOWNLOAD_QUEUE.ensure_worker() + WATCHLIST_REFRESH.ensure_worker() + return { + "config": CONFIG, + "watchlist": WATCHLIST, + "download_queue": DOWNLOAD_QUEUE, + "watchlist_refresh": WATCHLIST_REFRESH, + } + runtime = build_runtime(start_workers=start_workers) + CONFIG = runtime["config"] + WATCHLIST = runtime["watchlist"] + DOWNLOAD_QUEUE = runtime["download_queue"] + WATCHLIST_REFRESH = runtime["watchlist_refresh"] + return runtime + + +def ensure_runtime(start_workers=False): + if CONFIG is None or WATCHLIST is None or DOWNLOAD_QUEUE is None or WATCHLIST_REFRESH is None: + return initialize_runtime(start_workers=start_workers) + return { + "config": CONFIG, + "watchlist": WATCHLIST, + "download_queue": DOWNLOAD_QUEUE, + "watchlist_refresh": WATCHLIST_REFRESH, + } def dependency_status(): @@ -1911,11 +2271,25 @@ def dependency_status(): class Handler(BaseHTTPRequestHandler): server_version = "AniCliWeb/1.0" + def ensure_client_access(self): + if remote_access_allowed(): + return + client_host = self.client_address[0] if self.client_address else "" + if client_address_is_local(client_host): + return + raise PermissionError( + "Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients." + ) + def do_GET(self): parsed = urlparse(self.path) try: + self.ensure_client_access() + ensure_runtime() if parsed.path == "/": self.html(INDEX_HTML) + elif parsed.path == "/config": + self.html(CONFIG_HTML) elif parsed.path == "/watchlist": self.html(WATCHLIST_HTML) elif parsed.path == "/api/config": @@ -1946,11 +2320,10 @@ class Handler(BaseHTTPRequestHandler): per_page = (params.get("per_page") or ["10"])[0] self.json(DOWNLOAD_QUEUE.list(page=page, per_page=per_page)) elif parsed.path.startswith("/api/watchlist/thumb/"): - params = parse_qs(parsed.query) show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip() - force = (params.get("force") or ["0"])[0] == "1" - debug_log("thumbnail.route.request", show_id=show_id, force=force, path=parsed.path, query=parsed.query) - path = WATCHLIST.ensure_thumbnail(show_id, force=force) + debug_log("thumbnail.route.request", show_id=show_id, path=parsed.path, query=parsed.query) + item = WATCHLIST.get(show_id) + path = thumbnail_file_path(item.get("thumbnail_path")) if not path or not path.exists(): debug_log("thumbnail.route.miss", show_id=show_id, resolved_path=path) self.error(HTTPStatus.NOT_FOUND, "Thumbnail not found") @@ -1962,6 +2335,8 @@ class Handler(BaseHTTPRequestHandler): page = (params.get("page") or ["1"])[0] per_page = (params.get("per_page") or ["30"])[0] return self.json(get_watchlist(page=page, per_page=per_page)) + elif parsed.path == "/api/watchlist/refresh-status": + self.json(get_watchlist_refresh_status()) else: self.error(HTTPStatus.NOT_FOUND, "Not found") except Exception as exc: @@ -1970,6 +2345,8 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): parsed = urlparse(self.path) try: + self.ensure_client_access() + ensure_runtime() if parsed.path == "/api/config": self.update_config(self.body_json()) elif parsed.path == "/api/queue": @@ -2008,7 +2385,9 @@ class Handler(BaseHTTPRequestHandler): payload = self.body_json() self.json(update_watchlist_status(payload["show_id"], payload.get("mode"))) elif parsed.path == "/api/watchlist/update-all": - self.json(update_all_watchlist_statuses()) + result = update_all_watchlist_statuses() + status = HTTPStatus.ACCEPTED if result.get("created") else HTTPStatus.OK + self.json(result, status) elif parsed.path == "/api/watchlist/remove": payload = self.body_json() self.json(remove_from_watchlist(payload["show_id"])) @@ -2029,8 +2408,21 @@ class Handler(BaseHTTPRequestHandler): self.json(CONFIG) def body_json(self): - length = int(self.headers.get("Content-Length", "0") or "0") - raw = self.rfile.read(length).decode("utf-8") if length else "{}" + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError as exc: + raise ValueError("Invalid Content-Length header") from exc + if length < 0: + raise ValueError("Invalid Content-Length header") + if length > MAX_JSON_BODY_BYTES: + raise HttpError(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.") + raw_bytes = self.rfile.read(length) if length else b"{}" + if len(raw_bytes) > MAX_JSON_BODY_BYTES: + raise HttpError(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.") + try: + raw = raw_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("Request body must be valid UTF-8 JSON.") from exc try: return json.loads(raw) except json.JSONDecodeError as exc: @@ -2077,8 +2469,12 @@ class Handler(BaseHTTPRequestHandler): def exception(self, exc): if isinstance(exc, CLIENT_DISCONNECT_ERRORS): return - if isinstance(exc, KeyError): + if isinstance(exc, HttpError): + self.error(exc.status, exc.message) + elif isinstance(exc, KeyError): self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'")) + elif isinstance(exc, PermissionError): + self.error(HTTPStatus.FORBIDDEN, str(exc)) elif isinstance(exc, ValueError): self.error(HTTPStatus.BAD_REQUEST, str(exc)) else: @@ -2087,1555 +2483,6 @@ class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): print(f"{self.address_string()} - {fmt % args}") - -INDEX_HTML = r""" - - - - - ani-cli web - - - -
- - -
-
-
-
-

Select a result

-

Episodes will appear here.

-
-
- - -
-
-
- - -
-
- - -
-
-
-
- - - -
-
-
- -
-
-

Defaults

- -
-
- -
-
-
- -
-
-

Download queue

-
- - -
-
-
-
-
-
-
- - - - -""" - - -WATCHLIST_HTML = r""" - - - - - ani-cli web - watchlist - - - -
- - -
-
-
-
-

Tracked shows

-

Compact cards with local AnimeSchedule cover caching when artwork is available.

-
- -
-
-
-
- 0 - Tracked shows -
-
- 0 - Sub episodes -
-
- 0 - Dub episodes -
-
-
- -
-
-
-

Watchlist library

-

Compact poster grid for faster scanning.

-
-
-
-
-
-
-
- - - - -""" - - def server_host(): return os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1").strip() or "127.0.0.1" @@ -3664,6 +2511,7 @@ def parse_runtime_flags(argv): def main(): parse_runtime_flags(sys.argv[1:]) + initialize_runtime(start_workers=True) host = server_host() port = server_port() server = ThreadingHTTPServer((host, port), Handler) diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..6acb910 --- /dev/null +++ b/test_app.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +import importlib.util +import io +import os +import sys +import tempfile +import threading +import unittest +from http import HTTPStatus +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parent +MODULE_PATH = ROOT / "app.py" +TEMP_STATE = tempfile.TemporaryDirectory() + +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +os.environ.setdefault("ANI_CLI_WEB_DISABLE_WORKER", "1") +os.environ["ANI_CLI_WEB_STATE_ROOT"] = TEMP_STATE.name + +SPEC = importlib.util.spec_from_file_location("ani_cli_web_app", MODULE_PATH) +APP = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(APP) +IMPORT_RUNTIME_WAS_DEFERRED = ( + APP.CONFIG is None and APP.WATCHLIST is None and APP.DOWNLOAD_QUEUE is None and APP.WATCHLIST_REFRESH is None +) +APP.initialize_runtime(start_workers=False) + + +class DummyHandler: + def __init__(self, path, body=b"{}", content_length=None): + self.path = path + self.client_address = ("127.0.0.1", 8421) + self.headers = {} + if content_length is not None: + self.headers["Content-Length"] = str(content_length) + self.rfile = io.BytesIO(body) + self.json_payload = None + self.json_status = None + self.error_status = None + self.error_message = None + self.file_path = None + + def ensure_client_access(self): + return APP.Handler.ensure_client_access(self) + + def body_json(self): + return APP.Handler.body_json(self) + + def json(self, payload, status=HTTPStatus.OK): + self.json_payload = payload + self.json_status = status + + def error(self, status, message): + self.error_status = status + self.error_message = message + + def file(self, path): + self.file_path = path + + def html(self, _content): + raise AssertionError("HTML should not be served in this test") + + def exception(self, exc): + return APP.Handler.exception(self, exc) + + +class NetworkGuardTests(unittest.TestCase): + def test_runtime_bootstrap_is_deferred_until_initialize(self): + self.assertTrue(IMPORT_RUNTIME_WAS_DEFERRED) + + def test_client_address_is_local_accepts_loopback(self): + self.assertTrue(APP.client_address_is_local("127.0.0.1")) + self.assertTrue(APP.client_address_is_local("::1")) + self.assertTrue(APP.client_address_is_local("localhost")) + + def test_client_address_is_local_rejects_non_loopback(self): + self.assertFalse(APP.client_address_is_local("192.168.1.25")) + self.assertFalse(APP.client_address_is_local("10.0.0.8")) + + +class DownloadQueueCancelTests(unittest.TestCase): + def test_cancel_updates_inflight_job_state(self): + queue = object.__new__(APP.DownloadQueue) + queue.lock = threading.RLock() + queue.current_job_id = "job-1" + queue.current_process = mock.Mock(pid=4321) + queue.current_job = {"id": "job-1", "status": "running", "cancel_requested": False} + + saved = [] + queue._save_job_locked = lambda job: saved.append(dict(job)) + queue._find = lambda job_id: {"id": job_id, "status": "running", "cancel_requested": False} + + with mock.patch.object(APP.os, "getpgid", return_value=4321), mock.patch.object(APP.os, "killpg") as killpg: + result = APP.DownloadQueue.cancel(queue, "job-1") + + killpg.assert_called_once() + self.assertTrue(result["cancel_requested"]) + self.assertTrue(queue.current_job["cancel_requested"]) + self.assertEqual(saved[-1]["id"], "job-1") + self.assertTrue(saved[-1]["cancel_requested"]) + + +class WatchlistRefreshAllTests(unittest.TestCase): + def test_refresh_all_iterates_every_show_id(self): + store = object.__new__(APP.WatchlistStore) + calls = [] + store.all_show_ids = lambda: ["show-a", "show-b", "show-c"] + store.refresh = lambda show_id: calls.append(show_id) or {"show_id": show_id} + + result = APP.WatchlistStore.refresh_all(store) + + self.assertEqual(calls, ["show-a", "show-b", "show-c"]) + self.assertEqual(result["count"], 3) + self.assertEqual([item["show_id"] for item in result["items"]], calls) + + +class WatchlistRefreshJobTests(unittest.TestCase): + def setUp(self): + with APP.WatchlistRefreshJobs(APP.WATCHLIST, start_worker=False)._connect() as conn: + conn.execute("DELETE FROM watchlist_refresh_jobs") + + def test_refresh_job_tracks_completion_and_errors(self): + class FakeStore: + def all_show_ids(self): + return ["show-a", "show-b"] + + def refresh(self, show_id): + if show_id == "show-a": + return {"title": "Show A", "status": "updated"} + return {"title": "Show B", "status": "error"} + + service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + started = service.start() + self.assertTrue(started["created"]) + + job = service._next_pending() + self.assertIsNotNone(job) + service._run_job(job) + + status = service.status()["job"] + self.assertIsNotNone(status) + self.assertEqual(status["status"], "done") + self.assertEqual(status["completed"], 2) + self.assertEqual(status["errors"], 1) + + def test_refresh_start_reuses_pending_job(self): + class FakeStore: + def all_show_ids(self): + return [] + + def refresh(self, _show_id): + return {"title": "Unused", "status": "updated"} + + service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + first = service.start() + second = service.start() + + self.assertTrue(first["created"]) + self.assertFalse(second["created"]) + self.assertEqual(second["job"]["id"], first["job"]["id"]) + self.assertEqual(second["job"]["status"], "pending") + + def test_refresh_history_marks_pending_jobs_interrupted_after_restart(self): + class FakeStore: + def all_show_ids(self): + return [] + + def refresh(self, _show_id): + return {"title": "Unused", "status": "updated"} + + service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + started = service.start() + restarted = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + + status = restarted.status()["job"] + self.assertEqual(status["id"], started["job"]["id"]) + self.assertEqual(status["status"], "interrupted") + self.assertIn("restart", status["message"].lower()) + + +class ThumbnailEventRecoveryTests(unittest.TestCase): + def test_ensure_thumbnail_clears_inflight_event_after_update_failure(self): + store = object.__new__(APP.WatchlistStore) + store.lock = threading.RLock() + store.thumbnail_events = {} + store.get = lambda _show_id: { + "show_id": "show-1", + "title": "Show One", + "thumbnail_path": None, + "thumbnail_checked_at": None, + "thumbnail_ready": False, + "animeschedule_route": None, + "animeschedule_title": None, + "anidb_aid": None, + "anidb_title": None, + } + + class BrokenConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, _sql, _params): + raise RuntimeError("db write failed") + + store._connect = lambda: BrokenConn() + + with mock.patch.object(APP, "fetch_animeschedule_cover", return_value={"route": "route-1", "title": "Show One", "url": "https://example.com/cover.jpg"}), mock.patch.object( + APP, "cache_thumbnail_image", return_value="show-1.jpg" + ): + with self.assertRaises(RuntimeError): + APP.WatchlistStore.ensure_thumbnail(store, "show-1", force=False) + + self.assertEqual(store.thumbnail_events, {}) + + +class HandlerRouteTests(unittest.TestCase): + def test_body_json_rejects_oversized_request(self): + handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1) + with self.assertRaises(APP.HttpError) as ctx: + APP.Handler.body_json(handler) + self.assertEqual(ctx.exception.status, HTTPStatus.REQUEST_ENTITY_TOO_LARGE) + + def test_body_json_rejects_non_utf8_payload(self): + handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"\xff", content_length=1) + with self.assertRaises(ValueError) as ctx: + APP.Handler.body_json(handler) + self.assertIn("utf-8", str(ctx.exception).lower()) + + 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) + thumb_path.write_bytes(b"jpg") + + handler = DummyHandler("/api/watchlist/thumb/show-1") + with mock.patch.object(APP.WATCHLIST, "get", return_value={"thumbnail_path": "show-1.jpg"}) as get_item, mock.patch.object( + APP.WATCHLIST, "ensure_thumbnail", side_effect=AssertionError("thumbnail fetch should not run") + ): + APP.Handler.do_GET(handler) + + get_item.assert_called_once_with("show-1") + self.assertEqual(handler.file_path, thumb_path) + self.assertIsNone(handler.error_status) + + def test_watchlist_refresh_status_route_returns_json(self): + handler = DummyHandler("/api/watchlist/refresh-status") + payload = {"job": {"id": "refresh-1", "status": "running"}, "running": True} + with mock.patch.object(APP.WATCHLIST_REFRESH, "status", return_value=payload): + APP.Handler.do_GET(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.OK) + + def test_update_all_post_returns_accepted_for_new_job(self): + handler = DummyHandler("/api/watchlist/update-all", body=b"{}", content_length=2) + payload = {"message": "Watchlist refresh started.", "job": {"id": "refresh-1"}, "created": True} + with mock.patch.object(APP.WATCHLIST_REFRESH, "start", return_value=payload): + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.ACCEPTED) + + +class TemplateHelperTests(unittest.TestCase): + def test_page_links_marks_active_page(self): + markup = APP.render_page_links("config") + self.assertIn('class="page-link active" href="/config"', markup) + self.assertIn('class="page-link" href="/watchlist"', markup) + + def test_sidebar_brand_can_attach_optional_badge_id(self): + markup = APP.render_sidebar_brand("Shared note", "Search", badge_id="serverBadge") + self.assertIn('id="serverBadge"', markup) + self.assertIn("Shared note", markup) + + +if __name__ == "__main__": + unittest.main() diff --git a/web_templates.py b/web_templates.py new file mode 100644 index 0000000..dc9e3e3 --- /dev/null +++ b/web_templates.py @@ -0,0 +1,2122 @@ +#!/usr/bin/env python3 + +"""Shared inline page templates for ani-cli-web.""" + +PAGE_LINKS = ( + ("search", "Search", "/"), + ("config", "Config", "/config"), + ("watchlist", "Watchlist", "/watchlist"), +) + + +def render_sidebar_brand(note, badge, badge_id=""): + badge_attr = f' id="{badge_id}"' if badge_id else "" + return ( + '
\n' + '
\n' + '

ani-cli web

\n' + f"

{note}

\n" + "
\n" + f" {badge}\n" + "
\n" + ) + + +def render_page_links(active_page): + links = [] + for key, label, href in PAGE_LINKS: + class_name = "page-link active" if key == active_page else "page-link" + links.append(f' {label}') + return "
\n" + "\n".join(links) + "\n
\n" + + +INDEX_HTML = r""" + + + + + ani-cli web + + + +
+ + +
+
+
+
+

Select a result

+

Episodes will appear here.

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+
+
+ +
+
+

Download queue

+
+ + +
+
+
+
+
+
+
+ + + + +""" + + +CONFIG_HTML = r""" + + + + + ani-cli web - config + + + +
+ + +
+
+
+
+

Defaults

+

Manage the folder, mode, and quality that the Search page starts with.

+
+ +
+
+
+ +
+
+ + +
+
Tip: these are the saved defaults only. The active search form can still be adjusted per download.
+
+ +
+
+
+ + + + +""" + + +WATCHLIST_HTML = r""" + + + + + ani-cli web - watchlist + + + +
+ + +
+
+
+
+

Tracked shows

+

Compact cards with local AnimeSchedule cover caching when artwork is available.

+
+ +
+
+
+
+ 0 + Tracked shows +
+
+ 0 + Sub episodes +
+
+ 0 + Dub episodes +
+
+
+ +
+
+
+

Watchlist library

+

Compact poster grid for faster scanning.

+

+
+
+
+
+
+
+
+ + + + +"""