Files
ani-cli-web/app.py
T

1934 lines
75 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,
LEGACY_THUMBNAIL_ROOT,
MAX_JSON_BODY_BYTES,
MODE_CHOICES,
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_config,
now_iso,
sanitize_path_component,
write_json,
)
from http_handler import HandlerContext, build_handler_class, server_host, server_port
from queue_jobs import DownloadQueue, 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, 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
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 thumbnail_file_path(name):
filename = Path(str(name or "")).name
if not filename:
return None
project_path = THUMBNAIL_ROOT / filename
if project_path.exists():
return project_path
legacy_path = LEGACY_THUMBNAIL_ROOT / filename
if legacy_path.exists():
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
try:
shutil.move(str(legacy_path), str(project_path))
debug_log("thumbnail.cache.migrated", from_path=legacy_path, to_path=project_path)
return project_path
except OSError as exc:
debug_log("thumbnail.cache.migrate_failed", from_path=legacy_path, to_path=project_path, error=exc)
return legacy_path
return project_path
def cover_route(show_id):
return f"/api/watchlist/thumb/{quote(str(show_id).strip(), safe='')}"
def anime_source_url(show_id, title):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
return f"{ALLMANGA_SITE_BASE}/search-anime?tr=sub&cty=ALL&query={quote(str(title or '').strip(), safe='')}"
return f"{ALLMANGA_SITE_BASE}/bangumi/{quote(normalized_show_id, safe='')}"
def infer_image_extension(url, content_type):
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
extension = mimetypes.guess_extension(normalized_type, strict=False)
if extension == ".jpe":
extension = ".jpg"
if extension:
return extension
suffix = Path(urlparse(str(url or "")).path).suffix.lower()
if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
return suffix
return ".jpg"
def save_thumbnail_bytes(show_id, source_name, content_type, content):
if not content:
raise RuntimeError("Thumbnail content was empty")
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
stem = sanitize_path_component(show_id, "anime")
extension = infer_image_extension(source_name, content_type)
final_path = THUMBNAIL_ROOT / f"{stem}{extension}"
tmp_path = final_path.with_suffix(final_path.suffix + ".tmp")
with tmp_path.open("wb") as handle:
handle.write(content)
tmp_path.replace(final_path)
for existing in THUMBNAIL_ROOT.glob(f"{stem}.*"):
if existing != final_path and existing.is_file():
existing.unlink(missing_ok=True)
debug_log("thumbnail.cache.write", show_id=show_id, path=final_path, bytes=len(content), content_type=content_type)
return final_path.name
def decode_image_data_url(data_url):
text = str(data_url or "").strip()
match = re.match(r"^data:(image/[-a-zA-Z0-9.+]+);base64,(.+)$", text, re.DOTALL)
if not match:
raise ValueError("Thumbnail upload must be a base64 image data URL.")
content_type = match.group(1).strip().lower()
try:
content = base64.b64decode(match.group(2), validate=True)
except (ValueError, BinasciiError) as exc:
raise ValueError("Thumbnail upload data was not valid base64.") from exc
if len(content) > 8 * 1024 * 1024:
raise ValueError("Thumbnail upload is too large. Keep it under 8 MB.")
return content_type, content
def 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 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 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) + "."
class WatchlistStore:
def __init__(self, start_worker=True):
self.lock = threading.RLock()
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
show_id = self.refresh_pending.pop(0)
self.refresh_pending_set.discard(show_id)
self.refresh_inflight_set.add(show_id)
return show_id
def _enqueue_refresh_locked(self, show_id):
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)
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 value != 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())
if self.refresh_pending:
self.refresh_wakeup.set()
def _refresh_loop(self):
while not self.refresh_stop_event.is_set():
show_id = self._next_pending_refresh()
if not show_id:
self.refresh_wakeup.wait(2)
self.refresh_wakeup.clear()
continue
try:
self.refresh(show_id)
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):
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)
self.refresh_wakeup.set()
return self.get(normalized_show_id)
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 _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,
anidb_aid TEXT,
anidb_title TEXT,
thumbnail_path TEXT,
thumbnail_checked_at TEXT
)
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()}
if "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 "animeschedule_route" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT")
if "animeschedule_title" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_title TEXT")
conn.execute(
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
)
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)
item["category"] = normalize_watchlist_category(item.get("category"))
item["category_label"] = watchlist_category_label(item["category"])
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"))
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, anidb_aid, anidb_title,
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,
anidb_aid = excluded.anidb_aid,
anidb_title = excluded.anidb_title,
thumbnail_path = excluded.thumbnail_path,
thumbnail_checked_at = excluded.thumbnail_checked_at
""",
(
item["show_id"],
item["title"],
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("anidb_aid"),
item.get("anidb_title"),
item.get("thumbnail_path"),
item.get("thumbnail_checked_at"),
),
)
def _migrate_legacy_json(self):
if not WATCHLIST_JSON_PATH.exists():
return
legacy = load_json(WATCHLIST_JSON_PATH, {})
if not legacy:
WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated"))
return
aggregated = {}
for value in legacy.values():
if not isinstance(value, dict):
continue
show_id = str(value.get("show_id") or "").strip()
if not show_id:
continue
mode = str(value.get("mode") or "").strip().lower()
if mode not in MODE_CHOICES:
continue
item = aggregated.setdefault(
show_id,
{
"show_id": show_id,
"title": str(value.get("title") or "Unknown Anime").strip() or "Unknown Anime",
"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,
"anidb_aid": None,
"anidb_title": None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
},
)
episodes = value.get("episodes_available") if isinstance(value.get("episodes_available"), list) else []
count = len(episodes)
latest = episodes[-1] if episodes else None
if mode == "sub":
item["sub_count"] = max(item["sub_count"], count)
item["sub_latest_episode"] = latest or item["sub_latest_episode"]
else:
item["dub_count"] = max(item["dub_count"], count)
item["dub_latest_episode"] = latest or item["dub_latest_episode"]
if item["title"] == "Unknown Anime" and str(value.get("title") or "").strip():
item["title"] = str(value.get("title")).strip()
with self.lock, self._connect() as conn:
for item in aggregated.values():
self._upsert_conn(conn, item)
WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated"))
def list(self, page=1, per_page=30, 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):
with self.lock, self._connect() as conn:
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 = {
"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,
"anidb_aid": None,
"anidb_title": None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
self._upsert_conn(conn, item)
queued = self.schedule_refresh(show_id)
return {"message": f"Added {queued['title']} to the watchlist. Refresh queued.", "item": queued, "created": True}
def refresh(self, show_id, include_thumbnail=True):
existing = self.get(show_id)
normalized_show_id = existing["show_id"]
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
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"))
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"),
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"),
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 = ?
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
return self.get(normalized_show_id)
def refresh_all(self):
refreshed = []
for show_id in self.all_show_ids():
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 queue_refresh(self, show_id, status_message="Queued watchlist refresh."):
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"])
def mark_downloaded(self, show_id, title=""):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
raise ValueError("Missing show_id for watchlist download sync.")
created = False
try:
existing = self.get(normalized_show_id)
except KeyError:
created = True
now = now_iso()
existing = {
"show_id": normalized_show_id,
"title": str(title or "Unknown Anime").strip() 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,
"anidb_aid": None,
"anidb_title": None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
with self.lock, self._connect() as conn:
self._upsert_conn(conn, existing)
else:
with self.lock, self._connect() as conn:
conn.execute(
"""
UPDATE watchlist
SET downloaded = 1, status = 'queued', status_message = ?, updated_at = ?
WHERE show_id = ?
""",
("Queued refresh after successful download.", now_iso(), normalized_show_id),
)
self.schedule_refresh(normalized_show_id)
return self.get(normalized_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)
return {"message": f"Queued refresh for {item['title']}.", "item": item}
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_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)
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):
normalized = normalize_config(config)
with RUNTIME_LOCK:
global CONFIG
CONFIG = dict(normalized)
write_json(CONFIG_PATH, CONFIG)
return dict(CONFIG)
def runtime_state():
return {
"config": CONFIG,
"watchlist": WATCHLIST,
"download_queue": DOWNLOAD_QUEUE,
"watchlist_refresh": WATCHLIST_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(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_refresh = WatchlistRefreshJobs(watchlist, start_worker=worker_state)
return {
"config": config,
"watchlist": watchlist,
"download_queue": download_queue,
"watchlist_refresh": watchlist_refresh,
}
def initialize_runtime(start_workers=None):
global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_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:
if start_workers and workers_allowed:
WATCHLIST.worker_enabled = True
WATCHLIST.ensure_worker()
DOWNLOAD_QUEUE.ensure_worker()
WATCHLIST_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"]
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:
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:
return False
services = (WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH)
watchlist, download_queue, watchlist_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
return stopped
def reset_runtime(wait=False, cancel_active_downloads=None):
global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_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
return True
Handler = build_handler_class(
HandlerContext(
add_to_watchlist=add_to_watchlist,
config_html=CONFIG_HTML,
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,
remove_from_watchlist=remove_from_watchlist,
runtime_state=runtime_state,
save_runtime_config=save_runtime_config,
search_anime=search_anime,
thumbnail_file_path=thumbnail_file_path,
update_all_watchlist_statuses=update_all_watchlist_statuses,
update_watchlist_category=update_watchlist_category,
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()