Files
ani-cli-web/app.py
T

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.`);
}