Files
ani-cli-web/app.py
T

1347 lines
43 KiB
Python

#!/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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ani-cli web</title>
<style>
:root {
color-scheme: dark;
--bg: #101418;
--panel: #171d23;
--panel-2: #202832;
--line: #33404c;
--text: #eef3f7;
--muted: #aab6c1;
--accent: #49b07d;
--accent-2: #d8a84a;
--bad: #e66b6b;
--focus: #7cc7ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select {
font: inherit;
}
button {
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
min-height: 38px;
border-radius: 6px;
padding: 0 12px;
cursor: pointer;
}
button:hover { border-color: var(--focus); }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #06140d;
font-weight: 700;
}
button.ghost {
background: transparent;
}
button.danger {
border-color: #8c4646;
color: #ffd9d9;
}
input, select {
width: 100%;
min-height: 38px;
border-radius: 6px;
border: 1px solid var(--line);
background: #0d1115;
color: var(--text);
padding: 0 11px;
outline: none;
}
input:focus, select:focus { border-color: var(--focus); }
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0;
}
.app {
display: grid;
grid-template-columns: minmax(320px, 440px) 1fr;
min-height: 100vh;
}
aside, main {
padding: 18px;
}
aside {
border-right: 1px solid var(--line);
background: var(--panel);
display: grid;
align-content: start;
gap: 18px;
}
main {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 18px;
min-width: 0;
}
h1, h2, h3, p { margin: 0; }
h1 { font-size: 24px; }
h2 { font-size: 16px; }
h3 { font-size: 15px; }
.topline, .row, .controls, .toolbar {
display: flex;
gap: 10px;
align-items: center;
}
.topline, .toolbar { justify-content: space-between; }
.row > * { flex: 1; }
.controls {
align-items: end;
}
.search-box {
display: grid;
gap: 12px;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
}
.segmented button {
border: 0;
border-radius: 0;
background: transparent;
}
.segmented button.active {
background: var(--accent-2);
color: #1b1200;
font-weight: 700;
}
.results, .queue {
display: grid;
gap: 8px;
min-width: 0;
}
.result, .job, .settings, .episodes-panel, .deps {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.result {
padding: 12px;
display: grid;
gap: 5px;
text-align: left;
}
.result.active {
border-color: var(--focus);
background: #17242d;
}
.result-title {
color: var(--text);
font-weight: 700;
overflow-wrap: anywhere;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.settings, .episodes-panel, .deps {
padding: 14px;
display: grid;
gap: 12px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 142px;
overflow: auto;
}
.chip, .badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 8px;
color: var(--muted);
background: #11171c;
font-size: 12px;
white-space: nowrap;
}
.badge.ok { color: #baf3d3; border-color: #34734f; }
.badge.bad { color: #ffd1d1; border-color: #834242; }
.job {
padding: 14px;
display: grid;
gap: 10px;
}
.job-head {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: start;
min-width: 0;
}
.job-title {
font-weight: 800;
overflow-wrap: anywhere;
}
.status {
border-radius: 999px;
padding: 4px 9px;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0;
background: var(--panel-2);
color: var(--muted);
}
.status.running { color: #06140d; background: var(--accent); }
.status.done { color: #092111; background: #8ee4ad; }
.status.failed { color: #2a0707; background: var(--bad); }
.status.canceled { color: #261700; background: #d8a84a; }
pre {
margin: 0;
max-height: 180px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 10px;
border-radius: 6px;
background: #090d10;
color: #cbd5de;
font-size: 12px;
}
.empty {
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--muted);
padding: 22px;
text-align: center;
}
.notice {
min-height: 20px;
color: var(--accent-2);
font-size: 13px;
}
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); }
.controls, .row, .toolbar { align-items: stretch; flex-direction: column; }
.job-head { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="app">
<aside>
<div class="topline">
<h1>ani-cli web</h1>
<span class="badge" id="serverBadge">local</span>
</div>
<section class="search-box">
<div class="segmented" role="group" aria-label="Translation">
<button id="subBtn" type="button" data-mode="sub">Original</button>
<button id="dubBtn" type="button" data-mode="dub">Dubbed</button>
</div>
<label>Anime search
<input id="query" type="search" placeholder="Search anime">
</label>
<div class="controls">
<label>Quality
<select id="quality">
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
<option value="360">360p</option>
<option value="worst">Worst</option>
</select>
</label>
<button class="primary" id="searchBtn" type="button">Search</button>
</div>
<p class="notice" id="notice"></p>
</section>
<section class="results" id="results"></section>
</aside>
<main>
<section class="episodes-panel" id="episodesPanel">
<div class="toolbar">
<div>
<h2 id="selectedTitle">Select a result</h2>
<p class="muted" id="selectedMeta">Episodes will appear here.</p>
</div>
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
</div>
<div class="row">
<label>Library name
<input id="animeNameInput" placeholder="Anime name">
</label>
<label>Season
<input id="seasonInput" inputmode="numeric" pattern="[0-9]*" value="1">
</label>
</div>
<div class="row">
<label>Episodes
<input id="episodesInput" placeholder="1-12">
</label>
<label>Download folder
<input id="jobFolder" placeholder="/home/me/Downloads/Anime">
</label>
</div>
<div class="toolbar">
<div class="chips" id="episodeChips"></div>
<div class="row" style="flex:0 0 auto">
<button class="ghost" id="firstEpBtn" type="button">First</button>
<button class="ghost" id="latestEpBtn" type="button">Latest</button>
<button class="ghost" id="allEpBtn" type="button">All</button>
</div>
</div>
</section>
<section class="settings">
<div class="toolbar">
<h2>Defaults</h2>
<button id="saveConfigBtn" type="button">Save</button>
</div>
<div class="row">
<label>Default folder
<input id="downloadDir">
</label>
</div>
<div class="deps" id="deps"></div>
</section>
<section>
<div class="toolbar">
<h2>Download queue</h2>
<button class="ghost" id="clearBtn" type="button">Clear finished</button>
</div>
<div class="queue" id="queue"></div>
</section>
</main>
</div>
<script>
const state = {
config: { mode: "sub", quality: "best", download_dir: "" },
selected: null,
episodes: [],
queueTimer: null
};
const $ = (id) => document.getElementById(id);
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Request failed");
return data;
}
function setNotice(text) {
$("notice").textContent = text || "";
}
function setMode(mode) {
state.config.mode = mode;
$("subBtn").classList.toggle("active", mode === "sub");
$("dubBtn").classList.toggle("active", mode === "dub");
}
function renderResults(results) {
const el = $("results");
el.innerHTML = "";
if (!results.length) {
el.innerHTML = '<div class="empty">No matching anime found.</div>';
return;
}
for (const item of results) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "result";
btn.innerHTML = `
<span class="result-title"></span>
<span class="muted"></span>
`;
btn.querySelector(".result-title").textContent = item.title;
btn.querySelector(".muted").textContent = `${item.episodes} episodes · result ${item.index}`;
btn.addEventListener("click", () => selectResult(item, btn));
el.appendChild(btn);
}
}
async function selectResult(item, button) {
document.querySelectorAll(".result").forEach((node) => node.classList.remove("active"));
button.classList.add("active");
state.selected = item;
state.episodes = [];
$("selectedTitle").textContent = item.title;
$("animeNameInput").value = item.title;
$("seasonInput").value = $("seasonInput").value || "1";
$("selectedMeta").textContent = "Loading episodes...";
$("episodeChips").innerHTML = "";
$("addBtn").disabled = true;
try {
const data = await api(`/api/anime/${encodeURIComponent(item.id)}/episodes?mode=${state.config.mode}`);
state.episodes = data.episodes;
const first = state.episodes[0] || "";
const last = state.episodes[state.episodes.length - 1] || "";
$("episodesInput").value = first && last ? `${first}-${last}` : "";
$("selectedMeta").textContent = `${state.episodes.length} available episodes`;
renderEpisodeChips();
$("addBtn").disabled = !state.episodes.length;
} catch (error) {
$("selectedMeta").textContent = error.message;
}
}
function renderEpisodeChips() {
const el = $("episodeChips");
el.innerHTML = "";
for (const ep of state.episodes.slice(0, 90)) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "chip";
chip.textContent = ep;
chip.addEventListener("click", () => { $("episodesInput").value = ep; });
el.appendChild(chip);
}
}
async function search() {
const query = $("query").value.trim();
if (!query) return setNotice("Type a title first.");
setNotice("Searching...");
state.selected = null;
$("addBtn").disabled = true;
$("results").innerHTML = "";
try {
const data = await api(`/api/search?q=${encodeURIComponent(query)}&mode=${state.config.mode}`);
renderResults(data.results);
setNotice(`${data.results.length} results`);
} catch (error) {
setNotice(error.message);
}
}
async function addSelected() {
if (!state.selected) return;
const payload = {
query: state.selected.query,
title: state.selected.title,
anime_name: $("animeNameInput").value || state.selected.title,
season: $("seasonInput").value || "1",
index: state.selected.index,
mode: state.config.mode,
quality: $("quality").value,
episodes: $("episodesInput").value,
download_dir: $("jobFolder").value || state.config.download_dir
};
try {
await api("/api/queue", { method: "POST", body: JSON.stringify(payload) });
setNotice("Added to queue.");
await loadQueue();
} catch (error) {
setNotice(error.message);
}
}
function renderQueue(jobs) {
const el = $("queue");
el.innerHTML = "";
if (!jobs.length) {
el.innerHTML = '<div class="empty">Queue is empty.</div>';
return;
}
for (const job of jobs.slice().reverse()) {
const item = document.createElement("article");
item.className = "job";
const log = (job.log || []).slice(-28).join("\n");
item.innerHTML = `
<div class="job-head">
<div>
<div class="job-title"></div>
<p class="muted"></p>
</div>
<span class="status"></span>
</div>
<pre></pre>
<div class="toolbar">
<span class="muted"></span>
<div class="row" style="flex:0 0 auto"></div>
</div>
`;
item.querySelector(".job-title").textContent = job.title;
const libraryName = job.anime_name || job.title;
const season = String(job.season || "1").padStart(2, "0");
const target = job.target_dir || `${job.download_dir}/${libraryName}`;
item.querySelector(".job-head .muted").textContent =
`${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`;
const status = item.querySelector(".status");
status.textContent = job.status;
status.classList.add(job.status);
item.querySelector("pre").textContent = log || "Waiting...";
item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`;
const actions = item.querySelector(".row");
if (job.status === "running") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
} else if (job.status === "pending") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
} else {
actions.append(actionButton("Retry", () => queueAction(job.id, "retry")));
actions.append(actionButton("Remove", () => queueAction(job.id, "remove")));
}
el.appendChild(item);
}
}
function actionButton(text, handler, extra = "") {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = text;
btn.className = extra;
btn.addEventListener("click", handler);
return btn;
}
async function queueAction(id, action) {
try {
await api(`/api/queue/${id}/${action}`, { method: "POST", body: "{}" });
await loadQueue();
} catch (error) {
setNotice(error.message);
}
}
async function loadQueue() {
const data = await api("/api/queue");
renderQueue(data.jobs);
}
async function loadConfig() {
state.config = await api("/api/config");
$("downloadDir").value = state.config.download_dir;
$("jobFolder").value = state.config.download_dir;
$("quality").value = state.config.quality;
setMode(state.config.mode);
}
async function saveConfig() {
try {
const data = await api("/api/config", {
method: "POST",
body: JSON.stringify({
download_dir: $("downloadDir").value,
mode: state.config.mode,
quality: $("quality").value
})
});
state.config = data;
$("jobFolder").value = data.download_dir;
setNotice("Defaults saved.");
} catch (error) {
setNotice(error.message);
}
}
async function loadDeps() {
const data = await api("/api/dependencies");
const el = $("deps");
el.innerHTML = "<h2>Dependencies</h2>";
const wrap = document.createElement("div");
wrap.className = "chips";
for (const [name, ok] of Object.entries(data)) {
const span = document.createElement("span");
span.className = `badge ${ok ? "ok" : "bad"}`;
span.textContent = `${name} ${ok ? "ok" : "missing"}`;
wrap.appendChild(span);
}
el.appendChild(wrap);
}
$("subBtn").addEventListener("click", () => setMode("sub"));
$("dubBtn").addEventListener("click", () => setMode("dub"));
$("searchBtn").addEventListener("click", search);
$("query").addEventListener("keydown", (event) => {
if (event.key === "Enter") search();
});
$("addBtn").addEventListener("click", addSelected);
$("saveConfigBtn").addEventListener("click", saveConfig);
$("clearBtn").addEventListener("click", async () => {
await api("/api/queue/clear-finished", { method: "POST", body: "{}" });
await loadQueue();
});
$("quality").addEventListener("change", () => { state.config.quality = $("quality").value; });
$("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; });
$("latestEpBtn").addEventListener("click", () => {
$("episodesInput").value = state.episodes[state.episodes.length - 1] || "";
});
$("allEpBtn").addEventListener("click", () => {
const first = state.episodes[0] || "";
const last = state.episodes[state.episodes.length - 1] || "";
$("episodesInput").value = first && last ? `${first}-${last}` : "";
});
(async function init() {
try {
await loadConfig();
await loadDeps();
await loadQueue();
state.queueTimer = setInterval(loadQueue, 2500);
} catch (error) {
setNotice(error.message);
}
})();
</script>
</body>
</html>
"""
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()