Files
ani-cli-web/app.py
T

3682 lines
128 KiB
Python

#!/usr/bin/env python3
import base64
import gzip
import json
import mimetypes
import os
import re
import shutil
import signal
import sqlite3
import subprocess
import threading
import time
import unicodedata
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from binascii import Error as BinasciiError
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import sys
from urllib.parse import parse_qs, quote, unquote, urljoin, 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.8.13"
ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to"
ALLMANGA_SITE_BASE = "https://allmanga.to"
ANIMESCHEDULE_API = "https://animeschedule.net/api/v3"
ANIMESCHEDULE_IMAGE_BASE = "https://img.animeschedule.net/production/assets/public/img/"
ANIDB_TITLES_URL = "https://anidb.net/api/anime-titles.xml.gz"
ANIDB_ANIME_PAGE = "https://anidb.net/anime/{aid}"
AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"
CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)
DEBUG_MODE = False
QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"}
MODE_CHOICES = {"sub", "dub"}
MAX_LOG_LINES = 240
def debug_enabled():
return DEBUG_MODE or str(os.environ.get("ANI_CLI_WEB_DEBUG") or "").strip().lower() in {"1", "true", "yes", "on"}
def debug_log(event, **fields):
if not debug_enabled():
return
parts = [f"[debug] {event}"]
for key, value in fields.items():
text = str(value)
if len(text) > 240:
text = text[:237] + "..."
parts.append(f"{key}={text}")
print(" | ".join(parts), flush=True)
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()
PROJECT_APP_ROOT = PROJECT_ROOT / ".ani-cli-web"
PROJECT_APP_ROOT.mkdir(parents=True, exist_ok=True)
def legacy_state_root():
return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / APP_NAME
def legacy_config_root():
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
def project_state_path(*parts):
path = PROJECT_APP_ROOT.joinpath(*parts)
path.parent.mkdir(parents=True, exist_ok=True)
return path
def project_state_dir(*parts):
path = PROJECT_APP_ROOT.joinpath(*parts)
path.mkdir(parents=True, exist_ok=True)
return path
def migrate_legacy_file(project_path, legacy_path, label):
if project_path.exists() or not legacy_path.exists():
return project_path
project_path.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.move(str(legacy_path), str(project_path))
debug_log("state.migrated", label=label, from_path=legacy_path, to_path=project_path)
except OSError as exc:
debug_log("state.migrate_failed", label=label, from_path=legacy_path, to_path=project_path, error=exc)
return project_path
def migrate_legacy_dir(project_dir, legacy_dir, label):
if not legacy_dir.exists():
return project_dir
project_dir.mkdir(parents=True, exist_ok=True)
moved = 0
for child in legacy_dir.iterdir():
target = project_dir / child.name
if target.exists():
if child.is_dir() and target.is_dir():
migrate_legacy_dir(target, child, f"{label}/{child.name}")
continue
try:
shutil.move(str(child), str(target))
moved += 1
except OSError as exc:
debug_log("state.migrate_failed", label=label, from_path=child, to_path=target, error=exc)
try:
legacy_dir.rmdir()
except OSError:
pass
if moved:
debug_log("state.migrated", label=label, from_path=legacy_dir, to_path=project_dir, entries=moved)
return project_dir
LEGACY_STATE_ROOT = legacy_state_root()
LEGACY_CONFIG_ROOT = legacy_config_root()
LEGACY_CONFIG_PATH = LEGACY_CONFIG_ROOT / "config.json"
LEGACY_QUEUE_PATH = LEGACY_STATE_ROOT / "queue.json"
LEGACY_QUEUE_DB_PATH = LEGACY_STATE_ROOT / "queue.sqlite3"
LEGACY_STAGING_ROOT = LEGACY_STATE_ROOT / "staging"
LEGACY_WATCHLIST_JSON_PATH = LEGACY_STATE_ROOT / "watchlist.json"
LEGACY_THUMBNAIL_ROOT = LEGACY_STATE_ROOT / "thumbnails"
LEGACY_ANIDB_TITLES_PATH = LEGACY_STATE_ROOT / "anidb-anime-titles.xml.gz"
CONFIG_PATH = project_state_path("config.json")
QUEUE_PATH = project_state_path("queue.json")
QUEUE_DB_PATH = project_state_path("queue.sqlite3")
STAGING_ROOT = project_state_dir("staging")
WATCHLIST_JSON_PATH = project_state_path("watchlist.json")
THUMBNAIL_ROOT = project_state_dir("thumbnails")
THUMBNAIL_RETRY_SECONDS = 15 * 60
ANIDB_TITLES_PATH = project_state_path("anidb-anime-titles.xml.gz")
ANIDB_TITLES_CACHE = {"mtime": None, "entries": None}
def migrate_legacy_state_storage():
migrate_legacy_file(CONFIG_PATH, LEGACY_CONFIG_PATH, "config.json")
migrate_legacy_file(QUEUE_PATH, LEGACY_QUEUE_PATH, "queue.json")
migrate_legacy_file(QUEUE_DB_PATH, LEGACY_QUEUE_DB_PATH, "queue.sqlite3")
migrate_legacy_file(WATCHLIST_JSON_PATH, LEGACY_WATCHLIST_JSON_PATH, "watchlist.json")
migrate_legacy_file(ANIDB_TITLES_PATH, LEGACY_ANIDB_TITLES_PATH, "anidb-anime-titles.xml.gz")
migrate_legacy_dir(STAGING_ROOT, LEGACY_STAGING_ROOT, "staging")
migrate_legacy_dir(THUMBNAIL_ROOT, LEGACY_THUMBNAIL_ROOT, "thumbnails")
migrate_legacy_state_storage()
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 parse_iso_timestamp(value):
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
def thumbnail_retry_due(checked_at):
timestamp = parse_iso_timestamp(checked_at)
if not timestamp:
return True
age = datetime.now(timezone.utc) - timestamp.astimezone(timezone.utc)
return age.total_seconds() >= THUMBNAIL_RETRY_SECONDS
def thumbnail_file_path(name):
filename = Path(str(name or "")).name
if not filename:
return None
project_path = THUMBNAIL_ROOT / filename
if project_path.exists():
return project_path
legacy_path = LEGACY_THUMBNAIL_ROOT / filename
if legacy_path.exists():
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
try:
shutil.move(str(legacy_path), str(project_path))
debug_log("thumbnail.cache.migrated", from_path=legacy_path, to_path=project_path)
return project_path
except OSError as exc:
debug_log("thumbnail.cache.migrate_failed", from_path=legacy_path, to_path=project_path, error=exc)
return legacy_path
return project_path
def cover_route(show_id):
return f"/api/watchlist/thumb/{quote(str(show_id).strip(), safe='')}"
def anime_source_url(show_id, title):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
return f"{ALLMANGA_SITE_BASE}/search-anime?tr=sub&cty=ALL&query={quote(str(title or '').strip(), safe='')}"
return f"{ALLMANGA_SITE_BASE}/bangumi/{quote(normalized_show_id, safe='')}"
def infer_image_extension(url, content_type):
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
extension = mimetypes.guess_extension(normalized_type, strict=False)
if extension == ".jpe":
extension = ".jpg"
if extension:
return extension
suffix = Path(urlparse(str(url or "")).path).suffix.lower()
if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
return suffix
return ".jpg"
def save_thumbnail_bytes(show_id, source_name, content_type, content):
if not content:
raise RuntimeError("Thumbnail content was empty")
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
stem = sanitize_path_component(show_id, "anime")
extension = infer_image_extension(source_name, content_type)
final_path = THUMBNAIL_ROOT / f"{stem}{extension}"
tmp_path = final_path.with_suffix(final_path.suffix + ".tmp")
with tmp_path.open("wb") as handle:
handle.write(content)
tmp_path.replace(final_path)
for existing in THUMBNAIL_ROOT.glob(f"{stem}.*"):
if existing != final_path and existing.is_file():
existing.unlink(missing_ok=True)
debug_log("thumbnail.cache.write", show_id=show_id, path=final_path, bytes=len(content), content_type=content_type)
return final_path.name
def decode_image_data_url(data_url):
text = str(data_url or "").strip()
match = re.match(r"^data:(image/[-a-zA-Z0-9.+]+);base64,(.+)$", text, re.DOTALL)
if not match:
raise ValueError("Thumbnail upload must be a base64 image data URL.")
content_type = match.group(1).strip().lower()
try:
content = base64.b64decode(match.group(2), validate=True)
except (ValueError, BinasciiError) as exc:
raise ValueError("Thumbnail upload data was not valid base64.") from exc
if len(content) > 8 * 1024 * 1024:
raise ValueError("Thumbnail upload is too large. Keep it under 8 MB.")
return content_type, content
def normalize_title_key(value):
text = unicodedata.normalize("NFKD", str(value or ""))
text = "".join(char for char in text if not unicodedata.combining(char))
text = text.lower()
text = re.sub(r"\((19|20)\d{2}\)", " ", text)
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def base_title_variants(value):
text = str(value or "").strip()
variants = []
if text:
variants.append(text)
patterns = [
r"\s+\((19|20)\d{2}\)\s*$",
r"\s+season\s+\d+\s*$",
r"\s+part\s+\d+\s*$",
r"\s+cour\s+\d+\s*$",
r"\s+\d+(st|nd|rd|th)\s+season\s*$",
]
changed = True
current = text
while current and changed:
changed = False
for pattern in patterns:
trimmed = re.sub(pattern, "", current, flags=re.IGNORECASE).strip(" -:")
if trimmed and trimmed != current:
current = trimmed
if current not in variants:
variants.append(current)
changed = True
return variants
def title_match_variants(value):
variants = set()
for raw_value in base_title_variants(value):
normalized = normalize_title_key(raw_value)
if normalized:
variants.add(normalized)
stripped = re.sub(r"\b(19|20)\d{2}\b", " ", normalized)
stripped = re.sub(r"\s+", " ", stripped).strip()
if stripped:
variants.add(stripped)
return variants
def title_lookup_queries(value):
queries = []
for raw_value in base_title_variants(value):
text = str(raw_value or "").strip()
if text and text not in queries:
queries.append(text)
return queries
def animeschedule_request(path, params=None):
params = params or {}
query = "&".join(f"{quote(str(key), safe='')}={quote(str(value), safe='')}" for key, value in params.items())
url = f"{ANIMESCHEDULE_API}{path}"
if query:
url = f"{url}?{query}"
debug_log("animeschedule.request", url=url, params=params)
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"User-Agent": AGENT,
},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=25) as response:
raw = response.read().decode("utf-8")
debug_log(
"animeschedule.response",
url=url,
status=getattr(response, "status", "unknown"),
bytes=len(raw.encode("utf-8")),
snippet=raw[:200],
)
except urllib.error.URLError as exc:
debug_log("animeschedule.error", url=url, error=exc)
raise RuntimeError(f"Could not reach AnimeSchedule: {exc}") from exc
except TimeoutError as exc:
debug_log("animeschedule.timeout", url=url)
raise RuntimeError("AnimeSchedule request timed out") from exc
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError("AnimeSchedule returned unreadable JSON") from exc
def collect_animeschedule_entries(payload):
entries = []
def walk(node):
if isinstance(node, dict):
route = str(node.get("route") or "").strip()
image_route = str(node.get("imageVersionRoute") or "").strip()
title = str(node.get("title") or "").strip()
names = node.get("names") if isinstance(node.get("names"), dict) else {}
if route and (title or names):
entries.append(
{
"route": route,
"title": title,
"imageVersionRoute": image_route,
"names": names,
}
)
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(payload)
return entries
def animeschedule_title_candidates(entry):
values = []
if entry.get("title"):
values.append(entry["title"])
names = entry.get("names") if isinstance(entry.get("names"), dict) else {}
for key in ("english", "romaji", "native", "abbreviation"):
text = str(names.get(key) or "").strip()
if text:
values.append(text)
synonyms = names.get("synonyms") if isinstance(names.get("synonyms"), list) else []
for value in synonyms:
text = str(value or "").strip()
if text:
values.append(text)
deduped = []
seen = set()
for value in values:
normalized = normalize_title_key(value)
if normalized and normalized not in seen:
seen.add(normalized)
deduped.append(value)
return deduped
def score_animeschedule_match(query_variants, entry):
best = None
for candidate in animeschedule_title_candidates(entry):
normalized = normalize_title_key(candidate)
if not normalized:
continue
score = None
if normalized in query_variants:
score = 110
else:
for variant in query_variants:
if variant and (normalized in variant or variant in normalized):
score = max(score or 0, 80 - abs(len(normalized) - len(variant)))
if score is not None:
best = max(best or score, score)
return best
def fetch_animeschedule_cover_by_route(route, fallback_title=""):
debug_log("thumbnail.source.route_lookup", source="AnimeSchedule", route=route, fallback_title=fallback_title)
payload = animeschedule_request(f"/anime/{quote(str(route).strip(), safe='')}")
if not isinstance(payload, dict):
raise RuntimeError("AnimeSchedule anime response was not an object")
image_route = str(payload.get("imageVersionRoute") or "").strip()
if not image_route:
raise RuntimeError("AnimeSchedule anime did not provide an image route")
debug_log(
"thumbnail.source.route_match",
source="AnimeSchedule",
route=str(payload.get("route") or route).strip(),
image_route=image_route,
title=str(payload.get("title") or fallback_title or "").strip(),
)
return {
"route": str(payload.get("route") or route).strip(),
"title": str(payload.get("title") or fallback_title or "").strip(),
"url": urljoin(ANIMESCHEDULE_IMAGE_BASE, image_route),
}
def fetch_animeschedule_cover(title):
query_variants = title_match_variants(title)
if not query_variants:
raise RuntimeError("Missing title for AnimeSchedule lookup")
queries = title_lookup_queries(title)
debug_log("thumbnail.source.title_lookup", source="AnimeSchedule", title=title, variants=sorted(query_variants), queries=queries)
best = None
candidate_count = 0
last_error = None
for query in queries:
try:
payload = animeschedule_request("/anime", {"q": query, "st": "alphabetic"})
except Exception as exc:
last_error = exc
debug_log("thumbnail.source.query_failed", source="AnimeSchedule", title=title, query=query, error=exc)
continue
for entry in collect_animeschedule_entries(payload):
candidate_count += 1
score = score_animeschedule_match(query_variants, entry)
if score is None:
continue
if best is None or score > best["score"]:
best = {
"route": entry["route"],
"title": next(iter(animeschedule_title_candidates(entry)), entry.get("title") or str(title)),
"imageVersionRoute": str(entry.get("imageVersionRoute") or "").strip(),
"score": score,
}
if best is not None:
break
if not best:
debug_log("thumbnail.source.no_match", source="AnimeSchedule", title=title, candidates=candidate_count, queries=queries)
if last_error is not None:
raise last_error
raise RuntimeError("AnimeSchedule lookup did not find a matching anime")
debug_log(
"thumbnail.source.best_match",
source="AnimeSchedule",
title=title,
route=best["route"],
matched_title=best["title"],
image_route=best["imageVersionRoute"],
score=best["score"],
candidates=candidate_count,
)
if best["imageVersionRoute"]:
return {
"route": best["route"],
"title": str(best["title"]).strip(),
"url": urljoin(ANIMESCHEDULE_IMAGE_BASE, best["imageVersionRoute"]),
}
return fetch_animeschedule_cover_by_route(best["route"], fallback_title=best["title"])
def anidb_titles_cache_due():
if not ANIDB_TITLES_PATH.exists():
return True
age = time.time() - ANIDB_TITLES_PATH.stat().st_mtime
return age >= 24 * 60 * 60
def ensure_anidb_titles_dump(force=False):
if not force and not anidb_titles_cache_due():
return ANIDB_TITLES_PATH
debug_log("anidb.titles.request", url=ANIDB_TITLES_URL, force=force)
request = urllib.request.Request(
ANIDB_TITLES_URL,
headers={
"Accept": "application/gzip, application/octet-stream;q=0.9, */*;q=0.5",
"User-Agent": AGENT,
},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
payload = response.read()
debug_log(
"anidb.titles.response",
status=getattr(response, "status", "unknown"),
bytes=len(payload),
content_type=response.headers.get("Content-Type", ""),
)
except urllib.error.URLError as exc:
debug_log("anidb.titles.error", error=exc)
if ANIDB_TITLES_PATH.exists():
return ANIDB_TITLES_PATH
raise RuntimeError(f"Could not download AniDB titles dump: {exc}") from exc
except TimeoutError as exc:
if ANIDB_TITLES_PATH.exists():
return ANIDB_TITLES_PATH
raise RuntimeError("AniDB titles dump download timed out") from exc
if not payload:
if ANIDB_TITLES_PATH.exists():
return ANIDB_TITLES_PATH
raise RuntimeError("AniDB titles dump was empty")
tmp_path = ANIDB_TITLES_PATH.with_suffix(".xml.gz.tmp")
tmp_path.write_bytes(payload)
tmp_path.replace(ANIDB_TITLES_PATH)
return ANIDB_TITLES_PATH
def iter_anidb_titles():
path = ensure_anidb_titles_dump()
mtime = path.stat().st_mtime if path.exists() else None
if ANIDB_TITLES_CACHE["entries"] is not None and ANIDB_TITLES_CACHE["mtime"] == mtime:
for entry in ANIDB_TITLES_CACHE["entries"]:
yield entry
return
entries = []
with gzip.open(path, "rb") as handle:
context = ET.iterparse(handle, events=("end",))
for _, elem in context:
if elem.tag != "anime":
continue
aid = str(elem.attrib.get("aid") or "").strip()
if not aid:
elem.clear()
continue
for title_elem in elem.findall("title"):
title = str(title_elem.text or "").strip()
if not title:
continue
entries.append(
{
"aid": aid,
"title": title,
"type": str(title_elem.attrib.get("type") or "").strip().lower(),
"lang": str(title_elem.attrib.get("{http://www.w3.org/XML/1998/namespace}lang") or "").strip().lower(),
}
)
elem.clear()
ANIDB_TITLES_CACHE["mtime"] = mtime
ANIDB_TITLES_CACHE["entries"] = entries
for entry in entries:
yield entry
def score_anidb_title_match(query_variants, candidate):
normalized = normalize_title_key(candidate["title"])
if not normalized:
return None
score = None
if normalized in query_variants:
score = 100
else:
for variant in query_variants:
if variant and (normalized in variant or variant in normalized):
score = max(score or 0, 72 - abs(len(normalized) - len(variant)))
if score is None:
return None
type_bonus = {
"official": 12,
"main": 10,
"short": 6,
"syn": 4,
}.get(candidate["type"], 0)
lang_bonus = {
"en": 5,
"x-jat": 4,
"ja": 2,
}.get(candidate["lang"], 0)
return score + type_bonus + lang_bonus
def resolve_anidb_aid(title):
query_variants = title_match_variants(title)
if not query_variants:
raise RuntimeError("Missing title for AniDB lookup")
best = None
candidate_count = 0
for candidate in iter_anidb_titles():
candidate_count += 1
score = score_anidb_title_match(query_variants, candidate)
if score is None:
continue
if best is None or score > best["score"]:
best = {
"aid": candidate["aid"],
"title": candidate["title"],
"score": score,
}
if not best:
debug_log("thumbnail.source.no_match", source="AniDB", title=title, candidates=candidate_count)
raise RuntimeError("AniDB title lookup did not find a matching anime")
debug_log(
"thumbnail.source.best_match",
source="AniDB",
title=title,
aid=best["aid"],
matched_title=best["title"],
score=best["score"],
candidates=candidate_count,
)
return best
def fetch_anidb_cover(title):
match = resolve_anidb_aid(title)
return fetch_anidb_cover_by_aid(match["aid"], fallback_title=match["title"])
def fetch_anidb_cover_by_aid(aid, fallback_title=""):
page_url = ANIDB_ANIME_PAGE.format(aid=aid)
debug_log("thumbnail.source.route_lookup", source="AniDB", aid=aid, fallback_title=fallback_title, page_url=page_url)
request = urllib.request.Request(
page_url,
headers={
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Referer": "https://anidb.net/",
"User-Agent": AGENT,
},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=25) as response:
html = response.read().decode("utf-8", errors="replace")
debug_log(
"anidb.page.response",
aid=aid,
status=getattr(response, "status", "unknown"),
bytes=len(html.encode("utf-8")),
snippet=html[:200],
)
except urllib.error.URLError as exc:
debug_log("anidb.page.error", aid=aid, error=exc)
raise RuntimeError(f"Could not reach AniDB anime page: {exc}") from exc
except TimeoutError as exc:
raise RuntimeError("AniDB anime page request timed out") from exc
candidates = [
r'((?:https?:)?//cdn-[^/"\']+\.anidb\.net/images/main/[^"\'\s>]+)',
r'((?:https?:)?//anidb\.net/images/main/[^"\'\s>]+)',
r'(["\'])(/images/main/[^"\']+)\1',
r'(["\'])(/[^"\']*images/main/[^"\']+)\1',
]
image_url = None
for pattern in candidates:
match = re.search(pattern, html, re.IGNORECASE)
if not match:
continue
image_url = match.group(1) if match.lastindex == 1 else match.group(2)
break
if not image_url:
debug_log("anidb.page.no_image", aid=aid)
raise RuntimeError("AniDB page did not expose a cover image")
if image_url.startswith("//"):
image_url = f"https:{image_url}"
elif image_url.startswith("/"):
image_url = urljoin(page_url, image_url)
debug_log("anidb.page.image", aid=aid, image_url=image_url)
return {
"aid": str(aid).strip(),
"title": str(fallback_title or "").strip(),
"url": image_url,
}
def cache_thumbnail_image(show_id, image_url):
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
debug_log("thumbnail.download.request", show_id=show_id, image_url=image_url)
request = urllib.request.Request(
image_url,
headers={
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://anidb.net/",
"User-Agent": AGENT,
},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=25) as response:
content = response.read()
content_type = response.headers.get("Content-Type", "")
debug_log(
"thumbnail.download.response",
show_id=show_id,
status=getattr(response, "status", "unknown"),
bytes=len(content),
content_type=content_type,
final_url=getattr(response, "url", image_url),
)
except urllib.error.URLError as exc:
debug_log("thumbnail.download.error", show_id=show_id, image_url=image_url, error=exc)
raise RuntimeError(f"Could not download cover image: {exc}") from exc
except TimeoutError as exc:
debug_log("thumbnail.download.timeout", show_id=show_id, image_url=image_url)
raise RuntimeError("Cover image download timed out") from exc
if not content:
raise RuntimeError("Cover image response was empty")
stem = sanitize_path_component(show_id, "anime")
extension = infer_image_extension(image_url, content_type)
final_path = THUMBNAIL_ROOT / f"{stem}{extension}"
tmp_path = final_path.with_suffix(final_path.suffix + ".tmp")
with tmp_path.open("wb") as handle:
handle.write(content)
tmp_path.replace(final_path)
for existing in THUMBNAIL_ROOT.glob(f"{stem}.*"):
if existing != final_path and existing.is_file():
existing.unlink(missing_ok=True)
debug_log("thumbnail.cache.write", show_id=show_id, path=final_path, bytes=len(content), content_type=content_type)
return final_path.name
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 fetch_show_episode_snapshot(show_id):
query = "query ($showId: String!) { show(_id: $showId) { _id name availableEpisodesDetail } }"
data = graph_request(query, {"showId": show_id})
show = data.get("show") or {}
detail = show.get("availableEpisodesDetail") or {}
episodes = {}
for mode in MODE_CHOICES:
values = [str(item).strip() for item in detail.get(mode, []) if str(item).strip()]
episodes[mode] = sorted(values, key=numeric_episode_key)
return {
"show_id": str(show.get("_id") or show_id),
"title": str(show.get("name") or "").replace('\\"', '"').strip(),
"episodes": episodes,
}
def episode_list(show_id, mode):
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub")
return fetch_show_episode_snapshot(show_id)["episodes"][normalized_mode]
class WatchlistStore:
def __init__(self):
self.lock = threading.RLock()
self.thumbnail_events = {}
self._init_db()
self._migrate_legacy_json()
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 watchlist (
show_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
status_message TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_checked TEXT,
sub_count INTEGER NOT NULL DEFAULT 0,
dub_count INTEGER NOT NULL DEFAULT 0,
sub_latest_episode TEXT,
dub_latest_episode TEXT,
animeschedule_route TEXT,
animeschedule_title TEXT,
anidb_aid TEXT,
anidb_title TEXT,
thumbnail_path TEXT,
thumbnail_checked_at TEXT
)
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()}
if "thumbnail_path" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN thumbnail_path TEXT")
if "thumbnail_checked_at" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN thumbnail_checked_at TEXT")
if "anidb_aid" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_aid TEXT")
if "anidb_title" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_title TEXT")
if "animeschedule_route" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT")
if "animeschedule_title" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_title TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_title ON watchlist(title)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_updated_at ON watchlist(updated_at DESC)")
def _row_to_item(self, row):
item = dict(row)
item["sub_count"] = int(item.get("sub_count") or 0)
item["dub_count"] = int(item.get("dub_count") or 0)
item["total_count"] = item["sub_count"] + item["dub_count"]
item["source_url"] = anime_source_url(item.get("show_id"), item.get("title"))
thumb_path = thumbnail_file_path(item.get("thumbnail_path"))
item["thumbnail_ready"] = bool(thumb_path and thumb_path.exists())
item["thumbnail_url"] = cover_route(item.get("show_id"))
if item.get("animeschedule_route"):
title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title"
item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}"
elif item.get("anidb_aid"):
title = str(item.get("anidb_title") or item.get("title") or "").strip() or "unknown title"
item["thumbnail_debug"] = f"AniDB: {item['anidb_aid']} - {title}"
elif item["thumbnail_ready"]:
item["thumbnail_debug"] = "Thumbnail source: manual upload"
else:
item["thumbnail_debug"] = "Thumbnail source: unresolved"
return item
def _upsert_conn(self, conn, item):
conn.execute(
"""
INSERT INTO watchlist (
show_id, title, status, status_message, created_at, updated_at,
last_checked, sub_count, dub_count, sub_latest_episode, dub_latest_episode,
animeschedule_route, animeschedule_title, anidb_aid, anidb_title,
thumbnail_path, thumbnail_checked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(show_id) DO UPDATE SET
title = excluded.title,
status = excluded.status,
status_message = excluded.status_message,
updated_at = excluded.updated_at,
last_checked = excluded.last_checked,
sub_count = excluded.sub_count,
dub_count = excluded.dub_count,
sub_latest_episode = excluded.sub_latest_episode,
dub_latest_episode = excluded.dub_latest_episode,
animeschedule_route = excluded.animeschedule_route,
animeschedule_title = excluded.animeschedule_title,
anidb_aid = excluded.anidb_aid,
anidb_title = excluded.anidb_title,
thumbnail_path = excluded.thumbnail_path,
thumbnail_checked_at = excluded.thumbnail_checked_at
""",
(
item["show_id"],
item["title"],
item["status"],
item["status_message"],
item["created_at"],
item["updated_at"],
item["last_checked"],
int(item.get("sub_count") or 0),
int(item.get("dub_count") or 0),
item.get("sub_latest_episode"),
item.get("dub_latest_episode"),
item.get("animeschedule_route"),
item.get("animeschedule_title"),
item.get("anidb_aid"),
item.get("anidb_title"),
item.get("thumbnail_path"),
item.get("thumbnail_checked_at"),
),
)
def _migrate_legacy_json(self):
if not WATCHLIST_JSON_PATH.exists():
return
legacy = load_json(WATCHLIST_JSON_PATH, {})
if not legacy:
WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated"))
return
aggregated = {}
for value in legacy.values():
if not isinstance(value, dict):
continue
show_id = str(value.get("show_id") or "").strip()
if not show_id:
continue
mode = str(value.get("mode") or "").strip().lower()
if mode not in MODE_CHOICES:
continue
item = aggregated.setdefault(
show_id,
{
"show_id": show_id,
"title": str(value.get("title") or "Unknown Anime").strip() or "Unknown Anime",
"status": str(value.get("status") or "tracked"),
"status_message": str(value.get("status_message") or "Migrated from legacy watchlist."),
"created_at": str(value.get("added_at") or now_iso()),
"updated_at": now_iso(),
"last_checked": value.get("last_checked"),
"sub_count": 0,
"dub_count": 0,
"sub_latest_episode": None,
"dub_latest_episode": None,
"animeschedule_route": None,
"animeschedule_title": None,
"anidb_aid": None,
"anidb_title": None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
},
)
episodes = value.get("episodes_available") if isinstance(value.get("episodes_available"), list) else []
count = len(episodes)
latest = episodes[-1] if episodes else None
if mode == "sub":
item["sub_count"] = max(item["sub_count"], count)
item["sub_latest_episode"] = latest or item["sub_latest_episode"]
else:
item["dub_count"] = max(item["dub_count"], count)
item["dub_latest_episode"] = latest or item["dub_latest_episode"]
if item["title"] == "Unknown Anime" and str(value.get("title") or "").strip():
item["title"] = str(value.get("title")).strip()
with self.lock, self._connect() as conn:
for item in aggregated.values():
self._upsert_conn(conn, item)
WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated"))
def list(self, page=1, per_page=30):
page = max(1, int(page or 1))
per_page = min(60, max(1, int(per_page or 30)))
offset = (page - 1) * per_page
with self.lock, self._connect() as conn:
total = conn.execute("SELECT COUNT(*) AS count FROM watchlist").fetchone()["count"]
totals = conn.execute(
"""
SELECT
COALESCE(SUM(sub_count), 0) AS sub_total,
COALESCE(SUM(dub_count), 0) AS dub_total
FROM watchlist
"""
).fetchone()
rows = conn.execute(
"SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC LIMIT ? OFFSET ?",
(per_page, offset),
).fetchall()
items = [self._row_to_item(row) for row in rows]
pages = max(1, (total + per_page - 1) // per_page)
return {
"items": items,
"total": total,
"page": min(page, pages),
"pages": pages,
"per_page": per_page,
"summary": {
"shows": int(total),
"sub": int(totals["sub_total"] or 0),
"dub": int(totals["dub_total"] or 0),
},
}
def get(self, show_id):
with self.lock, self._connect() as conn:
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (str(show_id).strip(),)).fetchone()
if not row:
raise KeyError("Anime not found in watchlist.")
return self._row_to_item(row)
def add(self, payload):
show_id = str(payload.get("show_id") or "").strip()
title = str(payload.get("title") or "Unknown Anime").strip() or "Unknown Anime"
if not show_id:
raise ValueError("Missing show_id.")
with self.lock, self._connect() as conn:
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (show_id,)).fetchone()
if row:
item = self._row_to_item(row)
return {"message": f"Already tracking {item['title']}.", "item": item, "created": False}
now = now_iso()
item = {
"show_id": show_id,
"title": title,
"status": "tracked",
"status_message": "Waiting for the first manual refresh.",
"created_at": now,
"updated_at": now,
"last_checked": None,
"sub_count": 0,
"dub_count": 0,
"sub_latest_episode": None,
"dub_latest_episode": None,
"animeschedule_route": None,
"animeschedule_title": None,
"anidb_aid": None,
"anidb_title": None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
self._upsert_conn(conn, item)
refreshed = self.refresh(show_id)
return {"message": f"Added {refreshed['title']} to the watchlist.", "item": refreshed, "created": True}
def refresh(self, show_id):
existing = self.get(show_id)
now = now_iso()
try:
snapshot = fetch_show_episode_snapshot(show_id)
sub_episodes = snapshot["episodes"]["sub"]
dub_episodes = snapshot["episodes"]["dub"]
item = {
"show_id": existing["show_id"],
"title": snapshot["title"] or existing["title"],
"status": "updated",
"status_message": f"Sub: {len(sub_episodes)} episodes available. Dub: {len(dub_episodes)} episodes available.",
"created_at": existing["created_at"],
"updated_at": now,
"last_checked": now,
"sub_count": len(sub_episodes),
"dub_count": len(dub_episodes),
"sub_latest_episode": sub_episodes[-1] if sub_episodes else None,
"dub_latest_episode": dub_episodes[-1] if dub_episodes else None,
"animeschedule_route": existing.get("animeschedule_route"),
"animeschedule_title": existing.get("animeschedule_title"),
"anidb_aid": existing.get("anidb_aid"),
"anidb_title": existing.get("anidb_title"),
"thumbnail_path": existing.get("thumbnail_path"),
"thumbnail_checked_at": existing.get("thumbnail_checked_at"),
}
except Exception as exc:
item = {
"show_id": existing["show_id"],
"title": existing["title"],
"status": "error",
"status_message": f"Could not refresh episodes: {exc}",
"created_at": existing["created_at"],
"updated_at": now,
"last_checked": now,
"sub_count": existing["sub_count"],
"dub_count": existing["dub_count"],
"sub_latest_episode": existing.get("sub_latest_episode"),
"dub_latest_episode": existing.get("dub_latest_episode"),
"animeschedule_route": existing.get("animeschedule_route"),
"animeschedule_title": existing.get("animeschedule_title"),
"anidb_aid": existing.get("anidb_aid"),
"anidb_title": existing.get("anidb_title"),
"thumbnail_path": existing.get("thumbnail_path"),
"thumbnail_checked_at": existing.get("thumbnail_checked_at"),
}
with self.lock, self._connect() as conn:
self._upsert_conn(conn, item)
try:
self.ensure_thumbnail(show_id)
except Exception:
pass
return self.get(show_id)
def refresh_all(self):
items = self.list(page=1, per_page=10000)["items"]
refreshed = [self.refresh(item["show_id"]) for item in items]
return {"count": len(refreshed), "items": refreshed, "message": f"Refreshed {len(refreshed)} watchlist entries."}
def remove(self, show_id):
existing = self.get(show_id)
thumb_path = thumbnail_file_path(existing.get("thumbnail_path"))
with self.lock, self._connect() as conn:
cursor = conn.execute("DELETE FROM watchlist WHERE show_id = ?", (str(show_id).strip(),))
if cursor.rowcount < 1:
raise KeyError("Anime not found in watchlist.")
self.thumbnail_events.pop(str(show_id).strip(), None)
if thumb_path and thumb_path.exists():
thumb_path.unlink(missing_ok=True)
return {"ok": True, "message": f"Removed {existing['title']} from the watchlist.", "item": existing}
def set_manual_thumbnail(self, show_id, filename, data_url):
existing = self.get(show_id)
content_type, content = decode_image_data_url(data_url)
cached_name = save_thumbnail_bytes(existing["show_id"], filename or existing["title"], content_type, content)
checked_at = now_iso()
with self.lock, self._connect() as conn:
conn.execute(
"""
UPDATE watchlist
SET thumbnail_path = ?, thumbnail_checked_at = ?, updated_at = ?
WHERE show_id = ?
""",
(cached_name, checked_at, checked_at, existing["show_id"]),
)
item = self.get(show_id)
return {"message": f"Uploaded thumbnail for {item['title']}.", "item": item}
def ensure_thumbnail(self, show_id, force=False):
item = self.get(show_id)
current_path = thumbnail_file_path(item.get("thumbnail_path"))
current_exists = bool(current_path and current_path.exists())
debug_log(
"thumbnail.ensure.start",
show_id=show_id,
force=force,
title=item.get("title"),
cached_path=current_path,
cached_exists=current_exists,
thumbnail_ready=item.get("thumbnail_ready"),
route=item.get("animeschedule_route"),
aid=item.get("anidb_aid"),
)
if current_exists and not force:
debug_log("thumbnail.ensure.cached", show_id=show_id, path=current_path)
return current_path
if not force and current_path is None and not thumbnail_retry_due(item.get("thumbnail_checked_at")):
debug_log("thumbnail.ensure.skipped_retry_window", show_id=show_id, checked_at=item.get("thumbnail_checked_at"))
return None
if current_path and not current_exists:
debug_log("thumbnail.ensure.missing_cached_file", show_id=show_id, expected_path=current_path)
with self.lock:
event = self.thumbnail_events.get(show_id)
if event:
wait_event = event
else:
wait_event = threading.Event()
self.thumbnail_events[show_id] = wait_event
event = None
if event:
wait_event.wait(timeout=20)
refreshed = self.get(show_id)
refreshed_path = thumbnail_file_path(refreshed.get("thumbnail_path"))
debug_log("thumbnail.ensure.waited_for_inflight", show_id=show_id, resolved_path=refreshed_path)
return refreshed_path if refreshed_path and refreshed_path.exists() else None
cached_name = None
cover = None
checked_at = now_iso()
try:
stored_route = str(item.get("animeschedule_route") or "").strip()
stored_schedule_title = str(item.get("animeschedule_title") or item.get("title") or "").strip()
if stored_route:
try:
debug_log("thumbnail.ensure.try_route", source="AnimeSchedule", show_id=show_id, route=stored_route)
cover = fetch_animeschedule_cover_by_route(stored_route, fallback_title=stored_schedule_title)
except Exception:
debug_log("thumbnail.ensure.route_failed", source="AnimeSchedule", show_id=show_id, route=stored_route)
if not force:
raise
if cover is None:
try:
debug_log("thumbnail.ensure.try_title", source="AnimeSchedule", show_id=show_id, title=item["title"])
cover = fetch_animeschedule_cover(item["title"])
except Exception:
debug_log("thumbnail.ensure.title_failed", source="AnimeSchedule", show_id=show_id, title=item["title"])
cover = None
stored_aid = str(item.get("anidb_aid") or "").strip()
stored_title = str(item.get("anidb_title") or item.get("title") or "").strip()
if cover is None and stored_aid:
try:
debug_log("thumbnail.ensure.try_route", source="AniDB", show_id=show_id, aid=stored_aid)
cover = fetch_anidb_cover_by_aid(stored_aid, fallback_title=stored_title)
except Exception:
debug_log("thumbnail.ensure.route_failed", source="AniDB", show_id=show_id, aid=stored_aid)
if not force:
raise
if cover is None:
debug_log("thumbnail.ensure.try_title", source="AniDB", show_id=show_id, title=item["title"])
cover = fetch_anidb_cover(item["title"])
debug_log("thumbnail.ensure.cover_selected", show_id=show_id, source="AnimeSchedule" if cover.get("route") else "AniDB", cover=cover)
cached_name = cache_thumbnail_image(item["show_id"], cover["url"])
except Exception as exc:
debug_log("thumbnail.ensure.failed", show_id=show_id, error=exc)
cached_name = None
checked_at = item.get("thumbnail_checked_at")
with self.lock, self._connect() as conn:
conn.execute(
"""
UPDATE watchlist
SET thumbnail_path = ?, thumbnail_checked_at = ?,
animeschedule_route = ?, animeschedule_title = ?,
anidb_aid = ?, anidb_title = ?
WHERE show_id = ?
""",
(
cached_name,
checked_at,
(cover or {}).get("route"),
(cover or {}).get("title") if (cover or {}).get("route") else item.get("animeschedule_title"),
(cover or {}).get("aid"),
(cover or {}).get("title") if (cover or {}).get("aid") else item.get("anidb_title"),
item["show_id"],
),
)
inflight = self.thumbnail_events.get(show_id)
if inflight is wait_event:
inflight.set()
self.thumbnail_events.pop(show_id, None)
debug_log(
"thumbnail.ensure.finish",
show_id=show_id,
cached_name=cached_name,
route=(cover or {}).get("route"),
aid=(cover or {}).get("aid"),
checked_at=checked_at,
)
return thumbnail_file_path(cached_name)
def ensure_thumbnails(self, show_ids, limit=6, force=False):
normalized = []
for show_id in show_ids:
text = str(show_id or "").strip()
if text and text not in normalized:
normalized.append(text)
updated = []
for show_id in normalized[: max(1, min(int(limit or 6), 10))]:
item = self.get(show_id)
if not force and item.get("thumbnail_ready"):
updated.append(item)
continue
try:
self.ensure_thumbnail(show_id, force=force)
except Exception:
pass
updated.append(self.get(show_id))
return {"items": updated}
WATCHLIST = WatchlistStore()
def add_to_watchlist(payload):
return WATCHLIST.add(payload)
def get_watchlist(page=1, per_page=30):
return WATCHLIST.list(page=page, per_page=per_page)
def update_watchlist_status(show_id, _mode=None):
item = WATCHLIST.refresh(show_id)
return {"message": f"Updated {item['title']}.", "item": item}
def update_all_watchlist_statuses():
return WATCHLIST.refresh_all()
def remove_from_watchlist(show_id):
return WATCHLIST.remove(show_id)
def upload_watchlist_thumbnail(payload):
return WATCHLIST.set_manual_thumbnail(payload["show_id"], payload.get("filename"), payload.get("data_url"))
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 == "/watchlist":
self.html(WATCHLIST_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.startswith("/api/watchlist/thumb/"):
params = parse_qs(parsed.query)
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
force = (params.get("force") or ["0"])[0] == "1"
debug_log("thumbnail.route.request", show_id=show_id, force=force, path=parsed.path, query=parsed.query)
path = WATCHLIST.ensure_thumbnail(show_id, force=force)
if not path or not path.exists():
debug_log("thumbnail.route.miss", show_id=show_id, resolved_path=path)
self.error(HTTPStatus.NOT_FOUND, "Thumbnail not found")
return
debug_log("thumbnail.route.hit", show_id=show_id, resolved_path=path)
self.file(path)
elif parsed.path in {"/api/watchlist", "/api/watchlist/get"}:
params = parse_qs(parsed.query)
page = (params.get("page") or ["1"])[0]
per_page = (params.get("per_page") or ["30"])[0]
return self.json(get_watchlist(page=page, per_page=per_page))
else:
self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
self.exception(exc)
def do_POST(self):
parsed = urlparse(self.path)
try:
if parsed.path == "/api/config":
self.update_config(self.body_json())
elif parsed.path == "/api/queue":
self.json(DOWNLOAD_QUEUE.add(self.body_json()), HTTPStatus.CREATED)
elif parsed.path == "/api/queue/retry-failed":
self.json(DOWNLOAD_QUEUE.retry_all_failed())
elif parsed.path == "/api/queue/clear-finished":
self.json(DOWNLOAD_QUEUE.clear_finished())
elif parsed.path.startswith("/api/queue/"):
parts = parsed.path.strip("/").split("/")
if len(parts) != 4:
self.error(HTTPStatus.NOT_FOUND, "Not found")
return
_, _, job_id, action = parts
actions = {
"retry": DOWNLOAD_QUEUE.retry,
"cancel": DOWNLOAD_QUEUE.cancel,
"remove": DOWNLOAD_QUEUE.remove,
}
if action not in actions:
self.error(HTTPStatus.NOT_FOUND, "Not found")
return
self.json(actions[action](job_id))
elif parsed.path in {"/api/watchlist", "/api/watchlist/add"}:
self.json(add_to_watchlist(self.body_json()), HTTPStatus.CREATED)
elif parsed.path == "/api/watchlist/ensure-thumbnails":
payload = self.body_json()
self.json(
WATCHLIST.ensure_thumbnails(
payload.get("show_ids") or [],
limit=payload.get("limit", 6),
force=bool(payload.get("force")),
)
)
elif parsed.path == "/api/watchlist/update-status":
payload = self.body_json()
self.json(update_watchlist_status(payload["show_id"], payload.get("mode")))
elif parsed.path == "/api/watchlist/update-all":
self.json(update_all_watchlist_statuses())
elif parsed.path == "/api/watchlist/remove":
payload = self.body_json()
self.json(remove_from_watchlist(payload["show_id"]))
elif parsed.path == "/api/watchlist/upload-thumbnail":
payload = self.body_json()
self.json(upload_watchlist_thumbnail(payload))
else:
self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
self.exception(exc)
def update_config(self, payload):
global CONFIG
config = normalize_config(payload)
Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
CONFIG = config
write_json(CONFIG_PATH, CONFIG)
self.json(CONFIG)
def body_json(self):
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length).decode("utf-8") if length else "{}"
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("Invalid JSON body") from exc
def html(self, content):
data = content.encode("utf-8")
self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=utf-8"})
def file(self, path):
payload = Path(path).read_bytes()
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
debug_log("file.serve", path=path, content_type=content_type, bytes=len(payload))
self.write_response_bytes(
payload,
HTTPStatus.OK,
{
"Content-Type": content_type,
"Cache-Control": "public, max-age=86400",
},
)
def json(self, payload, status=HTTPStatus.OK):
data = json.dumps(payload).encode("utf-8")
self.write_response_bytes(data, status, {"Content-Type": "application/json"})
def write_response_bytes(self, payload, status, headers):
try:
self.send_response(status)
for key, value in headers.items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
except CLIENT_DISCONNECT_ERRORS:
return
def error(self, status, message):
try:
self.json({"error": message}, status)
except CLIENT_DISCONNECT_ERRORS:
return
def exception(self, exc):
if isinstance(exc, CLIENT_DISCONNECT_ERRORS):
return
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;
}
.page-links {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.page-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0 12px;
color: var(--text);
background: var(--panel-2);
font-weight: 700;
text-decoration: none;
}
.page-link.active {
background: #17242d;
border-color: var(--focus);
color: var(--text);
}
.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;
}
.statline {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@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>
<div class="page-links">
<a class="page-link active" href="/">Search</a>
<a class="page-link" href="/watchlist">Watchlist</a>
</div>
<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" id="searchBtn" type="button">Search</button>
</div>
<div class="notice" id="notice"></div>
<div class="results" id="results"></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>
<div class="row" style="flex:0 0 auto">
<button class="ghost" id="trackBtn" type="button" disabled>Add to watchlist</button>
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
</div>
</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) {
const notice = $("notice");
if (notice) notice.textContent = text || "";
}
function setMode(mode) {
state.config.mode = mode;
$("search-mode").value = mode;
}
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;
$("trackBtn").disabled = false;
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 = $("search-query").value.trim();
if (!query) return setNotice("Type a title first.");
setMode($("search-mode").value);
setNotice("Searching...");
state.selected = null;
$("addBtn").disabled = true;
$("trackBtn").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 addSelectedToWatchlist() {
if (!state.selected) {
setNotice("Select a search result first.");
return;
}
try {
const data = await api("/api/watchlist", {
method: "POST",
body: JSON.stringify({ show_id: state.selected.id, title: state.selected.title })
});
setNotice(data.message || "Added to watchlist.");
} 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: $("search-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;
$("search-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: $("search-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);
}
$("searchBtn").addEventListener("click", search);
$("search-query").addEventListener("keydown", (event) => {
if (event.key === "Enter") search();
});
$("search-mode").addEventListener("change", (event) => setMode(event.target.value));
$("trackBtn").addEventListener("click", addSelectedToWatchlist);
$("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"));
$("search-quality").addEventListener("change", () => { state.config.quality = $("search-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 {
const params = new URLSearchParams(window.location.search);
const query = (params.get("query") || "").trim();
if (query) $("search-query").value = query;
await loadConfig();
await loadDeps();
await loadQueue();
if (query) await search();
state.queueTimer = setInterval(loadQueue, 2500);
} catch (error) {
setNotice(error.message);
}
})();
</script>
</body>
</html>
"""
WATCHLIST_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 - watchlist</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 {
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;
}
input {
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 { 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(240px, 280px) 1fr;
min-height: 100vh;
}
aside, main {
padding: 16px;
}
aside {
border-right: 1px solid var(--line);
background: var(--panel);
display: grid;
align-content: start;
gap: 16px;
}
main {
display: grid;
gap: 18px;
min-width: 0;
}
.topline, .toolbar, .row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.topline, .toolbar {
justify-content: space-between;
}
.page-links {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.page-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0 12px;
color: var(--text);
background: var(--panel-2);
font-weight: 700;
text-decoration: none;
}
.page-link.active {
background: #17242d;
border-color: var(--focus);
color: var(--text);
}
h1, h2, h3, p { margin: 0; }
h1 { font-size: 24px; }
h2 { font-size: 16px; }
h3 { font-size: 15px; }
.row > * { flex: 1; }
.row {
align-items: end;
}
.settings, .summary-card, .watchlist-shell {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.settings {
padding: 14px;
display: grid;
gap: 12px;
}
.watchlist-shell {
padding: 14px;
display: grid;
gap: 12px;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
padding: 14px;
display: grid;
gap: 4px;
}
.summary-value {
font-size: 30px;
font-weight: 800;
line-height: 1;
}
.summary-label {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 8px;
color: var(--muted);
background: #11171c;
font-size: 12px;
white-space: nowrap;
}
.notice {
min-height: 20px;
color: var(--accent-2);
font-size: 13px;
}
.field-hint {
color: var(--muted);
font-size: 12px;
}
.stack {
display: grid;
gap: 6px;
}
.watchlist-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.anime-card {
border: 1px solid var(--line);
border-radius: 10px;
background: linear-gradient(180deg, rgba(32, 40, 50, 0.8) 0%, rgba(23, 29, 35, 1) 100%);
padding: 10px;
display: grid;
gap: 10px;
min-width: 0;
}
.anime-card.error {
border-color: #834242;
}
.cover-tile {
aspect-ratio: 3 / 4;
position: relative;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(124, 199, 255, 0.15);
background:
radial-gradient(circle at top, rgba(124, 199, 255, 0.24), transparent 44%),
linear-gradient(180deg, #1a242d 0%, #0d1115 100%);
}
.cover-tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-fallback {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #d8e5ef;
font-size: 28px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
background: linear-gradient(180deg, rgba(73, 176, 125, 0.2), rgba(9, 13, 16, 0.15));
}
.cover-tile.has-image .cover-fallback {
display: none;
}
.thumb-reload-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
min-height: 28px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid rgba(216, 229, 239, 0.2);
background: rgba(9, 13, 16, 0.82);
color: #d8e5ef;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.thumb-reload-btn:hover,
.thumb-reload-btn:focus-visible {
background: rgba(17, 23, 28, 0.96);
}
.thumb-reload-btn:disabled {
opacity: 0.65;
cursor: wait;
}
.thumb-upload-btn {
position: absolute;
left: 8px;
bottom: 8px;
z-index: 2;
min-height: 28px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid rgba(216, 229, 239, 0.2);
background: rgba(9, 13, 16, 0.82);
color: #d8e5ef;
font-size: 11px;
font-weight: 700;
cursor: pointer;
display: none;
}
.thumb-upload-btn:hover,
.thumb-upload-btn:focus-visible {
background: rgba(17, 23, 28, 0.96);
}
.thumb-upload-btn:disabled {
opacity: 0.65;
cursor: wait;
}
.card-title {
font-size: 14px;
font-weight: 800;
line-height: 1.3;
min-height: 2.6em;
overflow-wrap: anywhere;
}
.card-title a {
color: inherit;
text-decoration: none;
}
.card-title a:hover,
.card-title a:focus-visible {
text-decoration: underline;
}
.count-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.count-pill {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--line);
border-radius: 999px;
background: #11171c;
color: var(--muted);
font-size: 12px;
padding: 4px 8px;
}
.count-pill strong {
color: var(--text);
font-size: 13px;
}
.card-date {
min-height: 32px;
font-size: 12px;
line-height: 1.35;
}
.card-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.card-actions button {
min-height: 34px;
padding: 0 10px;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.pager-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.empty {
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--muted);
padding: 22px;
text-align: center;
}
@media (max-width: 1500px) {
.watchlist-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 1240px) {
.watchlist-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); }
.toolbar, .row { align-items: stretch; flex-direction: column; }
.summary-grid { grid-template-columns: 1fr; }
.watchlist-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.watchlist-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="app">
<aside>
<div class="topline">
<h1>ani-cli web</h1>
<span class="badge">Watchlist</span>
</div>
<div class="page-links">
<a class="page-link" href="/">Search</a>
<a class="page-link active" href="/watchlist">Watchlist</a>
</div>
<section class="settings">
<div class="toolbar">
<div>
<h2>Add manually</h2>
<p class="muted">Track a show directly when you already know its AllAnime ID.</p>
</div>
</div>
<form id="watchlist-form" class="stack" onsubmit="event.preventDefault(); addAnimeToWatchlist();">
<label>Anime title
<input type="text" id="watchlist-title" placeholder="Anime Title" required>
</label>
<label>Show ID
<input type="text" id="watchlist-show-id" placeholder="Show ID" required>
</label>
<div class="field-hint">Tip: you can also add entries directly from the Search page.</div>
<button class="primary" type="submit">Add to watchlist</button>
</form>
</section>
<section class="settings">
<h2>How it works</h2>
<div class="stack muted">
<p>Use Refresh to pull the latest sub and dub episode counts for a single show.</p>
<p>Use Open In Search to jump back to the main page with the title pre-filled.</p>
<p>Refresh All updates your whole tracked list in one pass.</p>
</div>
</section>
</aside>
<main>
<section class="watchlist-shell">
<div class="toolbar">
<div>
<h2>Tracked shows</h2>
<p class="muted">Compact cards with local AnimeSchedule cover caching when artwork is available.</p>
</div>
<button class="ghost" id="refreshAllWatchlistBtn" type="button">Refresh All</button>
</div>
<div class="notice" id="notice"></div>
<div class="summary-grid" id="watchlist-summary">
<div class="summary-card">
<span class="summary-value">0</span>
<span class="summary-label">Tracked shows</span>
</div>
<div class="summary-card">
<span class="summary-value">0</span>
<span class="summary-label">Sub episodes</span>
</div>
<div class="summary-card">
<span class="summary-value">0</span>
<span class="summary-label">Dub episodes</span>
</div>
</div>
</section>
<section class="watchlist-shell">
<div class="toolbar">
<div>
<h2>Watchlist library</h2>
<p class="muted" id="watchlist-meta">Compact poster grid for faster scanning.</p>
</div>
</div>
<div class="watchlist-grid" id="watchlist-container"></div>
<div class="pager" id="watchlistPager"></div>
</section>
</main>
</div>
<script>
const state = {
watchlistPage: 1,
watchlistPages: 1,
watchlistTotal: 0,
watchlistPerPage: 30,
thumbnailNonce: Date.now()
};
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 coverInitials(title) {
const parts = String(title || "")
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!parts.length) return "AN";
return parts.map((part) => part[0]).join("").slice(0, 2);
}
function renderSummary(summary) {
const totals = summary || { shows: 0, sub: 0, dub: 0 };
const cards = $("watchlist-summary").querySelectorAll(".summary-card");
cards[0].querySelector(".summary-value").textContent = String(totals.shows);
cards[1].querySelector(".summary-value").textContent = String(totals.sub);
cards[2].querySelector(".summary-value").textContent = String(totals.dub);
$("watchlist-meta").textContent = totals.shows
? `${totals.shows} tracked shows across ${state.watchlistPages} pages.`
: "Track shows and manually refresh sub and dub episode counts.";
}
function renderPager() {
const pager = $("watchlistPager");
const start = state.watchlistTotal ? ((state.watchlistPage - 1) * state.watchlistPerPage) + 1 : 0;
const end = Math.min(state.watchlistPage * state.watchlistPerPage, state.watchlistTotal);
pager.innerHTML = `
<span class="muted"></span>
<div class="pager-controls"></div>
`;
pager.querySelector(".muted").textContent =
`${start}-${end} of ${state.watchlistTotal} · page ${state.watchlistPage} of ${state.watchlistPages}`;
const controls = pager.querySelector(".pager-controls");
const prev = document.createElement("button");
prev.type = "button";
prev.className = "ghost";
prev.textContent = "Previous";
prev.disabled = state.watchlistPage <= 1;
prev.addEventListener("click", () => fetchWatchlist(state.watchlistPage - 1));
const next = document.createElement("button");
next.type = "button";
next.className = "ghost";
next.textContent = "Next";
next.disabled = state.watchlistPage >= state.watchlistPages;
next.addEventListener("click", () => fetchWatchlist(state.watchlistPage + 1));
controls.append(prev, next);
}
function applyThumbnail(card, item, force = false) {
const tile = card.querySelector(".cover-tile");
const img = card.querySelector("img");
const uploadButton = card.querySelector(".thumb-upload-btn");
const src = `${item.thumbnail_url}?v=${encodeURIComponent(item.thumbnail_checked_at || "")}&n=${state.thumbnailNonce}${force ? `&t=${Date.now()}` : ""}`;
const showImage = () => {
img.style.display = "block";
tile.classList.add("has-image");
if (uploadButton) uploadButton.style.display = "none";
};
const hideImage = () => {
img.style.display = "none";
tile.classList.remove("has-image");
if (uploadButton) uploadButton.style.display = "inline-flex";
};
card.title = item.thumbnail_debug || "Thumbnail source: unresolved";
tile.title = item.thumbnail_debug || "Thumbnail source: unresolved";
img.alt = `${item.title} cover`;
hideImage();
img.dataset.retry = force ? "1" : "0";
img.onload = showImage;
img.onerror = hideImage;
if (img.src !== src) {
img.src = src;
}
if (img.complete) {
if (img.naturalWidth > 0) {
showImage();
} else {
hideImage();
}
}
}
function setThumbnailReloading(card, reloading) {
const button = card.querySelector(".thumb-reload-btn");
if (!button) return;
button.disabled = !!reloading;
button.textContent = reloading ? "..." : "Reload";
}
function setThumbnailUploading(card, uploading) {
const button = card.querySelector(".thumb-upload-btn");
if (!button) return;
button.disabled = !!uploading;
button.textContent = uploading ? "..." : "Upload";
}
function findCardByShowId(showId) {
return Array.from(document.querySelectorAll(".anime-card"))
.find((card) => card.dataset.showId === showId) || null;
}
async function hydrateThumbnailsOnce(items) {
const missing = items.filter((item) => !item.thumbnail_ready).map((item) => item.show_id).slice(0, 8);
if (!missing.length) return;
try {
const data = await api("/api/watchlist/ensure-thumbnails", {
method: "POST",
body: JSON.stringify({ show_ids: missing, limit: 8, force: false })
});
for (const item of data.items || []) {
const card = findCardByShowId(item.show_id);
if (!card) continue;
applyThumbnail(card, item, true);
}
} catch (_error) {
}
}
async function reloadWatchlistThumbnail(showId, title) {
const card = findCardByShowId(showId);
if (card) setThumbnailReloading(card, true);
setNotice(`Reloading thumbnail for ${title || "selected anime"}...`);
try {
const data = await api("/api/watchlist/ensure-thumbnails", {
method: "POST",
body: JSON.stringify({ show_ids: [showId], limit: 1, force: true })
});
const item = (data.items || [])[0];
if (card && item) {
applyThumbnail(card, item, true);
}
if (item && item.thumbnail_ready) {
setNotice(`Redownloaded thumbnail for ${item.title || title || "selected anime"}.`);
} else {
setNotice(`Could not redownload thumbnail for ${title || "selected anime"}.`);
}
} catch (error) {
setNotice(error.message);
} finally {
if (card) setThumbnailReloading(card, false);
}
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("Could not read the selected image file."));
reader.readAsDataURL(file);
});
}
async function uploadWatchlistThumbnail(showId, title, file) {
if (!file) return;
if (!String(file.type || "").startsWith("image/")) {
setNotice("Choose an image file for the thumbnail.");
return;
}
if (file.size > 8 * 1024 * 1024) {
setNotice("Thumbnail image is too large. Keep it under 8 MB.");
return;
}
const card = findCardByShowId(showId);
if (card) setThumbnailUploading(card, true);
setNotice(`Uploading thumbnail for ${title || "selected anime"}...`);
try {
const dataUrl = await readFileAsDataUrl(file);
const data = await api("/api/watchlist/upload-thumbnail", {
method: "POST",
body: JSON.stringify({ show_id: showId, filename: file.name, data_url: dataUrl })
});
if (card && data.item) {
applyThumbnail(card, data.item, true);
}
setNotice(data.message || `Uploaded thumbnail for ${title || "selected anime"}.`);
} catch (error) {
setNotice(error.message);
} finally {
if (card) setThumbnailUploading(card, false);
}
}
function renderWatchlist(data) {
const items = data.items || [];
state.watchlistPage = data.page || 1;
state.watchlistPages = data.pages || 1;
state.watchlistTotal = data.total || 0;
state.watchlistPerPage = data.per_page || 30;
const container = $("watchlist-container");
container.innerHTML = "";
renderSummary(data.summary);
if (!items.length) {
container.innerHTML = '<div class="empty">Your watchlist is empty. Add anime here or from the search page.</div>';
renderPager();
return;
}
for (const item of items) {
const card = document.createElement("article");
const checked = item.last_checked ? new Date(item.last_checked).toLocaleString() : "Never";
card.className = `anime-card${item.status === "error" ? " error" : ""}`;
card.dataset.showId = item.show_id;
card.title = item.thumbnail_debug || "Thumbnail source: unresolved";
card.innerHTML = `
<div class="cover-tile">
<img alt="">
<div class="cover-fallback"></div>
<button class="thumb-reload-btn" type="button" title="Redownload thumbnail">Reload</button>
<button class="thumb-upload-btn" type="button" title="Upload thumbnail">Upload</button>
<input class="thumb-upload-input" type="file" accept="image/*" hidden>
</div>
<div class="card-title"></div>
<div class="count-row">
<span class="count-pill">Sub <strong>${item.sub_count || 0}</strong></span>
<span class="count-pill">Dub <strong>${item.dub_count || 0}</strong></span>
</div>
<div class="muted card-date"></div>
<div class="card-actions">
<button class="primary" type="button">Refresh</button>
<button class="ghost" type="button">Open</button>
<button class="ghost" type="button">Remove</button>
</div>
`;
card.querySelector(".cover-fallback").textContent = coverInitials(item.title);
const titleNode = card.querySelector(".card-title");
const titleLink = document.createElement("a");
titleLink.href = item.source_url || `/?query=${encodeURIComponent(item.title || item.show_id)}`;
titleLink.target = "_blank";
titleLink.rel = "noopener noreferrer";
titleLink.textContent = item.title;
titleNode.replaceChildren(titleLink);
card.querySelector(".card-date").textContent = `Last checked: ${checked}`;
card.querySelector(".thumb-reload-btn").addEventListener("click", () => reloadWatchlistThumbnail(item.show_id, item.title));
const uploadButton = card.querySelector(".thumb-upload-btn");
const uploadInput = card.querySelector(".thumb-upload-input");
uploadButton.addEventListener("click", () => uploadInput.click());
uploadInput.addEventListener("change", () => {
const [file] = Array.from(uploadInput.files || []);
uploadWatchlistThumbnail(item.show_id, item.title, file).finally(() => {
uploadInput.value = "";
});
});
const actionButtons = card.querySelector(".card-actions").querySelectorAll("button");
const refreshBtn = actionButtons[0];
const searchBtn = actionButtons[1];
const removeBtn = actionButtons[2];
refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id));
searchBtn.addEventListener("click", () => {
window.location.href = `/?query=${encodeURIComponent(item.title || item.show_id)}`;
});
removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title));
container.appendChild(card);
applyThumbnail(card, item, false);
}
renderPager();
window.setTimeout(() => hydrateThumbnailsOnce(items), 150);
}
async function fetchWatchlist(page = state.watchlistPage || 1) {
setNotice("Loading watchlist...");
try {
const data = await api(`/api/watchlist?page=${page}&per_page=30`);
if (data.page > data.pages && data.pages > 0) {
return fetchWatchlist(data.pages);
}
renderWatchlist(data);
setNotice("");
} catch (error) {
setNotice(error.message);
throw error;
}
}
async function addAnimeToWatchlist() {
const showId = $("watchlist-show-id").value.trim();
const title = $("watchlist-title").value.trim();
if (!showId || !title) {
setNotice("Fill in the watchlist title and show ID.");
return;
}
try {
const data = await api("/api/watchlist", {
method: "POST",
body: JSON.stringify({ show_id: showId, title })
});
$("watchlist-form").reset();
setNotice(data.message || "Added to watchlist.");
await fetchWatchlist();
} catch (error) {
setNotice(error.message);
}
}
async function refreshWatchlistItem(showId) {
try {
const data = await api("/api/watchlist/update-status", {
method: "POST",
body: JSON.stringify({ show_id: showId })
});
setNotice(data.message || "Watchlist status updated.");
await fetchWatchlist();
} catch (error) {
setNotice(error.message);
}
}
async function removeWatchlistItem(showId, title) {
if (!window.confirm(`Remove ${title || "this anime"} from the watchlist?`)) {
return;
}
try {
const data = await api("/api/watchlist/remove", {
method: "POST",
body: JSON.stringify({ show_id: showId })
});
setNotice(data.message || "Removed from watchlist.");
await fetchWatchlist(state.watchlistPage);
} catch (error) {
setNotice(error.message);
}
}
async function refreshAllWatchlist() {
setNotice("Refreshing all watchlist entries...");
try {
const data = await api("/api/watchlist/update-all", {
method: "POST",
body: "{}"
});
setNotice(data.message || "Watchlist refreshed.");
await fetchWatchlist();
} catch (error) {
setNotice(error.message);
}
}
$("refreshAllWatchlistBtn").addEventListener("click", refreshAllWatchlist);
(async function init() {
try {
await fetchWatchlist();
} catch (error) {
setNotice(error.message);
}
})();
</script>
</body>
</html>
"""
def server_host():
return os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1").strip() or "127.0.0.1"
def server_port():
raw = str(os.environ.get("ANI_CLI_WEB_PORT", "8421")).strip() or "8421"
try:
port = int(raw, 10)
except ValueError as exc:
raise ValueError("ANI_CLI_WEB_PORT must be a valid integer") from exc
if not 1 <= port <= 65535:
raise ValueError("ANI_CLI_WEB_PORT must be between 1 and 65535")
return port
def parse_runtime_flags(argv):
global DEBUG_MODE
remaining = []
for arg in argv:
if arg in {"--debug", "---debug"}:
DEBUG_MODE = True
continue
remaining.append(arg)
return remaining
def main():
parse_runtime_flags(sys.argv[1:])
host = server_host()
port = server_port()
server = ThreadingHTTPServer((host, port), Handler)
debug_log("server.start", version=VERSION, host=host, port=port, argv=sys.argv[1:])
print(f"Serving {APP_NAME} {VERSION} on http://{host}:{port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down server.")
finally:
server.server_close()
if __name__ == "__main__":
main()