#!/usr/bin/env python3 """HTTP handler and route wiring for Kaizoku.""" import json import mimetypes import os import shutil import traceback from base64 import b64decode from binascii import Error as BinasciiError from dataclasses import dataclass from http import HTTPStatus from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler from html import escape as html_escape from pathlib import Path from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse import provider_bridge from app_support import ( APP_NAME, CHANGELOG_FILE, CLIENT_DISCONNECT_ERRORS, MAX_JSON_BODY_BYTES, MODE_CHOICES, PROJECT_ROOT, VERSION, client_address_is_local, configured_remote_path_roots, debug_enabled, debug_log, env_value, load_project_text_file, normalize_config, path_is_within_roots, remote_access_allowed, remote_access_credentials_configured, remote_access_credentials_match, remote_access_session_matches, remote_access_session_value, sanitize_public_config, send_discord_webhook_event, validate_discord_webhook_url, ) FAVICON_PATH = PROJECT_ROOT / "favicon.png" @dataclass(frozen=True) class HandlerContext: add_to_watchlist: object config_html: str download_watchlist_item: object ensure_runtime: object episode_list: object export_watchlist_backup: object get_config_snapshot: object get_jellyfin_sync_status: object get_provider_update_status: object get_watchlist_homepage_summary: object get_watchlist: object get_watchlist_refresh_status: object http_error: object index_html: str import_watchlist_backup: object queue_html: str reconcile_watchlist_downloaded_files: object remove_from_watchlist: object run_jellyfin_sync: object runtime_state: object save_runtime_config: object search_anime: object resolve_search_thumbnail: object start_jellyfin_sync: object test_discord_webhook_config: object thumbnail_file_path: object update_all_watchlist_statuses: object update_watchlist_auto_download: object update_watchlist_category: object update_watchlist_media_type: 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 = ["node", "python3", "ffmpeg", "openssl"] result = {name: bool(shutil.which(name)) for name in checks} result["provider-bridge"] = provider_bridge.BRIDGE.exists() return result def browse_filesystem(path_value="", mode="dir", allowed_roots=None): raw_mode = str(mode or "dir").strip().lower() browse_mode = raw_mode if raw_mode in {"dir", "file"} else "dir" raw_path = str(path_value or "").strip() if raw_path: base_path = Path(raw_path).expanduser() elif allowed_roots: base_path = allowed_roots[0] else: base_path = Path.home() try: resolved = base_path.resolve() except OSError as exc: raise ValueError(f"Could not access path: {exc}") from exc if not resolved.exists(): raise ValueError("Selected path does not exist.") if resolved.is_file(): resolved = resolved.parent if not resolved.is_dir(): raise ValueError("Selected path is not a directory.") if allowed_roots and not path_is_within_roots(resolved, allowed_roots): raise PermissionError("That path is outside the allowed remote browse roots.") entries = [] try: for child in resolved.iterdir(): try: is_dir = child.is_dir() except OSError: continue if allowed_roots and not path_is_within_roots(child, allowed_roots): continue if browse_mode == "dir" and not is_dir: continue entries.append( { "name": child.name, "path": str(child), "is_dir": is_dir, } ) except OSError as exc: raise ValueError(f"Could not list directory: {exc}") from exc entries.sort(key=lambda item: (not item["is_dir"], item["name"].lower(), item["name"])) parent_path = str(resolved.parent if resolved.parent != resolved else resolved) if allowed_roots and not path_is_within_roots(parent_path, allowed_roots): parent_path = str(resolved) root_paths = [str(Path(root)) for root in (allowed_roots or [])] return { "path": str(resolved), "parent_path": parent_path, "home_path": str(allowed_roots[0] if allowed_roots else Path.home()), "root_paths": root_paths, "mode": browse_mode, "entries": entries, } def queue_list_payload(runtime, page=1, per_page=10): page = max(1, int(page or 1)) per_page = min(50, max(1, int(per_page or 10))) download_queue = runtime["download_queue"] jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") download_payload = download_queue.list(page=1, per_page=50) jobs = list(download_payload.get("jobs") or []) for download_page in range(2, int(download_payload.get("pages") or 1) + 1): jobs.extend(download_queue.list(page=download_page, per_page=50).get("jobs") or []) if jellyfin_jobs is not None: jobs.extend(jellyfin_jobs.list_queue_jobs(limit=200)) jobs.sort(key=lambda job: str(job.get("created_at") or ""), reverse=True) total = len(jobs) pages = max(1, (total + per_page - 1) // per_page) offset = (page - 1) * per_page return { "jobs": jobs[offset : offset + per_page], "page": page, "per_page": per_page, "total": total, "pages": pages, } def queue_get_job(runtime, job_id): try: return runtime["download_queue"].get(job_id) except KeyError: jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") if jellyfin_jobs is None: raise return jellyfin_jobs.get_queue_job(job_id) def queue_remove_job(runtime, job_id): try: return runtime["download_queue"].remove(job_id) except KeyError: jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") if jellyfin_jobs is None: raise return jellyfin_jobs.remove_queue_job(job_id) def queue_clear_finished(runtime): result = runtime["download_queue"].clear_finished() jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") if jellyfin_jobs is not None: result["count"] = int(result.get("count") or 0) + int(jellyfin_jobs.clear_finished_queue_jobs().get("count") or 0) return result def queue_clear_failed(runtime): result = runtime["download_queue"].clear_failed() jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") if jellyfin_jobs is not None: result["count"] = int(result.get("count") or 0) + int(jellyfin_jobs.clear_failed_queue_jobs().get("count") or 0) return result class Handler(BaseHTTPRequestHandler): server_version = "Kaizoku/0.1" remote_session_cookie_name = "kaizoku_session" remote_session_cookie_max_age_seconds = 30 * 24 * 60 * 60 quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status", "/api/config/jellyfin/sync-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 _authorization_header(self): return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip() def _host_header(self): host = str(self.headers.get("X-Forwarded-Host") or self.headers.get("x-forwarded-host") or "").strip() if host: return host.split(",", 1)[0].strip() return str(self.headers.get("Host") or self.headers.get("host") or "").strip() def _origin(self): return str(self.headers.get("Origin") or self.headers.get("origin") or "").strip() def _content_type(self): return str(self.headers.get("Content-Type") or self.headers.get("content-type") or "").strip() def _request_is_secure(self): forwarded_proto = str(self.headers.get("X-Forwarded-Proto") or self.headers.get("x-forwarded-proto") or "").strip() if forwarded_proto: return forwarded_proto.split(",", 1)[0].strip().lower() == "https" forwarded = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip() if forwarded: for part in forwarded.split(";"): key, _, value = part.partition("=") if key.strip().lower() == "proto": return value.strip().strip('"').lower() == "https" return False def _origin_matches_host(self): origin = Handler._origin(self) if not origin: return False parsed = urlparse(origin) if parsed.scheme not in {"http", "https"} or not parsed.netloc: return False return parsed.netloc.lower() == Handler._host_header(self).lower() def _basic_auth_credentials(self): header = Handler._authorization_header(self) if not header.startswith("Basic "): return ("", "") encoded = header[len("Basic ") :].strip() if not encoded: return ("", "") try: decoded = b64decode(encoded.encode("ascii"), validate=True).decode("utf-8") except (BinasciiError, UnicodeDecodeError, ValueError): return ("", "") username, separator, password = decoded.partition(":") if not separator: return ("", "") return (username.strip(), password.strip()) 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_value(self, name): 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(name) if not morsel: return "" return morsel.value.strip() def _session_cookie(self): return Handler._cookie_value(self, Handler.remote_session_cookie_name) def _remote_credentials_valid(self): session = Handler._session_cookie(self) if session and remote_access_session_matches(session): return True username, password = Handler._basic_auth_credentials(self) return remote_access_credentials_match(username, password) 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_auth_query(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, session_value): attributes = ( f"{Handler.remote_session_cookie_name}={session_value}; " f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}" ) if Handler._request_is_secure(self): attributes += "; Secure" return attributes def _clear_remote_auth_cookie(self): attributes = ( f"{Handler.remote_session_cookie_name}=; " "Path=/; HttpOnly; SameSite=Lax; Max-Age=0" ) if Handler._request_is_secure(self): attributes += "; Secure" return attributes def _set_remote_auth_cookie_and_redirect(self, session_value, location): Handler.write_response_bytes( self, b"", HTTPStatus.SEE_OTHER, { "Location": Handler._normalized_next_path(self, location), "Set-Cookie": Handler._remote_auth_cookie(self, session_value), }, ) 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_auth_query(self, parsed) error_html = "" if message: error_html = f'

{html_escape(message)}

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

{APP_NAME}

Sign in with the configured username and password. After that, this browser keeps a persistent auth session cookie.

{error_html}

API clients can also use Authorization: Basic <base64(username:password)>.

""" 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 KAIZOKU_ALLOW_REMOTE=1 to allow non-local clients." ) if not remote_access_credentials_configured(): raise PermissionError( "Remote access requires KAIZOKU_AUTH_USERNAME and KAIZOKU_AUTH_PASSWORD for non-local clients." ) if not Handler._remote_credentials_valid(self): raise PermissionError("Remote username or password missing or invalid.") def enforce_same_origin(self): origin = Handler._origin(self) if not origin: if Handler._session_cookie(self): raise PermissionError("Browser API requests must include a same-origin Origin header.") return if not Handler._origin_matches_host(self): raise PermissionError("Cross-origin browser requests are not allowed.") def _remote_path_roots(self): return configured_remote_path_roots(Handler._context(self).get_config_snapshot()) def validate_remote_path(self, path_value, label): client_host = Handler._effective_client_host(self) if client_address_is_local(client_host): return roots = Handler._remote_path_roots(self) if not roots: raise PermissionError(f"Remote {label} changes are disabled until a server-side path root is configured.") if not path_is_within_roots(path_value, roots): raise PermissionError(f"Remote {label} must stay inside the configured remote path roots.") def do_GET(self): parsed = urlparse(self.path) try: if parsed.path == "/auth/login": if Handler._session_cookie(self) and remote_access_session_matches(Handler._session_cookie(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._session_cookie(self), next_path) return if remote_access_allowed() and remote_access_credentials_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 in {"/favicon.png", "/favicon.ico"}: if not FAVICON_PATH.exists(): self.error(HTTPStatus.NOT_FOUND, "Favicon not found") return self.file(FAVICON_PATH) 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(sanitize_public_config(Handler._context(self).get_config_snapshot())) elif parsed.path == "/api/version": self.json( { "name": APP_NAME, "version": VERSION, "providers": list(provider_bridge.PROVIDERS), } ) elif parsed.path == "/api/changelog": self.json({"content": load_project_text_file(CHANGELOG_FILE, default="Changelog unavailable.")}) elif parsed.path == "/api/dependencies": self.json(dependency_status()) elif parsed.path == "/api/provider-updates": self.json(Handler._context(self).get_provider_update_status()) elif parsed.path == "/api/fs/browse": params = parse_qs(parsed.query) client_host = Handler._effective_client_host(self) allowed_roots = None if client_address_is_local(client_host) else Handler._remote_path_roots(self) self.json( browse_filesystem( (params.get("path") or [""])[0], (params.get("mode") or ["dir"])[0], allowed_roots=allowed_roots, ) ) 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() provider = (params.get("provider") or [config.get("provider") or ""])[0].strip().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, provider=provider)}) elif parsed.path == "/api/search/thumb": Handler._runtime(self) params = parse_qs(parsed.query) title = (params.get("title") or [""])[0].strip() if not title: raise ValueError("Title is required") path = Handler._context(self).resolve_search_thumbnail(title) self.file(path) 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(queue_list_payload(runtime, page=page, per_page=per_page)) elif parsed.path.startswith("/api/queue/"): runtime = Handler._runtime(self) parts = parsed.path.strip("/").split("/") if len(parts) != 3: self.error(HTTPStatus.NOT_FOUND, "Not found") return _, _, job_id = parts self.json(queue_get_job(runtime, job_id)) 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 == "/api/watchlist/homepage": Handler._runtime(self) self.json(Handler._context(self).get_watchlist_homepage_summary()) 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()) elif parsed.path == "/api/config/jellyfin/sync-status": Handler._runtime(self) self.json(Handler._context(self).get_jellyfin_sync_status()) elif parsed.path == "/api/config/watchlist/export": Handler._runtime(self) self.json_attachment( Handler._context(self).export_watchlist_backup(), "kaizoku-watchlist-backup.json", ) 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_credentials_configured(): raise PermissionError("Remote access sign-in is not available.") payload = Handler.body_form(self) username = str(payload.get("username") or "").strip() password = str(payload.get("password") or "").strip() next_path = payload.get("next") or "/" if not remote_access_credentials_match(username, password): Handler._render_remote_login_page( self, urlparse(Handler._normalized_next_path(self, next_path)), "Username or password missing or invalid.", ) return Handler._set_remote_auth_cookie_and_redirect(self, remote_access_session_value(), next_path) return self.ensure_client_access() Handler.enforce_same_origin(self) if parsed.path == "/api/config": Handler.update_config(self, Handler.require_json_object(self, self.body_json())) elif parsed.path == "/api/config/jellyfin/sync": payload = Handler.require_json_object(self, self.body_json()) current = Handler._context(self).get_config_snapshot() merged = dict(current) merged.update(payload) for key, label in ( ("download_dir", "download directory"), ("jellyfin_tv_dir", "Jellyfin TV directory"), ("jellyfin_movie_dir", "Jellyfin movie directory"), ): if str(merged.get(key) or "").strip(): Handler.validate_remote_path(self, merged[key], label) self.json(Handler._context(self).start_jellyfin_sync(merged), HTTPStatus.ACCEPTED) elif parsed.path == "/api/config/watchlist/reconcile-downloads": payload = Handler.require_json_object(self, self.body_json()) current = Handler._context(self).get_config_snapshot() merged = dict(current) merged.update(payload) if str(merged.get("download_dir") or "").strip(): Handler.validate_remote_path(self, merged["download_dir"], "download directory") self.json(Handler._context(self).reconcile_watchlist_downloaded_files(merged)) elif parsed.path == "/api/config/watchlist/import": payload = Handler.require_json_object(self, self.body_json()) self.json(Handler._context(self).import_watchlist_backup(payload)) elif parsed.path == "/api/config/webhook/test": payload = Handler.require_json_object(self, self.body_json()) validate_discord_webhook_url(payload.get("discord_webhook_url")) self.json(Handler._context(self).test_discord_webhook_config(payload)) elif parsed.path == "/api/queue": runtime = Handler._runtime(self) payload = Handler.require_json_object(self, self.body_json()) if str(payload.get("download_dir") or "").strip(): Handler.validate_remote_path(self, payload["download_dir"], "download directory") self.json(runtime["download_queue"].add(payload), 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(queue_clear_finished(runtime)) elif parsed.path == "/api/queue/clear-failed": runtime = Handler._runtime(self) self.json(queue_clear_failed(runtime)) 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": lambda job_id: queue_remove_job(runtime, job_id), } 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-media-type": Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id") self.json(Handler._context(self).update_watchlist_media_type(payload["show_id"], payload.get("media_type"))) elif parsed.path == "/api/watchlist/update-auto-download": Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id") self.json( Handler._context(self).update_watchlist_auto_download( payload["show_id"], payload.get("mode"), payload.get("quality"), payload.get("name"), payload.get("source_name"), payload.get("series"), payload.get("episode_offset"), ) ) 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): payload = Handler.require_json_object(self, payload) current = Handler._context(self).get_config_snapshot() merged = dict(current) merged.update(payload) config = normalize_config(merged) for key, label in ( ("download_dir", "download directory"), ("jellyfin_tv_dir", "Jellyfin TV directory"), ("jellyfin_movie_dir", "Jellyfin movie directory"), ): if str(config.get(key) or "").strip(): Handler.validate_remote_path(self, config[key], label) if "discord_webhook_url" in payload and str(config.get("discord_webhook_url") or "").strip(): validate_discord_webhook_url(config["discord_webhook_url"]) Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True) context = Handler._context(self) context.save_runtime_config(payload) self.json(sanitize_public_config(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): content_type = Handler._content_type(self).split(";", 1)[0].strip().lower() if content_type != "application/json": raise ValueError("Request body must use Content-Type: application/json.") 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 json_attachment(self, payload, filename, status=HTTPStatus.OK): data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") safe_filename = str(filename or "watchlist-backup.json").replace('"', "") self.write_response_bytes( data, status, { "Content-Type": "application/json", "Content-Disposition": f'attachment; filename="{safe_filename}"', "Cache-Control": "no-store", }, ) 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_credentials_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 env_value("KAIZOKU_HOST", "127.0.0.1") or "127.0.0.1" def server_port(): raw = env_value("KAIZOKU_PORT", "8421") or "8421" try: port = int(raw, 10) except ValueError as exc: raise ValueError("KAIZOKU_PORT must be a valid integer") from exc if not 1 <= port <= 65535: raise ValueError("KAIZOKU_PORT must be between 1 and 65535") return port