Files
ani-cli-web/app.py
T

2810 lines
117 KiB
Python

#!/usr/bin/env python3
import base64
import gzip
import json
import mimetypes
import os
import re
import shutil
import sqlite3
import threading
import time
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.server import ThreadingHTTPServer
from pathlib import Path
import sys
from urllib.parse import quote, urljoin, urlparse
import app_support
from app_support import (
AGENT,
ALLANIME_API,
ALLANIME_REFERER,
ALLMANGA_SITE_BASE,
ANIDB_ANIME_PAGE,
ANIDB_TITLES_CACHE,
ANIDB_TITLES_PATH,
ANIDB_TITLES_URL,
ANIMESCHEDULE_API,
ANIMESCHEDULE_IMAGE_BASE,
APP_NAME,
CONFIG_PATH,
DEFAULT_CONFIG,
MAX_JSON_BODY_BYTES,
MODE_CHOICES,
QUALITY_CHOICES,
send_discord_webhook_event,
STATE_DB_PATH,
THUMBNAIL_RETRY_SECONDS,
THUMBNAIL_ROOT,
VERSION,
WATCHLIST_CATEGORY_CHOICES,
WATCHLIST_CATEGORY_LABELS,
WATCHLIST_JSON_PATH,
WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS,
WORKER_SHUTDOWN_TIMEOUT_SECONDS,
background_workers_enabled,
client_address_is_local,
debug_log,
load_json,
migrate_legacy_state_storage,
normalize_season,
normalize_config,
normalize_episode_offset,
normalize_media_type,
now_iso,
sanitize_path_component,
thumbnail_file_path,
write_json,
)
from http_handler import HandlerContext, build_handler_class, server_host, server_port
from queue_jobs import DownloadQueue, WatchlistAutoRefreshScheduler, WatchlistRefreshJobs
from title_matching import (
base_title_variants,
normalize_title_key,
normalize_identity_title_key,
title_lookup_queries,
title_match_variants,
)
from watchlist_identity import resolve_job_watchlist_identity as resolve_job_watchlist_identity_impl
from web_templates import CONFIG_HTML, INDEX_HTML, PAGE_LINKS, QUEUE_HTML, WATCHLIST_HTML, render_page_links, render_sidebar_brand
RUNTIME_LOCK = threading.RLock()
class HttpError(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
self.message = message
CONFIG = None
WATCHLIST = None
DOWNLOAD_QUEUE = None
WATCHLIST_REFRESH = None
WATCHLIST_AUTO_REFRESH = None
def graph_request(query, variables, timeout=25):
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=timeout) 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 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 animeschedule_request(path, params=None, timeout=25):
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=timeout) 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 encode_alternative_titles(alternative_titles):
normalized = []
for item in alternative_titles or []:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
if label and value:
normalized.append({"label": label, "value": value})
return json.dumps(normalized, ensure_ascii=True)
def decode_alternative_titles(payload):
if not payload:
return []
try:
raw = json.loads(payload)
except (TypeError, ValueError, json.JSONDecodeError):
return []
normalized = []
seen = set()
for item in raw if isinstance(raw, list) else []:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
normalized_key = (label.lower(), normalize_title_key(value) or value.casefold())
if not label or not value or not normalized_key[1] or normalized_key in seen:
continue
seen.add(normalized_key)
normalized.append({"label": label, "value": value})
return normalized
def extract_anidb_alternative_titles(aid, primary_title=""):
normalized_aid = str(aid or "").strip()
if not normalized_aid:
return []
primary_key = normalize_title_key(primary_title) or str(primary_title or "").strip().casefold()
results = []
seen = set()
type_labels = {
"official": "English",
"main": "English",
"short": "English",
"syn": "English",
}
for entry in get_cached_anidb_titles():
if str(entry.get("aid") or "").strip() != normalized_aid:
continue
if str(entry.get("lang") or "").strip().lower() != "en":
continue
text = str(entry.get("title") or "").strip()
normalized = normalize_title_key(text) or text.casefold()
if not normalized or normalized == primary_key or normalized in seen:
continue
seen.add(normalized)
label = type_labels.get(str(entry.get("type") or "").strip().lower(), "English")
results.append({"label": label, "value": text})
return results
def parse_nonnegative_int(value, default=0):
try:
number = int(value)
except (TypeError, ValueError):
return default
return number if number >= 0 else default
def normalize_animeschedule_status(value):
text = str(value or "").strip()
return text.title() if text else ""
def normalize_watchlist_category(value, default="watching"):
text = str(value or "").strip().lower()
aliases = {
"watching": "watching",
"planned": "planned",
"planed": "planned",
"finished": "finished",
"dropped": "dropped",
}
return aliases.get(text, default)
def watchlist_category_label(value):
return WATCHLIST_CATEGORY_LABELS[normalize_watchlist_category(value)]
def media_type_label(value):
return "Movie" if normalize_media_type(value) == "movie" else "TV"
def normalize_animeschedule_entry(node):
if not isinstance(node, dict):
return None
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 not route or not (title or names):
return None
return {
"route": route,
"title": title,
"imageVersionRoute": image_route,
"names": names,
"status": normalize_animeschedule_status(node.get("status")),
"episodes": parse_nonnegative_int(node.get("episodes")),
"subPremier": str(node.get("subPremier") or "").strip() or None,
"dubPremier": str(node.get("dubPremier") or "").strip() or None,
}
def collect_animeschedule_entries(payload):
entries = []
def walk(node):
if isinstance(node, dict):
entry = normalize_animeschedule_entry(node)
if entry:
entries.append(entry)
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)
entry = fetch_animeschedule_anime_by_route(route, fallback_title=fallback_title)
image_route = str(entry.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=entry["route"],
image_route=image_route,
title=entry["title"],
)
return {
"route": entry["route"],
"title": entry["title"],
"url": urljoin(ANIMESCHEDULE_IMAGE_BASE, image_route),
}
def find_animeschedule_match(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,
)
return best
def fetch_animeschedule_anime_by_route(route, fallback_title="", timeout=25):
payload = animeschedule_request(f"/anime/{quote(str(route).strip(), safe='')}", timeout=timeout)
entry = normalize_animeschedule_entry(payload)
if not entry:
raise RuntimeError("AnimeSchedule anime response was missing required fields")
if fallback_title and not entry.get("title"):
entry["title"] = str(fallback_title).strip()
return entry
def resolve_animeschedule_anime(title, route="", timeout=25):
stored_route = str(route or "").strip()
if stored_route:
try:
return fetch_animeschedule_anime_by_route(stored_route, fallback_title=title, timeout=timeout)
except Exception:
debug_log("animeschedule.route_refresh_failed", route=stored_route, title=title)
best = find_animeschedule_match(title)
return fetch_animeschedule_anime_by_route(best["route"], fallback_title=best["title"], timeout=timeout)
def fetch_animeschedule_cover(title):
best = find_animeschedule_match(title)
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
ANIDB_TITLES_PATH.parent.mkdir(parents=True, exist_ok=True)
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 = []
title_index = {}
token_index = {}
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
normalized = normalize_title_key(title)
entry = {
"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(),
"normalized_title": normalized,
}
index = len(entries)
entries.append(entry)
if normalized:
title_index.setdefault(normalized, []).append(index)
for token in set(normalized.split()):
if len(token) >= 3:
token_index.setdefault(token, set()).add(index)
elem.clear()
ANIDB_TITLES_CACHE["mtime"] = mtime
ANIDB_TITLES_CACHE["entries"] = entries
ANIDB_TITLES_CACHE["title_index"] = title_index
ANIDB_TITLES_CACHE["token_index"] = {token: sorted(indices) for token, indices in token_index.items()}
ANIDB_TITLES_CACHE["match_cache"] = {}
for entry in entries:
yield entry
def get_cached_anidb_titles():
path = ensure_anidb_titles_dump()
mtime = path.stat().st_mtime if path.exists() else None
if ANIDB_TITLES_CACHE["entries"] is None or ANIDB_TITLES_CACHE["mtime"] != mtime:
for _entry in iter_anidb_titles():
pass
return ANIDB_TITLES_CACHE["entries"] or []
def score_anidb_title_match(query_variants, candidate):
normalized = candidate.get("normalized_title") or 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")
cache_key = tuple(sorted(query_variants))
cached = ANIDB_TITLES_CACHE["match_cache"].get(cache_key)
if cached is not None:
if cached:
return dict(cached)
raise RuntimeError("AniDB title lookup did not find a matching anime")
entries = get_cached_anidb_titles()
title_index = ANIDB_TITLES_CACHE.get("title_index") or {}
token_index = ANIDB_TITLES_CACHE.get("token_index") or {}
candidate_indices = set()
for variant in query_variants:
candidate_indices.update(title_index.get(variant, []))
for token in variant.split():
if len(token) >= 3:
candidate_indices.update(token_index.get(token, []))
candidates = [entries[index] for index in sorted(candidate_indices)] if candidate_indices else entries
def pick_best(pool, current_best=None):
best_match = current_best
for candidate in pool:
score = score_anidb_title_match(query_variants, candidate)
if score is None:
continue
if best_match is None or score > best_match["score"]:
best_match = {
"aid": candidate["aid"],
"title": candidate["title"],
"score": score,
}
return best_match
best = pick_best(candidates)
candidate_count = len(candidates)
if best is None and candidate_indices:
best = pick_best(entries)
candidate_count = len(entries)
if not best:
debug_log("thumbnail.source.no_match", source="AniDB", title=title, candidates=candidate_count)
ANIDB_TITLES_CACHE["match_cache"][cache_key] = None
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,
)
ANIDB_TITLES_CACHE["match_cache"][cache_key] = dict(best)
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, timeout=25):
query = "query ($showId: String!) { show(_id: $showId) { _id name availableEpisodesDetail } }"
data = graph_request(query, {"showId": show_id}, timeout=timeout)
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]
def format_episode_progress(available_count, expected_count):
available = parse_nonnegative_int(available_count)
expected = parse_nonnegative_int(expected_count)
if expected > 0:
return f"{available}/{expected}"
return str(available)
def mode_is_complete(available_count, expected_count, airing_status):
expected = parse_nonnegative_int(expected_count)
available = parse_nonnegative_int(available_count)
return expected > 0 and normalize_animeschedule_status(airing_status) == "Finished" and available >= expected
def watchlist_status_message(sub_count, dub_count, expected_count=0, airing_status=""):
parts = [
f"Sub: {format_episode_progress(sub_count, expected_count)} available",
f"Dub: {format_episode_progress(dub_count, expected_count)} available",
]
status = normalize_animeschedule_status(airing_status)
if status:
parts.append(f"AnimeSchedule: {status}")
if mode_is_complete(sub_count, expected_count, status):
parts.append("Sub ready")
if mode_is_complete(dub_count, expected_count, status):
parts.append("Dub ready")
return ". ".join(parts) + "."
def watchlist_download_library_name(item):
return sanitize_path_component(
item.get("auto_download_name") or item.get("title") or item.get("show_id") or "Anime",
sanitize_path_component(item.get("title") or item.get("show_id") or "Anime", "Anime"),
)
def watchlist_source_library_dir(item, config):
media_type = normalize_media_type(item.get("media_type"))
return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item)
def jellyfin_target_library_dir(item, config):
media_type = normalize_media_type(item.get("media_type"))
key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
root = str((config or {}).get(key) or "").strip()
if not root:
return None
return Path(root).expanduser() / watchlist_download_library_name(item)
def watchlist_item_ready_for_jellyfin(item):
if not isinstance(item, dict):
return False, "invalid_item"
if not bool(item.get("downloaded")):
return False, "not_downloaded"
if normalize_watchlist_category(item.get("category")) != "finished":
return False, "category"
if normalize_media_type(item.get("media_type")) == "movie":
return True, "ready"
preferred_mode = watchlist_preferred_completion_mode(item)
return watchlist_item_download_complete(item, mode=preferred_mode)
def watchlist_preferred_completion_mode(item, fallback_mode=""):
fallback = str(fallback_mode or "").strip().lower()
if fallback in MODE_CHOICES:
return fallback
configured = str((item or {}).get("auto_download_mode") or "").strip().lower()
if configured in MODE_CHOICES:
return configured
downloaded_dub = len((item or {}).get("downloaded_dub_episodes") or [])
downloaded_sub = len((item or {}).get("downloaded_sub_episodes") or [])
if downloaded_dub and not downloaded_sub:
return "dub"
if downloaded_sub and not downloaded_dub:
return "sub"
return "dub"
def watchlist_item_download_complete(item, mode=""):
if not isinstance(item, dict):
return False, "invalid_item"
normalized_mode = watchlist_preferred_completion_mode(item, fallback_mode=mode)
expected_count = int(item.get("expected_count") or 0)
if expected_count < 1:
return False, "expected_count"
if normalize_animeschedule_status(item.get("airing_status")) != "Finished":
return False, "airing_status"
available_count = int(item.get(f"{normalized_mode}_count") or 0)
if available_count < expected_count:
return False, "episodes_unavailable"
downloaded_count = len(item.get(f"downloaded_{normalized_mode}_episodes") or [])
if downloaded_count < expected_count:
return False, "episodes_missing"
return True, "ready"
def _move_tree_contents(source_dir, target_dir):
moved = []
for source_path in sorted(source_dir.rglob("*"), key=lambda path: (len(path.parts), str(path))):
if source_path.is_dir():
continue
relative = source_path.relative_to(source_dir)
destination = target_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
final_destination = app_support.unique_destination(destination)
shutil.move(str(source_path), str(final_destination))
moved.append(str(final_destination))
for path in sorted(source_dir.rglob("*"), key=lambda path: len(path.parts), reverse=True):
if path.is_dir():
try:
path.rmdir()
except OSError:
pass
try:
source_dir.rmdir()
except OSError:
pass
return moved
def jellyfin_media_files(target_dir):
video_suffixes = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".m4v", ".ts"}
return [
path
for path in target_dir.rglob("*")
if path.is_file() and path.suffix.lower() in video_suffixes
]
def jellyfin_target_looks_complete(item, target_dir):
files = jellyfin_media_files(target_dir)
if normalize_media_type(item.get("media_type")) == "movie":
return bool(files)
expected_count = int(item.get("expected_count") or 0)
if expected_count < 1:
return False
return len(files) >= expected_count
def move_watchlist_item_to_jellyfin(item, config):
source_dir = watchlist_source_library_dir(item, config)
target_dir = jellyfin_target_library_dir(item, config)
if target_dir is None:
return {"moved": False, "reason": "target_missing", "item": item}
try:
if source_dir.resolve() == target_dir.resolve():
return {"moved": False, "reason": "same_path", "item": item, "source": str(source_dir), "target": str(target_dir)}
except OSError:
pass
if not source_dir.exists():
if target_dir.exists():
reason = "already_moved" if jellyfin_target_looks_complete(item, target_dir) else "target_incomplete"
return {"moved": False, "reason": reason, "item": item, "source": str(source_dir), "target": str(target_dir)}
return {"moved": False, "reason": "source_missing", "item": item, "source": str(source_dir), "target": str(target_dir)}
target_dir.parent.mkdir(parents=True, exist_ok=True)
if not target_dir.exists():
shutil.move(str(source_dir), str(target_dir))
moved_files = [str(path) for path in target_dir.rglob("*") if path.is_file()]
else:
moved_files = _move_tree_contents(source_dir, target_dir)
return {
"moved": True,
"reason": "moved",
"item": item,
"source": str(source_dir),
"target": str(target_dir),
"moved_files": moved_files,
}
def notify_jellyfin_sync_result(item, result, config, automatic=False):
reason = str(result.get("reason") or "").strip()
if result.get("moved"):
event_name = "jellyfin_sync_success"
elif reason in {"already_moved", "same_path", "disabled", "not_downloaded", "category", "expected_count", "airing_status", "episodes_missing", "ready"}:
return {"sent": False, "reason": "skip"}
else:
event_name = "jellyfin_sync_failed"
payload = {
"show_id": item.get("show_id"),
"title": item.get("title"),
"media_type": normalize_media_type(item.get("media_type")),
"source": "automatic" if automatic else "manual",
"reason": reason or ("moved" if result.get("moved") else "unknown"),
"target": result.get("target"),
"source_path": result.get("source"),
"moved_files": result.get("moved_files") if isinstance(result.get("moved_files"), list) else [],
"error": result.get("error"),
}
send_discord_webhook_event(config or get_config_snapshot(), event_name, payload)
return {"sent": True, "event": event_name}
def episode_specs_for_values(values, available_episodes):
ordered = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
targets = {str(value).strip() for value in values or [] if str(value).strip()}
if not ordered or not targets:
return []
indices = [index for index, value in enumerate(ordered) if value in targets]
if not indices:
return []
ranges = []
start = indices[0]
end = indices[0]
for index in indices[1:]:
if index == end + 1:
end = index
continue
ranges.append((start, end))
start = end = index
ranges.append((start, end))
specs = []
for start, end in ranges:
specs.append(ordered[start] if start == end else f"{ordered[start]}-{ordered[end]}")
return specs
def encode_episode_values(values):
normalized = []
seen = set()
for value in values or []:
text = str(value or "").strip()
if text and text not in seen:
seen.add(text)
normalized.append(text)
return json.dumps(normalized, ensure_ascii=True)
def decode_episode_values(payload):
if not payload:
return []
try:
raw = json.loads(payload)
except (TypeError, ValueError, json.JSONDecodeError):
return []
normalized = []
seen = set()
for value in raw if isinstance(raw, list) else []:
text = str(value or "").strip()
if text and text not in seen:
seen.add(text)
normalized.append(text)
return normalized
def expand_episode_spec_against_available(episode_spec, available_episodes):
specs = [part for part in re.split(r"\s+", str(episode_spec or "").strip()) if part]
values = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
if not specs or not values:
return []
index_map = {value: index for index, value in enumerate(values)}
expanded = []
seen = set()
for spec in specs:
if "-" in spec:
start, end = [piece.strip() for piece in spec.split("-", 1)]
if start in index_map and end in index_map:
start_index = index_map[start]
end_index = index_map[end]
if start_index > end_index:
start_index, end_index = end_index, start_index
for value in values[start_index : end_index + 1]:
if value not in seen:
seen.add(value)
expanded.append(value)
continue
if spec in index_map and spec not in seen:
seen.add(spec)
expanded.append(spec)
return expanded
class WatchlistStore:
def __init__(self, config_getter=None, start_worker=True):
self.lock = threading.RLock()
self.config_getter = config_getter or (lambda: dict(DEFAULT_CONFIG))
self.auto_download_fn = None
self.jellyfin_sync_fn = None
self.thumbnail_events = {}
self.refresh_pending = []
self.refresh_pending_set = set()
self.refresh_inflight_set = set()
self.refresh_wakeup = threading.Event()
self.refresh_stop_event = threading.Event()
self.refresh_worker = None
self.worker_enabled = bool(start_worker)
self._init_db()
self._migrate_legacy_json()
self._restore_queued_refreshes()
if start_worker:
self.ensure_worker()
def ensure_worker(self):
if not self.worker_enabled:
return
with self.lock:
if self.refresh_worker is not None and self.refresh_worker.is_alive():
return
self.refresh_stop_event.clear()
self.refresh_worker = threading.Thread(target=self._refresh_loop, daemon=True)
self.refresh_worker.start()
def shutdown(self, wait=False):
self.refresh_stop_event.set()
self.refresh_wakeup.set()
worker = self.refresh_worker
if wait and worker is not None and worker.is_alive():
worker.join(timeout=WORKER_SHUTDOWN_TIMEOUT_SECONDS)
return worker is None or not worker.is_alive()
def _refresh_request_timeout(self):
return WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS
def _next_pending_refresh(self):
with self.lock:
if not self.refresh_pending:
return None, None
show_id, source = self.refresh_pending.pop(0)
self.refresh_pending_set.discard(show_id)
self.refresh_inflight_set.add(show_id)
return show_id, source
def _enqueue_refresh_locked(self, show_id, source="manual"):
normalized_show_id = str(show_id or "").strip()
if (
not normalized_show_id
or normalized_show_id in self.refresh_pending_set
or normalized_show_id in self.refresh_inflight_set
):
return False
self.refresh_pending.append((normalized_show_id, str(source or "manual").strip().lower() or "manual"))
self.refresh_pending_set.add(normalized_show_id)
return True
def _drop_refresh_locked(self, show_id):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
return False
removed = False
if normalized_show_id in self.refresh_pending_set:
self.refresh_pending = [
value for value in self.refresh_pending if str((value[0] if isinstance(value, tuple) else value) or "").strip() != normalized_show_id
]
self.refresh_pending_set.discard(normalized_show_id)
removed = True
if normalized_show_id in self.refresh_inflight_set:
self.refresh_inflight_set.discard(normalized_show_id)
removed = True
return removed
def _restore_queued_refreshes(self):
with self.lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT show_id
FROM watchlist
WHERE status = 'queued'
ORDER BY updated_at ASC, created_at ASC, title COLLATE NOCASE ASC
"""
).fetchall()
for row in rows:
self._enqueue_refresh_locked(str(row["show_id"]).strip(), source="manual")
if self.refresh_pending:
self.refresh_wakeup.set()
def _refresh_loop(self):
while not self.refresh_stop_event.is_set():
show_id, refresh_source = self._next_pending_refresh()
if not show_id:
self.refresh_wakeup.wait(2)
self.refresh_wakeup.clear()
continue
try:
self.refresh(show_id, refresh_source=refresh_source or "manual")
except Exception as exc:
debug_log("watchlist.refresh_worker.error", show_id=show_id, error=exc)
finally:
with self.lock:
self.refresh_inflight_set.discard(show_id)
def schedule_refresh(self, show_id, source="manual"):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
raise ValueError("Missing show_id.")
self.ensure_worker()
with self.lock:
self._enqueue_refresh_locked(normalized_show_id, source=source)
self.refresh_wakeup.set()
return self.get(normalized_show_id)
def _auto_download_defaults(self, title):
config = self.config_getter() or {}
return {
"auto_download_mode": str(config.get("auto_download_mode") or "dub").strip().lower() or "dub",
"auto_download_quality": str(config.get("auto_download_quality") or "best").strip().lower() or "best",
"auto_download_name": sanitize_path_component(title, "Anime"),
"auto_download_series": "1",
"auto_download_offset": None,
"media_type": "tv",
}
def _connect(self):
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
return conn
def _exists_conn(self, conn, show_id):
row = conn.execute(
"SELECT 1 FROM watchlist WHERE show_id = ? LIMIT 1",
(str(show_id or "").strip(),),
).fetchone()
return bool(row)
def has_show(self, show_id):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
return False
with self.lock, self._connect() as conn:
return self._exists_conn(conn, normalized_show_id)
def _find_download_title_match_conn(self, conn, title, exclude_show_id=""):
normalized_title_key = normalize_identity_title_key(title)
excluded_show_id = str(exclude_show_id or "").strip()
if not normalized_title_key:
return None
rows = conn.execute(
"""
SELECT *
FROM watchlist
ORDER BY updated_at DESC, created_at DESC, title COLLATE NOCASE ASC
"""
).fetchall()
matches = []
for row in rows:
row_show_id = str(row["show_id"] or "").strip()
if not row_show_id or row_show_id == excluded_show_id:
continue
if normalize_identity_title_key(row["title"]) == normalized_title_key:
matches.append(row)
if len(matches) > 1:
return None
return matches[0] if matches else None
def _init_db(self):
STATE_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,
category TEXT NOT NULL DEFAULT 'watching',
downloaded INTEGER NOT NULL DEFAULT 0,
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,
expected_count INTEGER NOT NULL DEFAULT 0,
airing_status TEXT,
sub_latest_episode TEXT,
dub_latest_episode TEXT,
animeschedule_route TEXT,
animeschedule_title TEXT,
anilist_id INTEGER,
anilist_title TEXT,
anilist_site_url TEXT,
alternative_titles_json TEXT,
anidb_aid TEXT,
anidb_title TEXT,
media_type TEXT NOT NULL DEFAULT 'tv',
auto_download_mode TEXT,
auto_download_quality TEXT,
auto_download_name TEXT,
auto_download_series TEXT,
auto_download_offset TEXT,
downloaded_sub_episodes_json TEXT,
downloaded_dub_episodes_json TEXT,
thumbnail_path TEXT,
thumbnail_checked_at TEXT
)
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()}
if "category" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN category TEXT NOT NULL DEFAULT 'watching'")
if "downloaded" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded INTEGER NOT NULL DEFAULT 0")
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 "expected_count" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN expected_count INTEGER NOT NULL DEFAULT 0")
if "airing_status" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN airing_status 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 "media_type" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN media_type TEXT NOT NULL DEFAULT 'tv'")
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")
if "anilist_id" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_id INTEGER")
if "anilist_title" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_title TEXT")
if "anilist_site_url" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_site_url TEXT")
if "alternative_titles_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN alternative_titles_json TEXT")
if "auto_download_mode" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_mode TEXT")
if "auto_download_quality" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_quality TEXT")
if "auto_download_name" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_name TEXT")
if "auto_download_series" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_series TEXT")
if "auto_download_offset" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_offset TEXT")
if "downloaded_sub_episodes_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_sub_episodes_json TEXT")
if "downloaded_dub_episodes_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_dub_episodes_json TEXT")
conn.execute(
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
)
conn.execute(
"UPDATE watchlist SET media_type = 'tv' WHERE media_type IS NULL OR TRIM(media_type) = ''"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_title ON watchlist(title)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_category ON watchlist(category)")
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)
defaults = self._auto_download_defaults(item.get("title"))
item["category"] = normalize_watchlist_category(item.get("category"))
item["category_label"] = watchlist_category_label(item["category"])
item["media_type"] = normalize_media_type(item.get("media_type"))
item["media_type_label"] = media_type_label(item["media_type"])
item["downloaded"] = bool(int(item.get("downloaded") or 0))
item["sub_count"] = int(item.get("sub_count") or 0)
item["dub_count"] = int(item.get("dub_count") or 0)
item["expected_count"] = int(item.get("expected_count") or 0)
item["airing_status"] = normalize_animeschedule_status(item.get("airing_status"))
item["total_count"] = item["sub_count"] + item["dub_count"]
item["sub_progress"] = format_episode_progress(item["sub_count"], item["expected_count"])
item["dub_progress"] = format_episode_progress(item["dub_count"], item["expected_count"])
item["sub_complete"] = mode_is_complete(item["sub_count"], item["expected_count"], item["airing_status"])
item["dub_complete"] = mode_is_complete(item["dub_count"], item["expected_count"], item["airing_status"])
item["finished_badge"] = (
"downloaded" if item["category"] == "finished" and item["downloaded"] else "manual" if item["category"] == "finished" else ""
)
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"))
item["alternative_titles"] = decode_alternative_titles(item.get("alternative_titles_json"))
item["has_alternative_titles"] = bool(item["alternative_titles"])
mode = str(item.get("auto_download_mode") or defaults["auto_download_mode"]).strip().lower()
quality = str(item.get("auto_download_quality") or defaults["auto_download_quality"]).strip().lower()
item["auto_download_mode"] = mode if mode in MODE_CHOICES else defaults["auto_download_mode"]
item["auto_download_quality"] = quality if quality in QUALITY_CHOICES else defaults["auto_download_quality"]
item["auto_download_name"] = sanitize_path_component(item.get("auto_download_name") or defaults["auto_download_name"], defaults["auto_download_name"])
item["auto_download_series"] = normalize_season(item.get("auto_download_series") or defaults["auto_download_series"])
item["auto_download_offset"] = normalize_episode_offset(item.get("auto_download_offset"))
item["downloaded_sub_episodes"] = decode_episode_values(item.get("downloaded_sub_episodes_json"))
item["downloaded_dub_episodes"] = decode_episode_values(item.get("downloaded_dub_episodes_json"))
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, category, downloaded, status, status_message, created_at, updated_at,
last_checked, sub_count, dub_count, expected_count, airing_status,
sub_latest_episode, dub_latest_episode,
animeschedule_route, animeschedule_title,
anilist_id, anilist_title, anilist_site_url, alternative_titles_json,
anidb_aid, anidb_title, media_type,
auto_download_mode, auto_download_quality, auto_download_name, auto_download_series, auto_download_offset,
downloaded_sub_episodes_json, downloaded_dub_episodes_json,
thumbnail_path, thumbnail_checked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(show_id) DO UPDATE SET
title = excluded.title,
category = excluded.category,
downloaded = excluded.downloaded,
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,
expected_count = excluded.expected_count,
airing_status = excluded.airing_status,
sub_latest_episode = excluded.sub_latest_episode,
dub_latest_episode = excluded.dub_latest_episode,
animeschedule_route = excluded.animeschedule_route,
animeschedule_title = excluded.animeschedule_title,
anilist_id = excluded.anilist_id,
anilist_title = excluded.anilist_title,
anilist_site_url = excluded.anilist_site_url,
alternative_titles_json = excluded.alternative_titles_json,
anidb_aid = excluded.anidb_aid,
anidb_title = excluded.anidb_title,
media_type = excluded.media_type,
auto_download_mode = excluded.auto_download_mode,
auto_download_quality = excluded.auto_download_quality,
auto_download_name = excluded.auto_download_name,
auto_download_series = excluded.auto_download_series,
auto_download_offset = excluded.auto_download_offset,
downloaded_sub_episodes_json = excluded.downloaded_sub_episodes_json,
downloaded_dub_episodes_json = excluded.downloaded_dub_episodes_json,
thumbnail_path = excluded.thumbnail_path,
thumbnail_checked_at = excluded.thumbnail_checked_at
""",
(
item["show_id"],
item["title"],
normalize_watchlist_category(item.get("category")),
1 if item.get("downloaded") else 0,
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),
int(item.get("expected_count") or 0),
item.get("airing_status"),
item.get("sub_latest_episode"),
item.get("dub_latest_episode"),
item.get("animeschedule_route"),
item.get("animeschedule_title"),
item.get("anilist_id"),
item.get("anilist_title"),
item.get("anilist_site_url"),
item.get("alternative_titles_json"),
item.get("anidb_aid"),
item.get("anidb_title"),
normalize_media_type(item.get("media_type")),
item.get("auto_download_mode"),
item.get("auto_download_quality"),
item.get("auto_download_name"),
item.get("auto_download_series"),
normalize_episode_offset(item.get("auto_download_offset")),
item.get("downloaded_sub_episodes_json"),
item.get("downloaded_dub_episodes_json"),
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",
"category": "watching",
"downloaded": False,
"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,
"expected_count": 0,
"airing_status": None,
"sub_latest_episode": None,
"dub_latest_episode": None,
"animeschedule_route": None,
"animeschedule_title": None,
"anilist_id": None,
"anilist_title": None,
"anilist_site_url": None,
"alternative_titles_json": None,
"anidb_aid": None,
"anidb_title": None,
"media_type": "tv",
"auto_download_mode": None,
"auto_download_quality": None,
"auto_download_name": None,
"auto_download_series": None,
"auto_download_offset": None,
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": 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, category=None):
page = max(1, int(page or 1))
per_page = min(60, max(1, int(per_page or 30)))
active_category = normalize_watchlist_category(category) if category else None
with self.lock, self._connect() as conn:
overall_total = conn.execute("SELECT COUNT(*) AS count FROM watchlist").fetchone()["count"]
if active_category:
total = conn.execute(
"SELECT COUNT(*) AS count FROM watchlist WHERE category = ?",
(active_category,),
).fetchone()["count"]
else:
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()
category_rows = conn.execute(
"SELECT category, COUNT(*) AS count FROM watchlist GROUP BY category"
).fetchall()
queued_total = conn.execute("SELECT COUNT(*) AS count FROM watchlist WHERE status = 'queued'").fetchone()["count"]
pages = max(1, (total + per_page - 1) // per_page)
page = min(page, pages)
offset = (page - 1) * per_page
if active_category:
rows = conn.execute(
"SELECT * FROM watchlist WHERE category = ? ORDER BY title COLLATE NOCASE ASC LIMIT ? OFFSET ?",
(active_category, per_page, offset),
).fetchall()
else:
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]
category_counts = {key: 0 for key in WATCHLIST_CATEGORY_CHOICES}
for row in category_rows:
category_counts[normalize_watchlist_category(row["category"])] = int(row["count"] or 0)
return {
"items": items,
"total": total,
"page": page,
"pages": pages,
"per_page": per_page,
"active_category": active_category,
"active_category_label": watchlist_category_label(active_category) if active_category else "",
"categories": category_counts,
"queued_count": int(queued_total or 0),
"summary": {
"shows": int(overall_total or 0),
"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 all_show_ids(self, categories=None):
normalized_categories = []
for category in categories or []:
normalized = normalize_watchlist_category(category)
if normalized not in normalized_categories:
normalized_categories.append(normalized)
with self.lock, self._connect() as conn:
if normalized_categories:
placeholders = ", ".join("?" for _ in normalized_categories)
rows = conn.execute(
f"SELECT show_id FROM watchlist WHERE category IN ({placeholders}) ORDER BY title COLLATE NOCASE ASC",
tuple(normalized_categories),
).fetchall()
else:
rows = conn.execute("SELECT show_id FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
return [str(row["show_id"]).strip() for row in rows if str(row["show_id"]).strip()]
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 = {
**self._auto_download_defaults(title),
"show_id": show_id,
"title": title,
"category": normalize_watchlist_category(payload.get("category")),
"downloaded": False,
"status": "queued",
"status_message": "Queued initial watchlist refresh.",
"created_at": now,
"updated_at": now,
"last_checked": None,
"sub_count": 0,
"dub_count": 0,
"expected_count": 0,
"airing_status": None,
"sub_latest_episode": None,
"dub_latest_episode": None,
"animeschedule_route": None,
"animeschedule_title": None,
"anilist_id": None,
"anilist_title": None,
"anilist_site_url": None,
"alternative_titles_json": None,
"anidb_aid": None,
"anidb_title": None,
"media_type": normalize_media_type(payload.get("media_type")),
"thumbnail_path": None,
"thumbnail_checked_at": None,
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": None,
}
item["auto_download_series"] = normalize_season(payload.get("auto_download_series") or item.get("auto_download_series") or "1")
item["auto_download_offset"] = normalize_episode_offset(payload.get("auto_download_offset"))
self._upsert_conn(conn, item)
queued = self.schedule_refresh(show_id, source="initial")
return {"message": f"Added {queued['title']} to the watchlist. Refresh queued.", "item": queued, "created": True}
def refresh(self, show_id, include_thumbnail=True, refresh_source="manual"):
existing = self.get(show_id)
normalized_show_id = existing["show_id"]
previous_sub_count = int(existing.get("sub_count") or 0)
previous_dub_count = int(existing.get("dub_count") or 0)
previous_last_checked = existing.get("last_checked")
now = now_iso()
request_timeout = self._refresh_request_timeout()
try:
snapshot = fetch_show_episode_snapshot(normalized_show_id, timeout=request_timeout)
if self.refresh_stop_event.is_set():
return None
if not self.has_show(normalized_show_id):
return None
sub_episodes = snapshot["episodes"]["sub"]
dub_episodes = snapshot["episodes"]["dub"]
schedule = None
try:
schedule = resolve_animeschedule_anime(
snapshot["title"] or existing["title"],
route=existing.get("animeschedule_route"),
timeout=request_timeout,
)
except Exception:
schedule = None
anidb_match = None
try:
if existing.get("anidb_aid"):
anidb_match = {
"aid": str(existing.get("anidb_aid")).strip(),
"title": str(existing.get("anidb_title") or snapshot["title"] or existing["title"]).strip(),
}
else:
anidb_match = resolve_anidb_aid(snapshot["title"] or existing["title"])
except Exception:
anidb_match = None
if self.refresh_stop_event.is_set():
return None
if not self.has_show(normalized_show_id):
return None
expected_count = parse_nonnegative_int((schedule or {}).get("episodes")) or int(existing.get("expected_count") or 0)
airing_status = normalize_animeschedule_status((schedule or {}).get("status") or existing.get("airing_status"))
alternative_titles = extract_anidb_alternative_titles(
(anidb_match or {}).get("aid"),
primary_title=snapshot["title"] or existing["title"],
)
payload = (
snapshot["title"] or existing["title"],
"updated",
watchlist_status_message(
len(sub_episodes),
len(dub_episodes),
expected_count=expected_count,
airing_status=airing_status,
),
now,
now,
len(sub_episodes),
len(dub_episodes),
expected_count,
airing_status,
sub_episodes[-1] if sub_episodes else None,
dub_episodes[-1] if dub_episodes else None,
(schedule or {}).get("route") or existing.get("animeschedule_route"),
(schedule or {}).get("title") or existing.get("animeschedule_title"),
existing.get("anilist_id"),
existing.get("anilist_title"),
existing.get("anilist_site_url"),
encode_alternative_titles(alternative_titles) if anidb_match is not None else existing.get("alternative_titles_json"),
(anidb_match or {}).get("aid") or existing.get("anidb_aid"),
(anidb_match or {}).get("title") or existing.get("anidb_title"),
normalized_show_id,
)
except Exception as exc:
if self.refresh_stop_event.is_set():
return None
if not self.has_show(normalized_show_id):
return None
payload = (
existing["title"],
"error",
f"Could not refresh episodes: {exc}",
now,
now,
existing["sub_count"],
existing["dub_count"],
existing.get("expected_count"),
existing.get("airing_status"),
existing.get("sub_latest_episode"),
existing.get("dub_latest_episode"),
existing.get("animeschedule_route"),
existing.get("animeschedule_title"),
existing.get("anilist_id"),
existing.get("anilist_title"),
existing.get("anilist_site_url"),
existing.get("alternative_titles_json"),
existing.get("anidb_aid"),
existing.get("anidb_title"),
normalized_show_id,
)
with self.lock, self._connect() as conn:
if self.refresh_stop_event.is_set():
return None
cursor = conn.execute(
"""
UPDATE watchlist
SET
title = ?,
status = ?,
status_message = ?,
updated_at = ?,
last_checked = ?,
sub_count = ?,
dub_count = ?,
expected_count = ?,
airing_status = ?,
sub_latest_episode = ?,
dub_latest_episode = ?,
animeschedule_route = ?,
animeschedule_title = ?,
anilist_id = ?,
anilist_title = ?,
anilist_site_url = ?,
alternative_titles_json = ?,
anidb_aid = ?,
anidb_title = ?
WHERE show_id = ?
""",
payload,
)
if cursor.rowcount < 1:
return None
if self.refresh_stop_event.is_set():
return None
if include_thumbnail and self.has_show(normalized_show_id):
try:
self.ensure_thumbnail(normalized_show_id)
except Exception:
pass
item = self.get(normalized_show_id)
item["previous_sub_count"] = previous_sub_count
item["previous_dub_count"] = previous_dub_count
item["previous_last_checked"] = previous_last_checked
item["sub_delta"] = max(0, int(item.get("sub_count") or 0) - previous_sub_count)
item["dub_delta"] = max(0, int(item.get("dub_count") or 0) - previous_dub_count)
item["had_new_episodes"] = bool(item["sub_delta"] or item["dub_delta"])
item["sub_episode_values"] = sub_episodes if 'sub_episodes' in locals() else []
item["dub_episode_values"] = dub_episodes if 'dub_episodes' in locals() else []
if self.auto_download_fn is not None:
try:
item["auto_download_result"] = self.auto_download_fn(item, refresh_source=refresh_source)
except Exception as exc:
debug_log("watchlist.auto_download.error", show_id=normalized_show_id, source=refresh_source, error=exc)
if self.jellyfin_sync_fn is not None:
try:
item["jellyfin_sync_result"] = self.jellyfin_sync_fn(item, automatic=True)
except Exception as exc:
debug_log("watchlist.jellyfin_sync.error", show_id=normalized_show_id, source=refresh_source, error=exc)
return item
def refresh_all(self):
refreshed = []
for show_id in self.all_show_ids(categories=("watching", "planned")):
item = self.refresh(show_id, include_thumbnail=False)
if item is not None:
refreshed.append(item)
return {"count": len(refreshed), "items": refreshed, "message": f"Refreshed {len(refreshed)} watchlist entries."}
def update_category(self, show_id, category):
existing = self.get(show_id)
now = now_iso()
with self.lock, self._connect() as conn:
conn.execute(
"UPDATE watchlist SET category = ?, updated_at = ? WHERE show_id = ?",
(normalize_watchlist_category(category), now, existing["show_id"]),
)
return self.get(show_id)
def update_media_type(self, show_id, media_type):
existing = self.get(show_id)
normalized_media_type = normalize_media_type(media_type)
with self.lock, self._connect() as conn:
conn.execute(
"UPDATE watchlist SET media_type = ?, updated_at = ? WHERE show_id = ?",
(normalized_media_type, now_iso(), existing["show_id"]),
)
return self.get(show_id)
def queue_refresh(self, show_id, status_message="Queued watchlist refresh.", source="manual"):
existing = self.get(show_id)
now = now_iso()
with self.lock, self._connect() as conn:
conn.execute(
"UPDATE watchlist SET status = 'queued', status_message = ?, updated_at = ? WHERE show_id = ?",
(str(status_message or "Queued watchlist refresh.").strip(), now, existing["show_id"]),
)
return self.schedule_refresh(existing["show_id"], source=source)
def mark_downloaded(self, show_id, title="", mode="", episodes=""):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
raise ValueError("Missing show_id for watchlist download sync.")
normalized_title = str(title or "").strip()
normalized_mode = str(mode or "").strip().lower()
episode_spec = str(episodes or "").strip()
now = now_iso()
downloaded_episode_values = []
if normalized_mode in MODE_CHOICES and episode_spec:
try:
available_episodes = episode_list(normalized_show_id, normalized_mode)
except Exception:
available_episodes = []
downloaded_episode_values = expand_episode_spec_against_available(episode_spec, available_episodes)
with self.lock, self._connect() as conn:
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (normalized_show_id,)).fetchone()
if row:
existing_item = self._row_to_item(row)
sub_payload = existing_item.get("downloaded_sub_episodes_json")
dub_payload = existing_item.get("downloaded_dub_episodes_json")
if normalized_mode == "sub" and downloaded_episode_values:
sub_payload = encode_episode_values(existing_item.get("downloaded_sub_episodes", []) + downloaded_episode_values)
elif normalized_mode == "dub" and downloaded_episode_values:
dub_payload = encode_episode_values(existing_item.get("downloaded_dub_episodes", []) + downloaded_episode_values)
completed_item = dict(existing_item)
completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload)
completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload)
is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode)
target_category = "finished" if is_complete else existing_item["category"]
conn.execute(
"""
UPDATE watchlist
SET category = ?, downloaded = 1, status = 'queued', status_message = ?, updated_at = ?,
downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ?
WHERE show_id = ?
""",
(target_category, "Queued refresh after successful download.", now, sub_payload, dub_payload, normalized_show_id),
)
else:
matched_row = self._find_download_title_match_conn(conn, normalized_title, exclude_show_id=normalized_show_id)
if matched_row is not None:
matched_item = self._row_to_item(matched_row)
previous_show_id = str(matched_row["show_id"]).strip()
resolved_title = normalized_title or str(matched_row["title"] or "").strip() or "Unknown Anime"
sub_payload = matched_item.get("downloaded_sub_episodes_json")
dub_payload = matched_item.get("downloaded_dub_episodes_json")
if normalized_mode == "sub" and downloaded_episode_values:
sub_payload = encode_episode_values(matched_item.get("downloaded_sub_episodes", []) + downloaded_episode_values)
elif normalized_mode == "dub" and downloaded_episode_values:
dub_payload = encode_episode_values(matched_item.get("downloaded_dub_episodes", []) + downloaded_episode_values)
completed_item = dict(matched_item)
completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload)
completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload)
is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode)
target_category = "finished" if is_complete else matched_item["category"]
conn.execute(
"""
UPDATE watchlist
SET
show_id = ?,
title = ?,
category = ?,
downloaded = 1,
status = 'queued',
status_message = ?,
updated_at = ?,
downloaded_sub_episodes_json = ?,
downloaded_dub_episodes_json = ?
WHERE show_id = ?
""",
(
normalized_show_id,
resolved_title,
target_category,
"Queued refresh after successful download.",
now,
sub_payload,
dub_payload,
previous_show_id,
),
)
if previous_show_id != normalized_show_id:
event = self.thumbnail_events.pop(previous_show_id, None)
if event is not None:
self.thumbnail_events[normalized_show_id] = event
self._drop_refresh_locked(previous_show_id)
else:
existing = {
**self._auto_download_defaults(normalized_title or "Unknown Anime"),
"show_id": normalized_show_id,
"title": normalized_title or "Unknown Anime",
"category": "finished",
"downloaded": True,
"status": "queued",
"status_message": "Queued refresh after successful download.",
"created_at": now,
"updated_at": now,
"last_checked": None,
"sub_count": 0,
"dub_count": 0,
"expected_count": 0,
"airing_status": None,
"sub_latest_episode": None,
"dub_latest_episode": None,
"animeschedule_route": None,
"animeschedule_title": None,
"anilist_id": None,
"anilist_title": None,
"anilist_site_url": None,
"alternative_titles_json": None,
"anidb_aid": None,
"anidb_title": None,
"media_type": "tv",
"auto_download_series": None,
"downloaded_sub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "sub" else None,
"downloaded_dub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "dub" else None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
self._upsert_conn(conn, existing)
self.schedule_refresh(normalized_show_id, source="sync")
return self.get(normalized_show_id)
def update_auto_download_settings(self, show_id, mode=None, quality=None, name=None, series=None, episode_offset=None):
existing = self.get(show_id)
defaults = self._auto_download_defaults(existing["title"])
normalized_mode = str(mode or defaults["auto_download_mode"]).strip().lower()
normalized_quality = str(quality or defaults["auto_download_quality"]).strip().lower()
if normalized_mode not in MODE_CHOICES:
raise ValueError("Auto-download mode must be sub or dub.")
if normalized_quality not in QUALITY_CHOICES:
raise ValueError("Unknown auto-download quality.")
normalized_name = sanitize_path_component(name or defaults["auto_download_name"], defaults["auto_download_name"])
normalized_series = normalize_season(series or existing.get("auto_download_series") or defaults["auto_download_series"])
normalized_offset = (
normalize_episode_offset(existing.get("auto_download_offset"))
if episode_offset is None
else normalize_episode_offset(episode_offset)
)
with self.lock, self._connect() as conn:
conn.execute(
"""
UPDATE watchlist
SET auto_download_mode = ?, auto_download_quality = ?, auto_download_name = ?, auto_download_series = ?, auto_download_offset = ?, updated_at = ?
WHERE show_id = ?
""",
(normalized_mode, normalized_quality, normalized_name, normalized_series, normalized_offset, now_iso(), existing["show_id"]),
)
return self.get(show_id)
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.")
event = self.thumbnail_events.pop(str(show_id).strip(), None)
if event is not None:
event.set()
self._drop_refresh_locked(show_id)
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
owns_event = False
else:
wait_event = threading.Event()
self.thumbnail_events[show_id] = wait_event
owns_event = True
if event:
wait_event.wait(timeout=20)
if not self.has_show(show_id):
debug_log("thumbnail.ensure.waited_removed", show_id=show_id)
return None
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:
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 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 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
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"],
),
)
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)
finally:
if owns_event:
wait_event.set()
with self.lock:
inflight = self.thumbnail_events.get(show_id)
if inflight is wait_event:
self.thumbnail_events.pop(show_id, None)
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))]:
try:
item = self.get(show_id)
except KeyError:
continue
if not force and item.get("thumbnail_ready"):
updated.append(item)
continue
try:
self.ensure_thumbnail(show_id, force=force)
except Exception:
pass
try:
updated.append(self.get(show_id))
except KeyError:
continue
return {"items": updated}
def add_to_watchlist(payload):
ensure_runtime()
return WATCHLIST.add(payload)
def get_watchlist(page=1, per_page=30, category=None):
ensure_runtime()
return WATCHLIST.list(page=page, per_page=per_page, category=category)
def update_watchlist_status(show_id, _mode=None):
ensure_runtime()
item = WATCHLIST.queue_refresh(show_id, source="manual")
return {"message": f"Queued refresh for {item['title']}.", "item": item}
def download_watchlist_item(show_id, mode):
runtime = ensure_runtime()
item = runtime["watchlist"].get(show_id)
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub")
query = str(item.get("title") or item.get("show_id") or "").strip()
if not query:
raise ValueError("Watchlist item is missing a searchable title.")
results = search_anime(query, normalized_mode)
match = next((candidate for candidate in results if str(candidate.get("id") or "").strip() == item["show_id"]), None)
if match is None:
raise ValueError(f"Could not match {item['title']} in {normalized_mode} search results.")
episodes = episode_list(item["show_id"], normalized_mode)
if not episodes:
raise ValueError(f"No {normalized_mode} episodes are currently available for {item['title']}.")
episode_spec = episodes[0] if len(episodes) == 1 else f"{episodes[0]}-{episodes[-1]}"
payload = {
"show_id": item["show_id"],
"query": match.get("query") or query,
"title": match.get("title") or item["title"],
"anime_name": item.get("auto_download_name") or item["title"],
"media_type": item.get("media_type") or "tv",
"season": item.get("auto_download_series") or "1",
"episode_offset": item.get("auto_download_offset"),
"mode": normalized_mode,
"quality": runtime["config"]["quality"],
"episodes": episode_spec,
"download_dir": runtime["config"]["download_dir"],
}
job = runtime["download_queue"].add(payload)
return {
"message": f"Queued {item['title']} for {normalized_mode} download.",
"job": job,
"item": item,
}
def queue_watchlist_auto_download(item, refresh_source="manual"):
source = str(refresh_source or "manual").strip().lower() or "manual"
if source not in {"manual", "scheduled"}:
return {"queued": False, "reason": f"source:{source}"}
runtime = ensure_runtime()
config = runtime["config"]
if not bool(config.get("auto_download_enabled")):
return {"queued": False, "reason": "config_disabled"}
if normalize_watchlist_category(item.get("category")) != "watching":
return {"queued": False, "reason": "category"}
if not item.get("previous_last_checked"):
return {"queued": False, "reason": "initial_refresh"}
mode = str(item.get("auto_download_mode") or config.get("auto_download_mode") or "dub").strip().lower()
quality = str(item.get("auto_download_quality") or config.get("auto_download_quality") or "best").strip().lower()
if mode not in MODE_CHOICES:
mode = "dub"
if quality not in QUALITY_CHOICES:
quality = "best"
available_episodes = list(item.get(f"{mode}_episode_values") or [])
downloaded_episodes = set(item.get("downloaded_dub_episodes", []) if mode == "dub" else item.get("downloaded_sub_episodes", []))
if not available_episodes:
return {"queued": False, "reason": "mode_has_no_available_episodes"}
covered = runtime["download_queue"].covered_episodes(item.get("show_id"), mode, available_episodes)
missing = [episode for episode in available_episodes if episode not in downloaded_episodes and episode not in covered]
specs = episode_specs_for_values(missing, available_episodes)
if not specs:
return {"queued": False, "reason": "nothing_missing"}
query = str(item.get("title") or item.get("show_id") or "").strip()
if not query:
return {"queued": False, "reason": "missing_title"}
results = search_anime(query, mode)
match = next((candidate for candidate in results if str(candidate.get("id") or "").strip() == str(item.get("show_id") or "").strip()), None)
if match is None:
return {"queued": False, "reason": f"search_match_missing:{mode}"}
anime_name = sanitize_path_component(
item.get("auto_download_name") or item.get("title") or match.get("title") or "Anime",
sanitize_path_component(item.get("title") or match.get("title") or "Anime", "Anime"),
)
series = normalize_season(item.get("auto_download_series") or "1")
episode_offset = normalize_episode_offset(item.get("auto_download_offset"))
jobs = []
for episode_spec in specs:
job = runtime["download_queue"].add(
{
"show_id": item["show_id"],
"query": match.get("query") or query,
"title": match.get("title") or item["title"],
"anime_name": anime_name,
"media_type": item.get("media_type") or "tv",
"season": series,
"episode_offset": episode_offset,
"mode": mode,
"quality": quality,
"episodes": episode_spec,
"download_dir": config["download_dir"],
}
)
jobs.append(job)
return {"queued": True, "mode": mode, "quality": quality, "episodes": specs, "jobs": jobs}
def trigger_jellyfin_sync_for_item(item, automatic=False, config=None):
active_config = normalize_config(config or get_config_snapshot())
if automatic and not bool(active_config.get("jellyfin_sync_enabled")):
return {"moved": False, "reason": "disabled", "item": item}
ready, reason = watchlist_item_ready_for_jellyfin(item)
if not ready:
return {"moved": False, "reason": reason, "item": item}
media_type = normalize_media_type(item.get("media_type"))
target_key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
if not str(active_config.get(target_key) or "").strip():
result = {"moved": False, "reason": "target_missing", "item": item}
try:
notify_jellyfin_sync_result(item, result, active_config, automatic=automatic)
except Exception as exc:
debug_log("discord_webhook.jellyfin_sync_failed", show_id=item.get("show_id"), reason=result.get("reason"), error=exc)
return result
try:
result = move_watchlist_item_to_jellyfin(item, active_config)
except Exception as exc:
result = {
"moved": False,
"reason": "error",
"item": item,
"source": str(watchlist_source_library_dir(item, active_config)),
"target": str(jellyfin_target_library_dir(item, active_config)),
"error": str(exc),
}
try:
notify_jellyfin_sync_result(item, result, active_config, automatic=automatic)
except Exception as exc:
debug_log("discord_webhook.jellyfin_sync_failed", show_id=item.get("show_id"), reason=result.get("reason"), error=exc)
if result.get("moved"):
debug_log(
"jellyfin.sync.moved",
show_id=item.get("show_id"),
media_type=media_type,
source=result.get("source"),
target=result.get("target"),
automatic=automatic,
)
return result
def run_jellyfin_sync(config=None):
runtime = ensure_runtime()
active_config = normalize_config(config or runtime["config"] or {})
items = []
moved = 0
for show_id in runtime["watchlist"].all_show_ids():
try:
item = runtime["watchlist"].get(show_id)
except KeyError:
continue
result = trigger_jellyfin_sync_for_item(item, automatic=False, config=active_config)
items.append(
{
"show_id": item.get("show_id"),
"title": item.get("title"),
"media_type": item.get("media_type"),
"moved": bool(result.get("moved")),
"reason": result.get("reason"),
"target": result.get("target"),
}
)
if result.get("moved"):
moved += 1
return {
"message": f"Moved {moved} watchlist entr{'y' if moved == 1 else 'ies'} to Jellyfin libraries.",
"moved": moved,
"total": len(items),
"items": items,
}
def update_watchlist_category(show_id, category):
ensure_runtime()
item = WATCHLIST.update_category(show_id, category)
return {"message": f"Moved {item['title']} to {item['category_label']}.", "item": item}
def update_watchlist_media_type(show_id, media_type):
ensure_runtime()
item = WATCHLIST.update_media_type(show_id, media_type)
return {"message": f"Saved {item['title']} as {item['media_type_label']}.", "item": item}
def update_watchlist_auto_download(show_id, mode=None, quality=None, name=None, series=None, episode_offset=None):
ensure_runtime()
item = WATCHLIST.update_auto_download_settings(
show_id,
mode=mode,
quality=quality,
name=name,
series=series,
episode_offset=episode_offset,
)
return {"message": f"Saved auto-download settings for {item['title']}.", "item": item}
def update_all_watchlist_statuses():
ensure_runtime()
return WATCHLIST_REFRESH.start()
def get_watchlist_refresh_status():
ensure_runtime()
return WATCHLIST_REFRESH.status()
def remove_from_watchlist(show_id):
ensure_runtime()
return WATCHLIST.remove(show_id)
def upload_watchlist_thumbnail(payload):
ensure_runtime()
return WATCHLIST.set_manual_thumbnail(payload["show_id"], payload.get("filename"), payload.get("data_url"))
def resolve_job_watchlist_identity(job, config=None):
active_config = config or CONFIG or DEFAULT_CONFIG
return resolve_job_watchlist_identity_impl(
job,
search_fn=search_anime,
normalize_title_key_fn=normalize_identity_title_key,
base_title_variants_fn=base_title_variants,
default_mode=active_config["mode"],
)
def sync_downloaded_job_to_watchlist(job):
ensure_runtime()
show_id, title, reason = resolve_job_watchlist_identity_impl(
job,
search_fn=search_anime,
normalize_title_key_fn=normalize_identity_title_key,
base_title_variants_fn=base_title_variants,
default_mode=(CONFIG or DEFAULT_CONFIG)["mode"],
return_reason=True,
)
if not show_id:
debug_log("watchlist.sync.resolve_failed", job=job, reason=reason)
raise ValueError(f"Could not resolve a watchlist show_id for the completed download: {reason}.")
return WATCHLIST.mark_downloaded(show_id, title=title, mode=job.get("mode"), episodes=job.get("episodes"))
def prepare_download_job(job):
config = ensure_runtime()["config"]
show_id, title, reason = resolve_job_watchlist_identity_impl(
job,
search_fn=search_anime,
normalize_title_key_fn=normalize_identity_title_key,
base_title_variants_fn=base_title_variants,
default_mode=config["mode"],
return_reason=True,
)
if show_id:
job["show_id"] = show_id
if title:
job["title"] = title
if not show_id and reason:
debug_log("watchlist.prepare_job.resolve_skipped", job=job, reason=reason)
return job
def get_config_snapshot(fallback=None):
with RUNTIME_LOCK:
source = CONFIG if CONFIG is not None else fallback
if source is not None:
return dict(source)
return normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG))
def save_runtime_config(config):
current = get_config_snapshot(fallback=DEFAULT_CONFIG)
merged = dict(current)
if isinstance(config, dict):
merged.update(config)
normalized = normalize_config(merged)
scheduler = None
with RUNTIME_LOCK:
global CONFIG
CONFIG = dict(normalized)
write_json(CONFIG_PATH, CONFIG)
scheduler = WATCHLIST_AUTO_REFRESH
if scheduler is not None:
scheduler.notify_config_changed()
return dict(CONFIG)
def test_discord_webhook_config(config):
normalized = normalize_config(config)
if not normalized.get("discord_webhook_url"):
raise ValueError("Discord webhook URL is required for testing.")
send_discord_webhook_event(normalized, "test", force=True)
return {"message": "Discord webhook test notification sent."}
def runtime_state():
return {
"config": CONFIG,
"watchlist": WATCHLIST,
"download_queue": DOWNLOAD_QUEUE,
"watchlist_refresh": WATCHLIST_REFRESH,
"watchlist_auto_refresh": WATCHLIST_AUTO_REFRESH,
}
def build_runtime(start_workers=None):
migrate_legacy_state_storage()
worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) and background_workers_enabled()
config = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG))
write_json(CONFIG_PATH, config)
watchlist = WatchlistStore(config_getter=lambda: get_config_snapshot(config), start_worker=worker_state)
download_queue = DownloadQueue(
config_getter=lambda: get_config_snapshot(config),
watchlist_sync_fn=sync_downloaded_job_to_watchlist,
job_prepare_fn=prepare_download_job,
start_worker=worker_state,
)
watchlist.auto_download_fn = queue_watchlist_auto_download
watchlist.jellyfin_sync_fn = trigger_jellyfin_sync_for_item
watchlist_refresh = WatchlistRefreshJobs(
watchlist,
config_getter=lambda: get_config_snapshot(config),
start_worker=worker_state,
)
watchlist_auto_refresh = WatchlistAutoRefreshScheduler(
config_getter=lambda: get_config_snapshot(config),
refresh_start_fn=watchlist_refresh.start,
start_worker=worker_state,
)
return {
"config": config,
"watchlist": watchlist,
"download_queue": download_queue,
"watchlist_refresh": watchlist_refresh,
"watchlist_auto_refresh": watchlist_auto_refresh,
}
def initialize_runtime(start_workers=None):
global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH
with RUNTIME_LOCK:
workers_allowed = background_workers_enabled()
if (
CONFIG is not None
and WATCHLIST is not None
and DOWNLOAD_QUEUE is not None
and WATCHLIST_REFRESH is not None
and WATCHLIST_AUTO_REFRESH is not None
):
if start_workers and workers_allowed:
WATCHLIST.worker_enabled = True
WATCHLIST.ensure_worker()
DOWNLOAD_QUEUE.ensure_worker()
WATCHLIST_REFRESH.ensure_worker()
WATCHLIST_AUTO_REFRESH.ensure_worker()
return runtime_state()
runtime = build_runtime(start_workers=start_workers)
CONFIG = runtime["config"]
WATCHLIST = runtime["watchlist"]
DOWNLOAD_QUEUE = runtime["download_queue"]
WATCHLIST_REFRESH = runtime["watchlist_refresh"]
WATCHLIST_AUTO_REFRESH = runtime["watchlist_auto_refresh"]
return runtime
def ensure_runtime(start_workers=False):
if (
CONFIG is None
or WATCHLIST is None
or DOWNLOAD_QUEUE is None
or WATCHLIST_REFRESH is None
or WATCHLIST_AUTO_REFRESH is None
):
return initialize_runtime(start_workers=start_workers)
if start_workers:
return initialize_runtime(start_workers=True)
return runtime_state()
def shutdown_runtime(wait=False, cancel_active_downloads=None):
services = None
with RUNTIME_LOCK:
if (
CONFIG is None
and WATCHLIST is None
and DOWNLOAD_QUEUE is None
and WATCHLIST_REFRESH is None
and WATCHLIST_AUTO_REFRESH is None
):
return False
services = (WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH)
watchlist, download_queue, watchlist_refresh, watchlist_auto_refresh = services
should_cancel_downloads = bool(wait) if cancel_active_downloads is None else bool(cancel_active_downloads)
stopped = True
if watchlist is not None:
stopped = watchlist.shutdown(wait=wait) and stopped
if download_queue is not None:
stopped = download_queue.shutdown(wait=wait, cancel_active=should_cancel_downloads) and stopped
if watchlist_refresh is not None:
stopped = watchlist_refresh.shutdown(wait=wait) and stopped
if watchlist_auto_refresh is not None:
stopped = watchlist_auto_refresh.shutdown(wait=wait) and stopped
return stopped
def reset_runtime(wait=False, cancel_active_downloads=None):
global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH
stopped = shutdown_runtime(wait=wait, cancel_active_downloads=cancel_active_downloads)
if not stopped:
if wait:
raise RuntimeError("Runtime shutdown did not complete cleanly.")
return False
with RUNTIME_LOCK:
CONFIG = None
WATCHLIST = None
DOWNLOAD_QUEUE = None
WATCHLIST_REFRESH = None
WATCHLIST_AUTO_REFRESH = None
return True
Handler = build_handler_class(
HandlerContext(
add_to_watchlist=add_to_watchlist,
config_html=CONFIG_HTML,
download_watchlist_item=download_watchlist_item,
ensure_runtime=ensure_runtime,
episode_list=episode_list,
get_config_snapshot=get_config_snapshot,
get_watchlist=get_watchlist,
get_watchlist_refresh_status=get_watchlist_refresh_status,
http_error=HttpError,
index_html=INDEX_HTML,
queue_html=QUEUE_HTML,
remove_from_watchlist=remove_from_watchlist,
run_jellyfin_sync=run_jellyfin_sync,
runtime_state=runtime_state,
save_runtime_config=save_runtime_config,
search_anime=search_anime,
test_discord_webhook_config=test_discord_webhook_config,
thumbnail_file_path=thumbnail_file_path,
update_all_watchlist_statuses=update_all_watchlist_statuses,
update_watchlist_auto_download=update_watchlist_auto_download,
update_watchlist_category=update_watchlist_category,
update_watchlist_media_type=update_watchlist_media_type,
update_watchlist_status=update_watchlist_status,
upload_watchlist_thumbnail=upload_watchlist_thumbnail,
watchlist_html=WATCHLIST_HTML,
)
)
def parse_runtime_flags(argv):
remaining = []
for arg in argv:
if arg in {"--debug", "---debug"}:
app_support.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:
if not shutdown_runtime(wait=True, cancel_active_downloads=True):
print("Warning: background workers did not stop cleanly.")
server.server_close()
if __name__ == "__main__":
main()