#!/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"""
Your watchlist is empty. Add an anime using the form above.
'; return; } Object.values(data).forEach(item => { const card = document.createElement('div'); card.className = 'result watchlist-item'; card.innerHTML = `Error loading watchlist: ${error.message}
`; }); } 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.`); }