#!/usr/bin/env python3
"""HTTP handler and route wiring for Kaizoku."""
import json
import mimetypes
import os
import shutil
import subprocess
import traceback
from base64 import b64decode
from binascii import Error as BinasciiError
from dataclasses import dataclass
from functools import lru_cache
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,
load_project_text_file,
cli_executable_exists_any,
cli_executable_exists,
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_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"]
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
@lru_cache(maxsize=1)
def installed_ani_cli_version():
return "Disregarded"
@lru_cache(maxsize=1)
def installed_anipy_cli_version():
return "Disregarded"
@lru_cache(maxsize=1)
def installed_animdl_version():
return "Disregarded"
class Handler(BaseHTTPRequestHandler):
server_version = "Kaizoku/0.1"
remote_session_cookie_name = "ani_cli_web_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 ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients."
)
if not remote_access_credentials_configured():
raise PermissionError(
"Remote access requires ANI_CLI_WEB_AUTH_USERNAME and ANI_CLI_WEB_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,
"ani_cli_version": installed_ani_cli_version(),
"anipy_cli_version": installed_anipy_cli_version(),
"animdl_version": installed_animdl_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/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()
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 == "/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 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