commit ec6efac9e16f2433fdc6f883f3681abac3afa8ea Author: Dymas Date: Mon May 11 11:53:51 2026 +0200 Initialize standalone ani-cli-web diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff705e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.ani-cli-web/ +downloads/ +__pycache__/ +*.py[cod] +*.pyo +.python-version +.venv/ +venv/ +env/ +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6e2f794 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.1.0 - 2026-05-11 + +- Split `ani-cli-web` into a standalone project that uses system-wide `ani-cli`. +- Added project hygiene files: `.gitignore`, `VERSION`, and this changelog. +- Documented runtime state handling and standalone project layout. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f203f5 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# ani-cli web + +A small local web UI for a system-wide `ani-cli` install. + +Current version: `0.1.0` + +## Project Layout + +```text +ani-cli-web/ + app.py Web server, queue, search, and post-download library naming. + ani-cli-web Launcher script. + VERSION Project version. + CHANGELOG.md Change history. + README.md Project documentation. +``` + +## Run + +```sh +./ani-cli-web +``` + +Then open: + +```text +http://127.0.0.1:8421/ +``` + +The app searches anime, queues downloads, supports original or dubbed mode, quality, episode ranges, editable library names, season numbers, and a default download folder. + +Downloads are staged in the web app state directory first. After `ani-cli` finishes, the wrapper moves them into a Plex/Jellyfin-friendly layout: + +```text +ANI_CLI_DOWNLOAD_DIR/anime_name/anime_name - S01E01.mp4 +``` + +## Configuration + +- `ANI_CLI_BIN`: path or command name for `ani-cli`; defaults to `ani-cli` from `PATH`. +- `ANI_CLI_DOWNLOAD_DIR`: initial default download folder. +- `ANI_CLI_MODE`: initial mode, `sub` or `dub`. +- `ANI_CLI_QUALITY`: initial quality, for example `best`, `1080`, or `720`. +- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`. +- `ANI_CLI_WEB_PORT`: server port, default `8421`. + +Settings and the queue are stored under XDG config/state directories when writable, with a local `.ani-cli-web/` fallback inside this project. + +Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode caches are ignored by git. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/ani-cli-web b/ani-cli-web new file mode 100644 index 0000000..b854b93 --- /dev/null +++ b/ani-cli-web @@ -0,0 +1,3 @@ +#!/bin/sh +cd "$(dirname "$0")" || exit 1 +exec python3 app.py "$@" diff --git a/app.py b/app.py new file mode 100644 index 0000000..1899c67 --- /dev/null +++ b/app.py @@ -0,0 +1,1346 @@ +#!/usr/bin/env python3 +import json +import os +import re +import shutil +import signal +import subprocess +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse +from uuid import uuid4 + + +PROJECT_ROOT = Path(__file__).resolve().parent +ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" +APP_NAME = "ani-cli-web" +VERSION = "0.1.0" +ALLANIME_BASE = "allanime.day" +ALLANIME_API = f"https://api.{ALLANIME_BASE}" +ALLANIME_REFERER = "https://allmanga.to" +AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" + +QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"} +MODE_CHOICES = {"sub", "dub"} +MAX_LOG_LINES = 240 + + +def default_download_dir(): + if os.environ.get("ANI_CLI_DOWNLOAD_DIR"): + return os.environ["ANI_CLI_DOWNLOAD_DIR"] + home_downloads = Path.home() / "Downloads" / "Anime" + if os.access(home_downloads.parent if home_downloads.parent.exists() else Path.home(), os.W_OK): + return str(home_downloads) + return str(PROJECT_ROOT / "downloads") + + +DEFAULT_CONFIG = { + "download_dir": default_download_dir(), + "mode": os.environ.get("ANI_CLI_MODE", "sub"), + "quality": os.environ.get("ANI_CLI_QUALITY", "best"), +} + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def app_dir(kind): + if kind == "config": + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + else: + base = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) + path = base / APP_NAME + try: + path.mkdir(parents=True, exist_ok=True) + probe = path / ".write-test" + probe.write_text("", encoding="utf-8") + probe.unlink() + return path + except OSError: + fallback = PROJECT_ROOT / ".ani-cli-web" / kind + fallback.mkdir(parents=True, exist_ok=True) + return fallback + + +CONFIG_PATH = app_dir("config") / "config.json" +QUEUE_PATH = app_dir("state") / "queue.json" +STAGING_ROOT = app_dir("state") / "staging" + + +def load_json(path, fallback): + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + return data if isinstance(data, type(fallback)) else fallback + except (FileNotFoundError, json.JSONDecodeError, OSError): + return fallback + + +def write_json(path, data): + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + tmp.replace(path) + + +def normalize_config(data): + config = dict(DEFAULT_CONFIG) + if isinstance(data, dict): + config.update({key: data[key] for key in DEFAULT_CONFIG if key in data}) + + mode = str(config.get("mode", "sub")).lower() + quality = str(config.get("quality", "best")).lower() + if quality.endswith("p") and quality[:-1].isdigit(): + quality = quality[:-1] + download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"]) + + config["mode"] = mode if mode in MODE_CHOICES else "sub" + config["quality"] = quality if quality in QUALITY_CHOICES else "best" + config["download_dir"] = str(Path(download_dir).expanduser()) + return config + + +CONFIG = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG)) +write_json(CONFIG_PATH, CONFIG) + + +def graph_request(query, variables): + payload = json.dumps({"variables": variables, "query": query}).encode("utf-8") + request = urllib.request.Request( + f"{ALLANIME_API}/api", + data=payload, + headers={ + "Content-Type": "application/json", + "Referer": ALLANIME_REFERER, + "User-Agent": AGENT, + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=25) as response: + raw = response.read().decode("utf-8") + except urllib.error.URLError as exc: + raise RuntimeError(f"Could not reach AllAnime: {exc}") from exc + except TimeoutError as exc: + raise RuntimeError("AllAnime request timed out") from exc + + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError("AllAnime returned an unreadable response") from exc + + if data.get("errors"): + message = data["errors"][0].get("message", "AllAnime returned an error") + raise RuntimeError(message) + return data.get("data", {}) + + +def search_anime(query, mode): + search_gql = ( + "query( $search: SearchInput $limit: Int $page: Int " + "$translationType: VaildTranslationTypeEnumType $countryOrigin: VaildCountryOriginEnumType ) " + "{ shows( search: $search limit: $limit page: $page translationType: $translationType " + "countryOrigin: $countryOrigin ) { edges { _id name availableEpisodes __typename } }}" + ) + data = graph_request( + search_gql, + { + "search": {"allowAdult": False, "allowUnknown": False, "query": query}, + "limit": 40, + "page": 1, + "translationType": mode, + "countryOrigin": "ALL", + }, + ) + shows = (((data.get("shows") or {}).get("edges")) or []) + results = [] + for show in shows: + episodes = (show.get("availableEpisodes") or {}).get(mode) + if not episodes: + continue + title = str(show.get("name") or "").replace('\\"', '"').strip() + if not show.get("_id") or not title: + continue + index = len(results) + 1 + results.append( + { + "id": show["_id"], + "title": title, + "episodes": episodes, + "index": index, + "query": query, + "mode": mode, + } + ) + return results + + +def numeric_episode_key(value): + parts = re.findall(r"\d+|\D+", str(value)) + key = [] + for part in parts: + key.append((0, int(part)) if part.isdigit() else (1, part)) + return key + + +def episode_list(show_id, mode): + episodes_gql = "query ($showId: String!) { show( _id: $showId ) { _id availableEpisodesDetail }}" + data = graph_request(episodes_gql, {"showId": show_id}) + detail = ((data.get("show") or {}).get("availableEpisodesDetail")) or {} + episodes = [str(item) for item in detail.get(mode, []) if str(item).strip()] + return sorted(episodes, key=numeric_episode_key) + + +class DownloadQueue: + def __init__(self): + self.lock = threading.RLock() + self.wakeup = threading.Event() + self.jobs = load_json(QUEUE_PATH, []) + self.current_process = None + self.current_job_id = None + self._restore_interrupted_jobs() + self.worker = threading.Thread(target=self._run, daemon=True) + self.worker.start() + + def _restore_interrupted_jobs(self): + changed = False + for job in self.jobs: + if job.get("status") == "running": + job["status"] = "pending" + job["pid"] = None + job.setdefault("log", []).append("Restarted after web app restart.") + changed = True + if changed: + self._save_locked() + + def _save_locked(self): + clean_jobs = [] + for job in self.jobs: + clean = dict(job) + clean.pop("process", None) + clean_jobs.append(clean) + write_json(QUEUE_PATH, clean_jobs) + + def list(self): + with self.lock: + return [dict(job) for job in self.jobs] + + def add(self, payload): + job = build_job(payload) + with self.lock: + self.jobs.append(job) + self._save_locked() + self.wakeup.set() + return job + + def retry(self, job_id): + with self.lock: + job = self._find(job_id) + if job["status"] == "running": + raise ValueError("Running jobs cannot be retried") + job["status"] = "pending" + job["exit_code"] = None + job["pid"] = None + job["started_at"] = None + job["finished_at"] = None + job["updated_at"] = now_iso() + job["log"] = [] + self._save_locked() + self.wakeup.set() + return job + + def cancel(self, job_id): + with self.lock: + job = self._find(job_id) + if job["status"] == "pending": + job["status"] = "canceled" + job["updated_at"] = now_iso() + self._save_locked() + return job + if job["status"] != "running": + return job + if self.current_job_id == job_id and self.current_process: + try: + os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM) + except OSError: + self.current_process.terminate() + job["cancel_requested"] = True + job["updated_at"] = now_iso() + self._save_locked() + return job + raise ValueError("Could not find the running process") + + def remove(self, job_id): + with self.lock: + job = self._find(job_id) + if job["status"] == "running": + raise ValueError("Running jobs must be canceled before removing") + self.jobs = [item for item in self.jobs if item["id"] != job_id] + self._save_locked() + return {"ok": True} + + def clear_finished(self): + with self.lock: + self.jobs = [job for job in self.jobs if job.get("status") in {"pending", "running"}] + self._save_locked() + return {"ok": True} + + def _find(self, job_id): + for job in self.jobs: + if job.get("id") == job_id: + return job + raise KeyError("Job not found") + + def _next_pending(self): + with self.lock: + for job in self.jobs: + if job.get("status") == "pending": + return job + return None + + def _append_log(self, job, line): + text = strip_control(line).strip() + if not text: + return + with self.lock: + job.setdefault("log", []).append(text) + job["log"] = job["log"][-MAX_LOG_LINES:] + job["updated_at"] = now_iso() + self._save_locked() + + def _run(self): + while True: + job = self._next_pending() + if not job: + self.wakeup.wait(2) + self.wakeup.clear() + continue + self._run_job(job) + + def _run_job(self, job): + command = command_for_job(job) + staging_dir = job_staging_dir(job) + target_dir = job_output_dir(job) + env = os.environ.copy() + env.update( + { + "ANI_CLI_DOWNLOAD_DIR": str(staging_dir), + "ANI_CLI_MODE": job["mode"], + "ANI_CLI_QUALITY": job["quality"], + "TERM": env.get("TERM", "xterm-256color"), + } + ) + staging_dir.mkdir(parents=True, exist_ok=True) + target_dir.mkdir(parents=True, exist_ok=True) + + with self.lock: + job["status"] = "running" + job["started_at"] = now_iso() + job["updated_at"] = job["started_at"] + job["command"] = printable_command(command) + job["staging_dir"] = str(staging_dir) + job["target_dir"] = str(target_dir) + job["log"] = [ + f"Starting: {job['command']}", + f"Staging in: {job['staging_dir']}", + f"Final folder: {job['target_dir']}", + ] + job["cancel_requested"] = False + self._save_locked() + + process = subprocess.Popen( + command, + cwd=str(PROJECT_ROOT), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + ) + with self.lock: + self.current_process = process + self.current_job_id = job["id"] + job["pid"] = process.pid + self._save_locked() + + assert process.stdout is not None + for line in process.stdout: + self._append_log(job, line) + exit_code = process.wait() + finalize_error = None + moved_files = [] + if exit_code == 0 and not job.get("cancel_requested"): + try: + moved_files = finalize_library_files(job) + except Exception as exc: + finalize_error = exc + + with self.lock: + canceled = job.get("cancel_requested") + job["exit_code"] = 1 if finalize_error else exit_code + job["pid"] = None + job["finished_at"] = now_iso() + job["updated_at"] = job["finished_at"] + if canceled: + job["status"] = "canceled" + job.setdefault("log", []).append("Canceled.") + elif finalize_error: + job["status"] = "failed" + job.setdefault("log", []).append(f"Library rename failed: {finalize_error}") + elif exit_code == 0: + job["status"] = "done" + for path in moved_files: + job.setdefault("log", []).append(f"Saved: {path}") + job.setdefault("log", []).append("Download completed.") + else: + job["status"] = "failed" + job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.") + self.current_process = None + self.current_job_id = None + self._save_locked() + + +def strip_control(value): + return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n") + + +def printable_command(command): + return " ".join(sh_quote(part) for part in command) + + +def sh_quote(value): + if re.match(r"^[A-Za-z0-9_./:=+-]+$", value): + return value + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def validate_episode_spec(value): + text = str(value or "").strip() + if not text: + raise ValueError("Choose at least one episode") + if not re.match(r"^[0-9.\-\s]+$", text): + raise ValueError("Episodes can contain numbers, dots, spaces, and ranges") + return re.sub(r"\s+", " ", text) + + +def sanitize_path_component(value, fallback="Anime"): + text = str(value or "").strip() + text = re.sub(r'[\\/:*?"<>|]+', "-", text) + text = re.sub(r"\s+", " ", text).strip(" .") + return text or fallback + + +def normalize_season(value): + text = str(value or "1").strip() + if not re.match(r"^[0-9]+$", text): + raise ValueError("Season must be a positive number") + number = int(text, 10) + if number < 1: + raise ValueError("Season must be a positive number") + return str(number) + + +def job_output_dir(job): + return Path(job["download_dir"]).expanduser() / sanitize_path_component(job.get("anime_name") or job.get("title")) + + +def job_staging_dir(job): + return STAGING_ROOT / job["id"] + + +def season_label(job): + return f"{int(job.get('season') or 1):02d}" + + +def episode_label(value): + text = str(value or "").strip() + match = re.match(r"^0*([0-9]+)(.*)$", text) + if match: + number = int(match.group(1) or "0", 10) + suffix = re.sub(r"[^0-9A-Za-z.]+", "-", match.group(2)).strip("-") + return f"{number:02d}{suffix}" + return re.sub(r"[^0-9A-Za-z.]+", "-", text).strip("-") or "00" + + +def extract_episode_from_filename(path): + match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem) + return match.group(1) if match else None + + +def unique_destination(path): + if not path.exists(): + return path + counter = 2 + while True: + candidate = path.with_name(f"{path.stem} ({counter}){path.suffix}") + if not candidate.exists(): + return candidate + counter += 1 + + +def finalize_library_files(job): + staging_dir = job_staging_dir(job) + target_dir = job_output_dir(job) + library_name = sanitize_path_component(job.get("anime_name") or job.get("title")) + season = season_label(job) + target_dir.mkdir(parents=True, exist_ok=True) + files = sorted( + [path for path in staging_dir.iterdir() if path.is_file()], + key=lambda path: (path.stat().st_mtime, path.name), + ) + if not files: + raise RuntimeError("No downloaded files were found in the staging folder") + + moved = [] + fallback_episode = 1 + for source in files: + ep = extract_episode_from_filename(source) + if ep is None: + ep = str(fallback_episode) + fallback_episode += 1 + final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}" + destination = unique_destination(target_dir / final_name) + shutil.move(str(source), str(destination)) + moved.append(str(destination)) + + try: + staging_dir.rmdir() + except OSError: + pass + return moved + + +def build_job(payload): + if not isinstance(payload, dict): + raise ValueError("Expected a JSON object") + query = str(payload.get("query") or payload.get("title") or "").strip() + title = str(payload.get("title") or query).strip() + anime_name = sanitize_path_component(payload.get("anime_name") or title) + if not query: + raise ValueError("Missing search query") + try: + index = int(payload.get("index", 1)) + except (TypeError, ValueError) as exc: + raise ValueError("Invalid result index") from exc + if index < 1: + raise ValueError("Result index must be positive") + + mode = str(payload.get("mode") or CONFIG["mode"]).lower() + quality = str(payload.get("quality") or CONFIG["quality"]).lower() + download_dir = str(payload.get("download_dir") or CONFIG["download_dir"]).strip() + if mode not in MODE_CHOICES: + raise ValueError("Mode must be sub or dub") + if quality not in QUALITY_CHOICES: + raise ValueError("Unknown quality") + + return { + "id": uuid4().hex, + "title": title, + "anime_name": anime_name, + "season": normalize_season(payload.get("season")), + "query": query, + "result_index": index, + "mode": mode, + "quality": quality, + "episodes": validate_episode_spec(payload.get("episodes")), + "download_dir": str(Path(download_dir).expanduser()), + "status": "pending", + "created_at": now_iso(), + "updated_at": now_iso(), + "started_at": None, + "finished_at": None, + "exit_code": None, + "pid": None, + "log": [], + } + + +def command_for_job(job): + command = [ + ANI_CLI, + "-d", + "-q", + job["quality"], + "-e", + job["episodes"], + "-S", + str(job["result_index"]), + ] + if job["mode"] == "dub": + command.append("--dub") + command.append(job["query"]) + return command + + +DOWNLOAD_QUEUE = DownloadQueue() + + +def dependency_status(): + checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"] + result = {name: bool(shutil.which(name)) for name in checks} + result["ani-cli"] = bool(shutil.which(ANI_CLI) or (Path(ANI_CLI).exists() and os.access(ANI_CLI, os.X_OK))) + return result + + +class Handler(BaseHTTPRequestHandler): + server_version = "AniCliWeb/1.0" + + def do_GET(self): + parsed = urlparse(self.path) + try: + if parsed.path == "/": + self.html(INDEX_HTML) + elif parsed.path == "/api/config": + self.json(CONFIG) + elif parsed.path == "/api/version": + self.json({"name": APP_NAME, "version": VERSION}) + elif parsed.path == "/api/dependencies": + self.json(dependency_status()) + elif parsed.path == "/api/search": + 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": search_anime(query, mode)}) + elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"): + 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": episode_list(show_id, mode)}) + elif parsed.path == "/api/queue": + self.json({"jobs": DOWNLOAD_QUEUE.list()}) + 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 == "/api/config": + self.update_config(self.body_json()) + elif parsed.path == "/api/queue": + self.json(DOWNLOAD_QUEUE.add(self.body_json()), HTTPStatus.CREATED) + elif parsed.path == "/api/queue/clear-finished": + self.json(DOWNLOAD_QUEUE.clear_finished()) + elif parsed.path.startswith("/api/queue/"): + parts = parsed.path.strip("/").split("/") + if len(parts) != 4: + raise ValueError("Invalid queue action") + job_id, action = parts[2], parts[3] + if action == "cancel": + self.json(DOWNLOAD_QUEUE.cancel(job_id)) + elif action == "retry": + self.json(DOWNLOAD_QUEUE.retry(job_id)) + elif action == "remove": + self.json(DOWNLOAD_QUEUE.remove(job_id)) + else: + self.error(HTTPStatus.NOT_FOUND, "Not found") + else: + self.error(HTTPStatus.NOT_FOUND, "Not found") + except Exception as exc: + self.exception(exc) + + def update_config(self, payload): + global CONFIG + config = normalize_config(payload) + Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True) + CONFIG = config + write_json(CONFIG_PATH, CONFIG) + self.json(CONFIG) + + def body_json(self): + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length).decode("utf-8") if length else "{}" + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError("Invalid JSON body") from exc + + def html(self, content): + data = content.encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def json(self, payload, status=HTTPStatus.OK): + data = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def error(self, status, message): + self.json({"error": message}, status) + + def exception(self, exc): + if isinstance(exc, KeyError): + self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'")) + elif isinstance(exc, ValueError): + self.error(HTTPStatus.BAD_REQUEST, str(exc)) + else: + self.error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) + + def log_message(self, fmt, *args): + print(f"{self.address_string()} - {fmt % args}") + + +INDEX_HTML = r""" + + + + + ani-cli web + + + +
+ + +
+
+
+
+

Select a result

+

Episodes will appear here.

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

Defaults

+ +
+
+ +
+
+
+ +
+
+

Download queue

+ +
+
+
+
+
+ + + + +""" + + +def install_signal_handlers(httpd): + def shutdown(_signum, _frame): + if DOWNLOAD_QUEUE.current_process: + try: + os.killpg(os.getpgid(DOWNLOAD_QUEUE.current_process.pid), signal.SIGTERM) + except OSError: + pass + threading.Thread(target=httpd.shutdown, daemon=True).start() + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + +def main(): + host = os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1") + port = int(os.environ.get("ANI_CLI_WEB_PORT", "8421")) + httpd = ThreadingHTTPServer((host, port), Handler) + install_signal_handlers(httpd) + print(f"ani-cli web is running at http://{host}:{port}/") + print(f"Config: {CONFIG_PATH}") + print(f"Queue: {QUEUE_PATH}") + httpd.serve_forever() + + +if __name__ == "__main__": + main()