Add a Queue page bulk action and API route to remove all failed jobs, cover it with regression tests, and update the version, changelog, and README for the new queue workflow.
981 lines
41 KiB
Python
981 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
|
|
"""HTTP handler and route wiring for ani-cli-web."""
|
|
|
|
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
|
|
|
|
from app_support import (
|
|
ANI_CLI,
|
|
ANIPY_CLI,
|
|
APP_NAME,
|
|
CHANGELOG_FILE,
|
|
CLIENT_DISCONNECT_ERRORS,
|
|
MAX_JSON_BODY_BYTES,
|
|
MODE_CHOICES,
|
|
VERSION,
|
|
client_address_is_local,
|
|
configured_remote_path_roots,
|
|
debug_enabled,
|
|
debug_log,
|
|
load_project_text_file,
|
|
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,
|
|
)
|
|
|
|
@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_jellyfin_sync_status: object
|
|
get_watchlist_homepage_summary: object
|
|
get_watchlist: object
|
|
get_watchlist_refresh_status: object
|
|
http_error: object
|
|
index_html: str
|
|
queue_html: str
|
|
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 = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
|
|
result = {name: bool(shutil.which(name)) for name in checks}
|
|
result["ani-cli"] = cli_executable_exists(ANI_CLI)
|
|
result["anipy-cli"] = cli_executable_exists(ANIPY_CLI)
|
|
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,
|
|
}
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def installed_ani_cli_version():
|
|
try:
|
|
completed = subprocess.run(
|
|
[ANI_CLI, "--version"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=6,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return "Unavailable"
|
|
version = str(completed.stdout or completed.stderr or "").strip()
|
|
return version or "Unavailable"
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def installed_anipy_cli_version():
|
|
try:
|
|
completed = subprocess.run(
|
|
[ANIPY_CLI, "--version"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=6,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return "Unavailable"
|
|
version = str(completed.stdout or completed.stderr or "").strip()
|
|
return version or "Unavailable"
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "AniCliWeb/1.0"
|
|
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'<p class="error">{html_escape(message)}</p>'
|
|
html = f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{APP_NAME} sign in</title>
|
|
<style>
|
|
:root {{
|
|
color-scheme: dark;
|
|
--bg: #0b0813;
|
|
--panel: rgba(25, 18, 41, 0.94);
|
|
--line: rgba(255, 255, 255, 0.08);
|
|
--text: #f6f2ff;
|
|
--muted: #b8b0cf;
|
|
--accent: #9d7bff;
|
|
--error: #ffb4c2;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 24px;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
|
|
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
|
|
color: var(--text);
|
|
font: 15px/1.5 "Segoe UI", Inter, system-ui, sans-serif;
|
|
}}
|
|
.panel {{
|
|
width: min(100%, 420px);
|
|
display: grid;
|
|
gap: 14px;
|
|
padding: 22px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 22px;
|
|
background: var(--panel);
|
|
}}
|
|
h1, p {{ margin: 0; }}
|
|
h1 {{ font-size: 28px; line-height: 1.05; }}
|
|
.muted {{ color: var(--muted); }}
|
|
.error {{
|
|
color: var(--error);
|
|
padding: 10px 12px;
|
|
border-radius: 14px;
|
|
background: rgba(255, 144, 164, 0.12);
|
|
border: 1px solid rgba(255, 144, 164, 0.18);
|
|
}}
|
|
form {{
|
|
display: grid;
|
|
gap: 12px;
|
|
}}
|
|
label {{
|
|
display: grid;
|
|
gap: 8px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}}
|
|
input {{
|
|
width: 100%;
|
|
min-height: 44px;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(10, 7, 18, 0.82);
|
|
color: var(--text);
|
|
padding: 0 13px;
|
|
}}
|
|
button {{
|
|
min-height: 42px;
|
|
border: 0;
|
|
border-radius: 14px;
|
|
background: linear-gradient(135deg, var(--accent), #7b5cff);
|
|
color: #fcfaff;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}}
|
|
code {{
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<section class="panel">
|
|
<div>
|
|
<h1>{APP_NAME}</h1>
|
|
<p class="muted">Sign in with the configured username and password. After that, this browser keeps a persistent auth session cookie.</p>
|
|
</div>
|
|
{error_html}
|
|
<form method="post" action="/auth/login">
|
|
<input type="hidden" name="next" value="{html_escape(next_path, quote=True)}">
|
|
<label>Username
|
|
<input name="username" type="text" autocomplete="username" autofocus required>
|
|
</label>
|
|
<label>Password
|
|
<input name="password" type="password" autocomplete="current-password" required>
|
|
</label>
|
|
<button type="submit">Sign in</button>
|
|
</form>
|
|
<p class="muted">API clients can also use <code>Authorization: Basic <base64(username:password)></code>.</p>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
"""
|
|
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 == "/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(),
|
|
}
|
|
)
|
|
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(runtime["download_queue"].list(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(runtime["download_queue"].get(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())
|
|
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/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(runtime["download_queue"].clear_finished())
|
|
elif parsed.path == "/api/queue/clear-failed":
|
|
runtime = Handler._runtime(self)
|
|
self.json(runtime["download_queue"].clear_failed())
|
|
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-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 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
|