#!/usr/bin/env python3 """HTTP handler and route wiring for ani-cli-web.""" import json import mimetypes import os import shutil import traceback from dataclasses import dataclass from http.cookies import SimpleCookie from http import HTTPStatus from http.server import BaseHTTPRequestHandler from pathlib import Path from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse from app_support import ( ANI_CLI, APP_NAME, CLIENT_DISCONNECT_ERRORS, MAX_JSON_BODY_BYTES, MODE_CHOICES, VERSION, client_address_is_local, debug_enabled, debug_log, normalize_config, remote_access_allowed, remote_access_token_configured, remote_access_token_matches, send_discord_webhook_event, ) @dataclass(frozen=True) class HandlerContext: add_to_watchlist: object config_html: str download_watchlist_item: object ensure_runtime: object episode_list: object get_config_snapshot: object get_watchlist: object get_watchlist_refresh_status: object http_error: object index_html: str queue_html: str remove_from_watchlist: object runtime_state: object save_runtime_config: object search_anime: object test_discord_webhook_config: object thumbnail_file_path: object update_all_watchlist_statuses: object update_watchlist_category: object update_watchlist_status: object upload_watchlist_thumbnail: object watchlist_html: str def build_handler_class(context): class ConfiguredHandler(Handler): handler_context = context return ConfiguredHandler def dependency_status(): checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"] result = {name: bool(shutil.which(name)) for name in checks} result["ani-cli"] = bool(shutil.which(ANI_CLI) or (Path(ANI_CLI).exists() and os.access(ANI_CLI, os.X_OK))) return result class Handler(BaseHTTPRequestHandler): server_version = "AniCliWeb/1.0" remote_token_cookie_name = "ani_cli_web_token" remote_token_cookie_max_age_seconds = 30 * 24 * 60 * 60 quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status"} def _context(self): context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None) if context is None: raise RuntimeError("Handler context is not configured.") return context def _runtime(self): context = Handler._context(self) context.ensure_runtime(start_workers=True) return context.runtime_state() def _effective_client_host(self): peer_host = self.client_address[0] if self.client_address else "" if not client_address_is_local(peer_host): return peer_host forwarded = ( self.headers.get("X-Forwarded-For") or self.headers.get("x-forwarded-for") or self.headers.get("X-Real-IP") or self.headers.get("x-real-ip") or "" ) if forwarded: first = str(forwarded).split(",", 1)[0].strip().strip("[]") if first: return first forwarded_header = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip() if forwarded_header: for part in forwarded_header.split(";"): key, _, value = part.partition("=") if key.strip().lower() != "for": continue candidate = value.strip().strip('"') if candidate.startswith("[") and "]" in candidate: candidate = candidate[1 : candidate.index("]")] elif ":" in candidate and candidate.count(":") == 1: candidate = candidate.split(":", 1)[0] candidate = candidate.strip() if candidate: return candidate return peer_host def _remote_token(self): return ( self.headers.get("X-AniCli-Web-Token") or self.headers.get("x-ani-cli-web-token") or Handler._bearer_token(self) or Handler._cookie_token(self) or Handler._query_token(self) or "" ) def _bearer_token(self): header = str(self.headers.get("Authorization") or "").strip() if not header.startswith("Bearer "): return "" return header[len("Bearer ") :].strip() def _cookie_token(self): raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip() if not raw: return "" try: cookie = SimpleCookie() cookie.load(raw) except Exception: return "" morsel = cookie.get(Handler.remote_token_cookie_name) if not morsel: return "" return morsel.value.strip() def _query_token(self): parsed = urlparse(self.path) params = parse_qs(parsed.query, keep_blank_values=True) for key in ("token", "remote_token"): values = params.get(key) or [] for value in values: text = str(value).strip() if text: return text return "" def _normalized_next_path(self, value): raw = str(value or "").strip() if not raw: return "/" parsed = urlparse(raw) if parsed.scheme or parsed.netloc: return "/" path = parsed.path or "/" if not path.startswith("/"): return "/" if path == "/auth/login": path = "/" query = parsed.query or "" return urlunparse(("", "", path, "", query, "")) def _current_path_without_tokens(self, parsed): params = parse_qs(parsed.query, keep_blank_values=True) params.pop("token", None) params.pop("remote_token", None) query = urlencode(params, doseq=True) return urlunparse(("", "", parsed.path or "/", "", query, "")) def _remote_auth_cookie(self, token): return ( f"{Handler.remote_token_cookie_name}={token}; " f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_token_cookie_max_age_seconds}" ) def _set_remote_auth_cookie_and_redirect(self, token, location): Handler.write_response_bytes( self, b"", HTTPStatus.SEE_OTHER, { "Location": Handler._normalized_next_path(self, location), "Set-Cookie": Handler._remote_auth_cookie(self, token), }, ) def _remote_cookie_bootstrap_response(self, parsed): client_host = Handler._effective_client_host(self) if client_address_is_local(client_host): return False if not remote_access_allowed() or not remote_access_token_configured(): return False if Handler._cookie_token(self): return False token = Handler._query_token(self) if not token or not remote_access_token_matches(token): return False location = Handler._current_path_without_tokens(self, parsed) Handler._set_remote_auth_cookie_and_redirect(self, token, location) return True def _is_browser_page_request(self, parsed): if getattr(self, "command", "") != "GET": return False return not str(parsed.path or "").startswith("/api/") def _render_remote_login_page(self, parsed, message=""): next_path = Handler._current_path_without_tokens(self, parsed) error_html = "" if message: error_html = f'

{message}

' html = f""" {APP_NAME} sign in

{APP_NAME}

Enter the remote access token for this browser. After that, the app keeps a persistent auth cookie and future visits stay signed in.

{error_html}

API clients can still use Authorization: Bearer <token> or ?token=....

""" Handler.write_response_bytes( self, html.encode("utf-8"), HTTPStatus.UNAUTHORIZED, {"Content-Type": "text/html; charset=utf-8"}, ) return True def ensure_client_access(self): client_host = Handler._effective_client_host(self) if client_address_is_local(client_host): return if not remote_access_allowed(): raise PermissionError( "Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients." ) if not remote_access_token_configured(): raise PermissionError( "Remote access requires ANI_CLI_WEB_REMOTE_TOKEN for non-local clients." ) if not remote_access_token_matches(Handler._remote_token(self)): raise PermissionError("Remote access token missing or invalid.") def do_GET(self): parsed = urlparse(self.path) try: if Handler._remote_cookie_bootstrap_response(self, parsed): return if parsed.path == "/auth/login": if Handler._cookie_token(self) and remote_access_token_matches(Handler._cookie_token(self)): params = parse_qs(parsed.query, keep_blank_values=True) next_path = (params.get("next") or ["/"])[0] Handler._set_remote_auth_cookie_and_redirect(self, Handler._cookie_token(self), next_path) return if remote_access_allowed() and remote_access_token_configured(): Handler._render_remote_login_page(self, parsed) return self.ensure_client_access() if parsed.path == "/": self.html(Handler._context(self).index_html) elif parsed.path == "/queue": self.html(Handler._context(self).queue_html) elif parsed.path == "/config": self.html(Handler._context(self).config_html) elif parsed.path == "/watchlist": self.html(Handler._context(self).watchlist_html) elif parsed.path == "/api/config": self.json(Handler._context(self).get_config_snapshot()) elif parsed.path == "/api/version": self.json({"name": APP_NAME, "version": VERSION}) elif parsed.path == "/api/dependencies": self.json(dependency_status()) elif parsed.path == "/api/search": runtime = Handler._runtime(self) config = runtime["config"] params = parse_qs(parsed.query) query = (params.get("q") or [""])[0].strip() mode = (params.get("mode") or [config["mode"]])[0].lower() if not query: raise ValueError("Search query is required") if mode not in MODE_CHOICES: raise ValueError("Mode must be sub or dub") self.json({"results": Handler._context(self).search_anime(query, mode)}) elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"): runtime = Handler._runtime(self) config = runtime["config"] show_id = unquote(parsed.path[len("/api/anime/") : -len("/episodes")]) params = parse_qs(parsed.query) mode = (params.get("mode") or [config["mode"]])[0].lower() if mode not in MODE_CHOICES: raise ValueError("Mode must be sub or dub") self.json({"episodes": Handler._context(self).episode_list(show_id, mode)}) elif parsed.path == "/api/queue": runtime = Handler._runtime(self) params = parse_qs(parsed.query) page = (params.get("page") or ["1"])[0] per_page = (params.get("per_page") or ["10"])[0] self.json(runtime["download_queue"].list(page=page, per_page=per_page)) elif parsed.path.startswith("/api/watchlist/thumb/"): runtime = Handler._runtime(self) show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip() debug_log("thumbnail.route.request", show_id=show_id, path=parsed.path, query=parsed.query) item = runtime["watchlist"].get(show_id) path = Handler._context(self).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") return debug_log("thumbnail.route.hit", show_id=show_id, resolved_path=path) self.file(path) elif parsed.path in {"/api/watchlist", "/api/watchlist/get"}: Handler._runtime(self) params = parse_qs(parsed.query) page = (params.get("page") or ["1"])[0] per_page = (params.get("per_page") or ["30"])[0] category = (params.get("category") or [""])[0].strip().lower() or None self.json(Handler._context(self).get_watchlist(page=page, per_page=per_page, category=category)) elif parsed.path == "/api/watchlist/refresh-status": Handler._runtime(self) self.json(Handler._context(self).get_watchlist_refresh_status()) else: self.error(HTTPStatus.NOT_FOUND, "Not found") except Exception as exc: self.exception(exc) def do_POST(self): parsed = urlparse(self.path) try: if parsed.path == "/auth/login": if not remote_access_allowed() or not remote_access_token_configured(): raise PermissionError("Remote access sign-in is not available.") payload = Handler.body_form(self) token = str(payload.get("token") or "").strip() next_path = payload.get("next") or "/" if not remote_access_token_matches(token): Handler._render_remote_login_page( self, urlparse(Handler._normalized_next_path(self, next_path)), "Remote access token missing or invalid.", ) return Handler._set_remote_auth_cookie_and_redirect(self, token, next_path) return self.ensure_client_access() if parsed.path == "/api/config": Handler.update_config(self, Handler.require_json_object(self, self.body_json())) elif parsed.path == "/api/config/webhook/test": payload = Handler.require_json_object(self, self.body_json()) self.json(Handler._context(self).test_discord_webhook_config(payload)) elif parsed.path == "/api/queue": runtime = Handler._runtime(self) self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED) elif parsed.path == "/api/queue/retry-failed": runtime = Handler._runtime(self) self.json(runtime["download_queue"].retry_all_failed()) elif parsed.path == "/api/queue/clear-finished": runtime = Handler._runtime(self) self.json(runtime["download_queue"].clear_finished()) elif parsed.path.startswith("/api/queue/"): runtime = Handler._runtime(self) parts = parsed.path.strip("/").split("/") if len(parts) != 4: self.error(HTTPStatus.NOT_FOUND, "Not found") return _, _, job_id, action = parts actions = { "retry": runtime["download_queue"].retry, "cancel": runtime["download_queue"].cancel, "remove": runtime["download_queue"].remove, } if action not in actions: self.error(HTTPStatus.NOT_FOUND, "Not found") return self.json(actions[action](job_id)) elif parsed.path in {"/api/watchlist", "/api/watchlist/add"}: Handler._runtime(self) result = Handler._context(self).add_to_watchlist(Handler.require_json_object(self, 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 = Handler.require_json_object(self, self.body_json()) self.json( runtime["watchlist"].ensure_thumbnails( Handler.optional_list_field(self, payload, "show_ids") or [], limit=payload.get("limit", 6), force=Handler.optional_bool_field(self, payload, "force", default=False), ) ) elif parsed.path == "/api/watchlist/update-status": 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") self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category"))) elif parsed.path == "/api/watchlist/update-all": Handler._runtime(self) result = Handler._context(self).update_all_watchlist_statuses() status = HTTPStatus.ACCEPTED if result.get("created") else HTTPStatus.OK self.json(result, status) elif parsed.path == "/api/watchlist/remove": Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id") self.json(Handler._context(self).remove_from_watchlist(payload["show_id"])) elif parsed.path == "/api/watchlist/upload-thumbnail": Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id", "data_url") self.json(Handler._context(self).upload_watchlist_thumbnail(payload)) else: self.error(HTTPStatus.NOT_FOUND, "Not found") except Exception as exc: self.exception(exc) def update_config(self, payload): config = normalize_config(payload) Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True) context = Handler._context(self) context.save_runtime_config(config) self.json(context.get_config_snapshot()) def require_fields(self, payload, *names): 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: raise ValueError(f"Missing required field: {missing[0]}") 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") 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 Handler._context(self).http_error(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 Handler._context(self).http_error(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: raise ValueError("Invalid JSON body") from exc def body_form(self): 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 Handler._context(self).http_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.") raw_bytes = self.rfile.read(length) if length else b"" try: raw = raw_bytes.decode("utf-8") except UnicodeDecodeError as exc: raise ValueError("Request body must be valid UTF-8 form data.") from exc params = parse_qs(raw, keep_blank_values=True) return {key: values[-1] if values else "" for key, values in params.items()} def html(self, content): data = content.encode("utf-8") self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=utf-8"}) def file(self, path): payload = Path(path).read_bytes() content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream" debug_log("file.serve", path=path, content_type=content_type, bytes=len(payload)) self.write_response_bytes( payload, HTTPStatus.OK, { "Content-Type": content_type, "Cache-Control": "public, max-age=86400", }, ) def json(self, payload, status=HTTPStatus.OK): data = json.dumps(payload).encode("utf-8") self.write_response_bytes(data, status, {"Content-Type": "application/json"}) def write_response_bytes(self, payload, status, headers): try: self.send_response(status) for key, value in headers.items(): self.send_header(key, value) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) except CLIENT_DISCONNECT_ERRORS: return def error(self, status, message): try: self.json({"error": message}, status) except CLIENT_DISCONNECT_ERRORS: return def exception(self, exc): if isinstance(exc, CLIENT_DISCONNECT_ERRORS): return if isinstance(exc, Handler._context(self).http_error): self.error(exc.status, exc.message) elif isinstance(exc, KeyError): self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'")) elif isinstance(exc, PermissionError): parsed = urlparse(getattr(self, "path", "") or "/") if remote_access_allowed() and remote_access_token_configured() and Handler._is_browser_page_request(self, parsed): Handler._render_remote_login_page(self, parsed, str(exc)) else: self.error(HTTPStatus.FORBIDDEN, str(exc)) elif isinstance(exc, ValueError): self.error(HTTPStatus.BAD_REQUEST, str(exc)) else: if debug_enabled(): debug_log("handler.exception", path=getattr(self, "path", ""), error=exc) traceback.print_exc() else: debug_log("handler.exception", path=getattr(self, "path", ""), error=exc) try: send_discord_webhook_event( Handler._context(self).get_config_snapshot(), "runtime_error", { "source": "http_handler", "error": str(exc), "details": f"path={getattr(self, 'path', '')}", }, ) except Exception as notify_exc: debug_log("discord_webhook.runtime_error_failed", source="http_handler", error=notify_exc) self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.") def log_message(self, fmt, *args): parsed = urlparse(getattr(self, "path", "") or "") if self.command == "GET" and parsed.path in Handler.quiet_poll_paths: return print(f"{self.address_string()} - {fmt % args}") def server_host(): return os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1").strip() or "127.0.0.1" def server_port(): raw = str(os.environ.get("ANI_CLI_WEB_PORT", "8421")).strip() or "8421" try: port = int(raw, 10) except ValueError as exc: raise ValueError("ANI_CLI_WEB_PORT must be a valid integer") from exc if not 1 <= port <= 65535: raise ValueError("ANI_CLI_WEB_PORT must be between 1 and 65535") return port