#!/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.1" 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"""