2196 lines
86 KiB
Python
2196 lines
86 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,
|
|
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_config,
|
|
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 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 _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,
|
|
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")
|
|
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")
|
|
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"))
|
|
item["alternative_titles"] = decode_alternative_titles(item.get("alternative_titles_json"))
|
|
item["has_alternative_titles"] = bool(item["alternative_titles"])
|
|
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,
|
|
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,
|
|
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"),
|
|
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,
|
|
"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,
|
|
"anilist_id": None,
|
|
"anilist_title": None,
|
|
"anilist_site_url": None,
|
|
"alternative_titles_json": 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"]
|
|
previous_sub_count = int(existing.get("sub_count") or 0)
|
|
previous_dub_count = int(existing.get("dub_count") or 0)
|
|
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["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"])
|
|
return item
|
|
|
|
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.")
|
|
|
|
normalized_title = str(title or "").strip()
|
|
now = now_iso()
|
|
with self.lock, self._connect() as conn:
|
|
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (normalized_show_id,)).fetchone()
|
|
if row:
|
|
conn.execute(
|
|
"""
|
|
UPDATE watchlist
|
|
SET category = 'finished', downloaded = 1, status = 'queued', status_message = ?, updated_at = ?
|
|
WHERE show_id = ?
|
|
""",
|
|
("Queued refresh after successful download.", now, 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:
|
|
previous_show_id = str(matched_row["show_id"]).strip()
|
|
resolved_title = normalized_title or str(matched_row["title"] or "").strip() or "Unknown Anime"
|
|
conn.execute(
|
|
"""
|
|
UPDATE watchlist
|
|
SET
|
|
show_id = ?,
|
|
title = ?,
|
|
category = 'finished',
|
|
downloaded = 1,
|
|
status = 'queued',
|
|
status_message = ?,
|
|
updated_at = ?
|
|
WHERE show_id = ?
|
|
""",
|
|
(
|
|
normalized_show_id,
|
|
resolved_title,
|
|
"Queued refresh after successful download.",
|
|
now,
|
|
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 = {
|
|
"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,
|
|
"thumbnail_path": None,
|
|
"thumbnail_checked_at": None,
|
|
}
|
|
self._upsert_conn(conn, existing)
|
|
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 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["title"],
|
|
"season": "1",
|
|
"index": match.get("index", 1),
|
|
"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 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)
|
|
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(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,
|
|
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,
|
|
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_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()
|