3682 lines
128 KiB
Python
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()
|