1672 lines
57 KiB
Python
1672 lines
57 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"
|
|
WATCHLIST_PATH = app_dir("state") / "watchlist.json"
|
|
|
|
def load_watchlist(fallback):
|
|
try:
|
|
with WATCHLIST_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_watchlist(data):
|
|
tmp = WATCHLIST_PATH.with_suffix(WATCHLIST_PATH.suffix + ".tmp")
|
|
with tmp.open("w", encoding="utf-8") as handle:
|
|
json.dump(data, handle, indent=2, sort_keys=True)
|
|
tmp.replace(WATCHLIST_PATH)
|
|
|
|
WATCHLIST = load_watchlist(dict())
|
|
WATCHLIST_PATH = app_dir("state") / "watchlist.json"
|
|
|
|
def load_watchlist(fallback):
|
|
try:
|
|
with WATCHLIST_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_watchlist(data):
|
|
tmp = WATCHLIST_PATH.with_suffix(WATCHLIST_PATH.suffix + ".tmp")
|
|
with tmp.open("w", encoding="utf-8") as handle:
|
|
json.dump(data, handle, indent=2, sort_keys=True)
|
|
tmp.replace(WATCHLIST_PATH)
|
|
|
|
WATCHLIST = load_watchlist(dict())
|
|
|
|
|
|
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 get_anime_status(show_id, mode):
|
|
"""Fetches the episode list and determines if the anime is complete."""
|
|
try:
|
|
episodes = episode_list(show_id, mode)
|
|
if not episodes:
|
|
return {"status": "unknown", "message": "Could not retrieve episode list."}
|
|
|
|
# Check if the last episode number matches the total available episodes
|
|
# This is a heuristic: if the last episode number is significantly lower
|
|
# than the total available episodes, it might not be finished.
|
|
# For simplicity, we'll just check if the episode count is high enough
|
|
# or if the last episode is marked as the final one.
|
|
# A more robust check would require knowing the total count from the API.
|
|
# For now, we'll assume it's "complete" if we have a reasonable number of episodes.
|
|
# A better check would be to compare the max episode number to the total count.
|
|
|
|
# Simple check: if the last episode number is less than 10, assume not finished.
|
|
# This is a placeholder for better logic.
|
|
last_episode_num = int(re.findall(r'\d+', episodes[-1])[0])
|
|
if last_episode_num < 10 and len(episodes) < 5:
|
|
return {"status": "ongoing", "message": f"Only {len(episodes)} episodes found. Might be ongoing."}
|
|
|
|
return {"status": "available", "message": f"Found {len(episodes)} episodes."}
|
|
except Exception as e:
|
|
return {"status": "error", "message": f"Error checking status: {str(e)}"}
|
|
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))
|
|
elif parsed.path == "/api/watchlist/add":
|
|
payload = self.body_json()
|
|
return self.json(add_to_watchlist(payload))
|
|
elif parsed.path == "/api/watchlist/get":
|
|
return self.json(get_watchlist())
|
|
elif parsed.path == "/api/watchlist/update-status":
|
|
payload = self.body_json()
|
|
return self.json(update_watchlist_status(payload["show_id"], payload["mode"]))
|
|
else:
|
|
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
|
except Exception as exc:
|
|
self.exception(exc)
|
|
|
|
def add_to_watchlist(payload):
|
|
"""Adds an anime to the watchlist."""
|
|
show_id = payload.get("show_id")
|
|
mode = payload.get("mode")
|
|
if not show_id or not mode:
|
|
return {"error": "Missing show_id or mode."}
|
|
|
|
# Check if already exists
|
|
for item in WATCHLIST:
|
|
if item["show_id"] == show_id and item["mode"] == mode:
|
|
return {"message": f"Already tracked: {item['title']} ({mode})."}
|
|
|
|
# Add new item
|
|
new_item = {
|
|
"show_id": show_id,
|
|
"title": payload.get("title", "Unknown Anime"),
|
|
"mode": mode,
|
|
"added_at": now_iso(),
|
|
"status": "pending_check",
|
|
"last_checked": now_iso(),
|
|
"episodes_available": [],
|
|
"is_complete": False,
|
|
}
|
|
WATCHLIST[str(uuid4())] = new_item
|
|
write_watchlist(WATCHLIST)
|
|
return {"message": f"Successfully added {payload.get('title', 'Anime')} to watchlist for {mode}."}
|
|
|
|
def get_watchlist():
|
|
"""Retrieves the entire watchlist."""
|
|
return WATCHLIST
|
|
|
|
def update_watchlist_status(show_id, mode):
|
|
"""Updates the status of a specific anime in the watchlist."""
|
|
if str(show_id) not in WATCHLIST:
|
|
return {"error": "Anime not found in watchlist."}
|
|
|
|
item = WATCHLIST[str(show_id)]
|
|
|
|
# 1. Check episode availability
|
|
status_check = get_anime_status(show_id, mode)
|
|
item["episodes_available"] = status_check["message"]
|
|
|
|
# 2. Determine completion status (Placeholder logic)
|
|
# In a real scenario, we'd check if the last episode number matches the total count.
|
|
# For now, we'll just update the status based on the check result.
|
|
if status_check["status"] == "available" and "Found" in status_check["message"]:
|
|
item["is_complete"] = True # Assume available means complete for now
|
|
item["status"] = "ready_to_download"
|
|
elif status_check["status"] == "ongoing":
|
|
item["is_complete"] = False
|
|
item["status"] = "ongoing"
|
|
else:
|
|
item["is_complete"] = False
|
|
item["status"] = "pending_check"
|
|
|
|
item["last_checked"] = now_iso()
|
|
write_watchlist(WATCHLIST)
|
|
return {"message": f"Status updated for {item['title']}. Status: {item['status']}"}
|
|
|
|
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>
|
|
|
|
<!-- Search and Search Options -->
|
|
<div class="search-box">
|
|
<label for="search-query">Anime Title / Keyword</label>
|
|
<input type="text" id="search-query" placeholder="e.g., Attack on Titan">
|
|
|
|
<label for="search-mode">Mode</label>
|
|
<select id="search-mode">
|
|
<option value="sub">Subtitled (Sub)</option>
|
|
<option value="dub">Dubbed (Dub)</option>
|
|
</select>
|
|
|
|
<label for="search-quality">Quality</label>
|
|
<select id="search-quality">
|
|
<option value="best">Best</option>
|
|
<option value="1080p">1080p</option>
|
|
<option value="720p">720p</option>
|
|
<option value="480p">480p</option>
|
|
</select>
|
|
|
|
<button class="primary" onclick="searchAnime()">Search</button>
|
|
</div>
|
|
|
|
<!-- Watchlist Section -->
|
|
<div class="settings">
|
|
<h3>Anime Watchlist</h3>
|
|
<div class="topline">
|
|
<button class="primary" onclick="document.getElementById('watchlist-form').dispatchEvent(new Event('submit'))">Add to Watchlist</button>
|
|
</div>
|
|
<form id="watchlist-form" onsubmit="event.preventDefault(); addAnimeToWatchlist();">
|
|
<label for="watchlist-title">Anime Title</label>
|
|
<input type="text" id="watchlist-title" placeholder="Anime Title" required>
|
|
|
|
<label for="watchlist-show-id">Show ID (from search)</label>
|
|
<input type="text" id="watchlist-show-id" placeholder="Show ID" required>
|
|
|
|
<label for="watchlist-mode">Mode</label>
|
|
<select id="watchlist-mode">
|
|
<option value="sub">Subtitled (Sub)</option>
|
|
<option value="dub">Dubbed (Dub)</option>
|
|
</select>
|
|
</form>
|
|
<div id="watchlist-container" class="results">
|
|
<!-- Watchlist items will be loaded here -->
|
|
</div>
|
|
</div>
|
|
</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>
|
|
"""
|
|
|
|
# --- Watchlist Section ---
|
|
const watchlistContainer = document.getElementById('watchlist-container');
|
|
if (watchlistContainer) {
|
|
fetchWatchlist();
|
|
}
|
|
|
|
function fetchWatchlist() {
|
|
fetch('/api/watchlist/get')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const watchlistContainer = document.getElementById('watchlist-container');
|
|
if (!watchlistContainer) return;
|
|
watchlistContainer.innerHTML = '';
|
|
|
|
if (Object.keys(data).length === 0) {
|
|
watchlistContainer.innerHTML = '<p class="muted">Your watchlist is empty. Add an anime using the form above.</p>';
|
|
return;
|
|
}
|
|
|
|
Object.values(data).forEach(item => {
|
|
const card = document.createElement('div');
|
|
card.className = 'result watchlist-item';
|
|
card.innerHTML = `
|
|
<div class="result-title">${item.title}</div>
|
|
<div class="muted">
|
|
ID: ${item.show_id} | Mode: ${item.mode.toUpperCase()} | Status: <span class="badge ${item.status.includes('ready') ? 'ok' : item.status.includes('error') ? 'bad' : ''}">${item.status.replace('_', ' ')}</span>
|
|
</div>
|
|
<div class="topline">
|
|
<button class="primary" onclick="checkWatchlistStatus('${item.show_id}', '${item.mode}')">Check Status</button>
|
|
<button class="ghost" onclick="viewWatchlistItem('${item.show_id}', '${item.mode}')">View Details</button>
|
|
</div>
|
|
`;
|
|
watchlistContainer.appendChild(card);
|
|
});
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching watchlist:', error);
|
|
document.getElementById('watchlist-container').innerHTML = `<p class="badge bad">Error loading watchlist: ${error.message}</p>`;
|
|
});
|
|
}
|
|
|
|
function addAnimeToWatchlist() {
|
|
const showId = document.getElementById('watchlist-show-id').value.trim();
|
|
const mode = document.getElementById('watchlist-mode').value;
|
|
const title = document.getElementById('watchlist-title').value.trim();
|
|
|
|
if (!showId || !mode || !title) {
|
|
alert('Please fill all fields.');
|
|
return;
|
|
}
|
|
|
|
fetch('/api/watchlist/add', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ show_id: showId, mode: mode, title: title })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
alert(data.message);
|
|
fetchWatchlist(); // Refresh list
|
|
})
|
|
.catch(error => {
|
|
console.error('Error adding to watchlist:', error);
|
|
alert('Failed to add to watchlist. Check console for details.');
|
|
});
|
|
}
|
|
|
|
function checkWatchlistStatus(showId, mode) {
|
|
fetch('/api/watchlist/update-status', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ show_id: showId, mode: mode })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
alert(data.message);
|
|
fetchWatchlist(); // Refresh list
|
|
})
|
|
.catch(error => {
|
|
console.error('Error checking status:', error);
|
|
alert('Failed to check status.');
|
|
});
|
|
}
|
|
|
|
function viewWatchlistItem(showId, mode) {
|
|
// Placeholder for detailed view logic
|
|
alert(`Viewing details for ${showId} (${mode}). This would trigger a detailed view panel.`);
|
|
}
|