1495 lines
49 KiB
Python
1495 lines
49 KiB
Python
#!/usr/bin/env python3
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import sqlite3
|
|
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.2.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"
|
|
QUEUE_DB_PATH = app_dir("state") / "queue.sqlite3"
|
|
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.current_process = None
|
|
self.current_job_id = None
|
|
self._init_db()
|
|
self._migrate_json_queue()
|
|
self._restore_interrupted_jobs()
|
|
self.worker = threading.Thread(target=self._run, daemon=True)
|
|
self.worker.start()
|
|
|
|
def _connect(self):
|
|
conn = sqlite3.connect(QUEUE_DB_PATH, timeout=30)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _init_db(self):
|
|
QUEUE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
id TEXT PRIMARY KEY,
|
|
status TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
payload TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at)")
|
|
|
|
def _migrate_json_queue(self):
|
|
if not QUEUE_PATH.exists():
|
|
return
|
|
jobs = load_json(QUEUE_PATH, [])
|
|
if not jobs:
|
|
QUEUE_PATH.rename(QUEUE_PATH.with_suffix(".json.migrated"))
|
|
return
|
|
with self.lock, self._connect() as conn:
|
|
existing = conn.execute("SELECT COUNT(*) AS count FROM jobs").fetchone()["count"]
|
|
if existing:
|
|
return
|
|
for job in jobs:
|
|
if isinstance(job, dict) and job.get("id"):
|
|
self._upsert_job_conn(conn, job)
|
|
QUEUE_PATH.rename(QUEUE_PATH.with_suffix(".json.migrated"))
|
|
|
|
def _restore_interrupted_jobs(self):
|
|
with self.lock, self._connect() as conn:
|
|
rows = conn.execute("SELECT payload FROM jobs WHERE status = 'running'").fetchall()
|
|
for row in rows:
|
|
job = self._job_from_row(row)
|
|
job["status"] = "pending"
|
|
job["pid"] = None
|
|
job.setdefault("log", []).append("Restarted after web app restart.")
|
|
self._upsert_job_conn(conn, job)
|
|
|
|
def _job_from_row(self, row):
|
|
payload = row["payload"] if isinstance(row, sqlite3.Row) else row[0]
|
|
return json.loads(payload)
|
|
|
|
def _upsert_job_conn(self, conn, job):
|
|
clean = dict(job)
|
|
clean.pop("process", None)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO jobs (id, status, created_at, updated_at, payload)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
status = excluded.status,
|
|
updated_at = excluded.updated_at,
|
|
payload = excluded.payload
|
|
""",
|
|
(
|
|
clean["id"],
|
|
clean.get("status", "pending"),
|
|
clean.get("created_at") or now_iso(),
|
|
clean.get("updated_at") or now_iso(),
|
|
json.dumps(clean, sort_keys=True),
|
|
),
|
|
)
|
|
|
|
def _save_job_locked(self, job):
|
|
with self._connect() as conn:
|
|
self._upsert_job_conn(conn, job)
|
|
|
|
def list(self, page=1, per_page=10):
|
|
page = max(1, int(page or 1))
|
|
per_page = min(50, max(1, int(per_page or 10)))
|
|
offset = (page - 1) * per_page
|
|
with self.lock, self._connect() as conn:
|
|
total = conn.execute("SELECT COUNT(*) AS count FROM jobs").fetchone()["count"]
|
|
rows = conn.execute(
|
|
"SELECT payload FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
(per_page, offset),
|
|
).fetchall()
|
|
pages = max(1, (total + per_page - 1) // per_page)
|
|
return {
|
|
"jobs": [self._job_from_row(row) for row in rows],
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"total": total,
|
|
"pages": pages,
|
|
}
|
|
|
|
def add(self, payload):
|
|
job = build_job(payload)
|
|
with self.lock:
|
|
self._save_job_locked(job)
|
|
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_job_locked(job)
|
|
self.wakeup.set()
|
|
return job
|
|
|
|
def retry_all_failed(self):
|
|
count = 0
|
|
with self.lock, self._connect() as conn:
|
|
rows = conn.execute("SELECT payload FROM jobs WHERE status = 'failed'").fetchall()
|
|
for row in rows:
|
|
job = self._job_from_row(row)
|
|
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._upsert_job_conn(conn, job)
|
|
count += 1
|
|
if count:
|
|
self.wakeup.set()
|
|
return {"ok": True, "count": count}
|
|
|
|
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_job_locked(job)
|
|
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_job_locked(job)
|
|
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")
|
|
with self._connect() as conn:
|
|
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
|
return {"ok": True}
|
|
|
|
def clear_finished(self):
|
|
with self.lock, self._connect() as conn:
|
|
cursor = conn.execute("DELETE FROM jobs WHERE status IN ('done', 'canceled')")
|
|
return {"ok": True, "count": cursor.rowcount}
|
|
|
|
def _find(self, job_id):
|
|
with self._connect() as conn:
|
|
row = conn.execute("SELECT payload FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
|
if row:
|
|
return self._job_from_row(row)
|
|
raise KeyError("Job not found")
|
|
|
|
def _next_pending(self):
|
|
with self.lock:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT payload FROM jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
|
|
).fetchone()
|
|
if row:
|
|
return self._job_from_row(row)
|
|
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_job_locked(job)
|
|
|
|
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_job_locked(job)
|
|
|
|
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_job_locked(job)
|
|
|
|
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_job_locked(job)
|
|
|
|
|
|
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):
|
|
anime_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
|
|
return Path(job["download_dir"]).expanduser() / anime_name / f"Season {season_label(job)}"
|
|
|
|
|
|
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":
|
|
params = parse_qs(parsed.query)
|
|
page = (params.get("page") or ["1"])[0]
|
|
per_page = (params.get("per_page") or ["10"])[0]
|
|
self.json(DOWNLOAD_QUEUE.list(page=page, per_page=per_page))
|
|
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 == "/api/queue/retry-failed":
|
|
self.json(DOWNLOAD_QUEUE.retry_all_failed())
|
|
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>
|
|
<div class="row" style="flex:0 0 auto">
|
|
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button>
|
|
<button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
|
|
</div>
|
|
</div>
|
|
<div class="queue" id="queue"></div>
|
|
<div class="toolbar" id="queuePager"></div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
const state = {
|
|
config: { mode: "sub", quality: "best", download_dir: "" },
|
|
selected: null,
|
|
episodes: [],
|
|
queuePage: 1,
|
|
queuePages: 1,
|
|
queueTotal: 0,
|
|
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(data) {
|
|
const jobs = data.jobs || [];
|
|
state.queuePage = data.page || 1;
|
|
state.queuePages = data.pages || 1;
|
|
state.queueTotal = data.total || 0;
|
|
const el = $("queue");
|
|
el.innerHTML = "";
|
|
if (!jobs.length) {
|
|
el.innerHTML = '<div class="empty">Queue is empty.</div>';
|
|
renderQueuePager();
|
|
return;
|
|
}
|
|
for (const job of jobs) {
|
|
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 seasonFolder = `Season ${season}`;
|
|
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
|
|
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);
|
|
}
|
|
renderQueuePager();
|
|
}
|
|
|
|
function renderQueuePager() {
|
|
const el = $("queuePager");
|
|
const start = state.queueTotal ? ((state.queuePage - 1) * 10) + 1 : 0;
|
|
const end = Math.min(state.queuePage * 10, state.queueTotal);
|
|
el.innerHTML = `
|
|
<span class="muted"></span>
|
|
<div class="row" style="flex:0 0 auto"></div>
|
|
`;
|
|
el.querySelector(".muted").textContent =
|
|
`${start}-${end} of ${state.queueTotal} · page ${state.queuePage} of ${state.queuePages}`;
|
|
const controls = el.querySelector(".row");
|
|
const prev = actionButton("Previous", () => {
|
|
if (state.queuePage > 1) loadQueue(state.queuePage - 1);
|
|
});
|
|
const next = actionButton("Next", () => {
|
|
if (state.queuePage < state.queuePages) loadQueue(state.queuePage + 1);
|
|
});
|
|
prev.disabled = state.queuePage <= 1;
|
|
next.disabled = state.queuePage >= state.queuePages;
|
|
controls.append(prev, next);
|
|
}
|
|
|
|
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(state.queuePage);
|
|
} catch (error) {
|
|
setNotice(error.message);
|
|
}
|
|
}
|
|
|
|
async function queueBulkAction(path, message) {
|
|
try {
|
|
const data = await api(path, { method: "POST", body: "{}" });
|
|
setNotice(`${message}: ${data.count || 0}`);
|
|
await loadQueue(state.queuePage);
|
|
} catch (error) {
|
|
setNotice(error.message);
|
|
}
|
|
}
|
|
|
|
async function loadQueue(page = state.queuePage || 1) {
|
|
const data = await api(`/api/queue?page=${page}&per_page=10`);
|
|
if (data.page > data.pages && data.pages > 0) return loadQueue(data.pages);
|
|
renderQueue(data);
|
|
}
|
|
|
|
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);
|
|
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs"));
|
|
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
|
|
$("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_DB_PATH}")
|
|
httpd.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|