Files

3433 lines
144 KiB
Python

#!/usr/bin/env python3
import base64
import hashlib
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 contextlib import contextmanager
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
import provider_bridge
import provider_updates
from app_support import (
AGENT,
ANIDB_ANIME_PAGE,
ANIDB_TITLES_CACHE,
ANIDB_TITLES_PATH,
ANIDB_TITLES_URL,
ANIMESCHEDULE_API,
ANIMESCHEDULE_IMAGE_BASE,
APP_NAME,
CONFIG_PATH,
DEFAULT_CONFIG,
MAX_JSON_BODY_BYTES,
MODE_CHOICES,
QUALITY_CHOICES,
send_discord_webhook_event,
STATE_DB_PATH,
THUMBNAIL_RETRY_SECONDS,
THUMBNAIL_ROOT,
VERSION,
WATCHLIST_CATEGORY_CHOICES,
WATCHLIST_CATEGORY_LABELS,
WATCHLIST_JSON_PATH,
WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS,
WORKER_SHUTDOWN_TIMEOUT_SECONDS,
background_workers_enabled,
client_address_is_local,
clear_remote_access_sessions,
debug_log,
ensure_project_app_root,
load_json,
normalize_season,
normalize_config,
normalize_episode_offset,
normalize_media_type,
normalize_result_index,
now_iso,
remote_access_session_fingerprint,
sanitize_path_component,
thumbnail_file_path,
validate_discord_webhook_url,
write_json,
)
from http_handler import HandlerContext, build_handler_class, server_host, server_port
from queue_jobs import DownloadQueue, JellyfinSyncJobs, 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,
titles_confidently_match,
)
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
WATCHLIST_JELLYFIN_SYNC = None
WATCHLIST_BACKUP_FORMAT_VERSION = 1
PROVIDER_UPDATE_STATUS = provider_updates.default_status()
PROVIDER_UPDATE_CHECK_THREAD = None
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 search_thumbnail_cache_key(title):
normalized = normalize_title_key(title) or str(title or "").strip().casefold()
digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
return f"search-{digest}"
PROVIDER_SITE_BASES = {
"anikoto": "https://anikototv.to",
"anineko": "https://anineko.to",
"pahe": "https://animepahe.pw",
}
def anime_source_url(show_id, title, provider=None):
parsed_provider, provider_id = provider_bridge.parse_provider_id(show_id, provider or "anikoto")
normalized_provider = normalize_provider(provider, parsed_provider)
base_url = PROVIDER_SITE_BASES[normalized_provider]
normalized_provider_id = str(provider_id or "").strip()
if normalized_provider_id:
if normalized_provider == "pahe":
return f"{base_url}/anime/{quote(normalized_provider_id, safe='')}"
return f"{base_url}/watch/{quote(normalized_provider_id, safe='')}"
query = quote(str(title or "").strip(), safe="")
if normalized_provider == "anineko":
return f"{base_url}/browser?keyword={query}"
if normalized_provider == "pahe":
return f"{base_url}/api?m=search&q={query}"
return f"{base_url}/search?keyword={query}"
def infer_image_extension(url, content_type):
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
extension = mimetypes.guess_extension(normalized_type, strict=False)
if extension == ".jpe":
extension = ".jpg"
if extension:
return extension
suffix = Path(urlparse(str(url or "")).path).suffix.lower()
if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
return suffix
return ".jpg"
def save_thumbnail_bytes(show_id, source_name, content_type, content):
if not content:
raise RuntimeError("Thumbnail content was empty")
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
stem = sanitize_path_component(show_id, "anime")
extension = infer_image_extension(source_name, content_type)
final_path = THUMBNAIL_ROOT / f"{stem}{extension}"
tmp_path = final_path.with_suffix(final_path.suffix + ".tmp")
with tmp_path.open("wb") as handle:
handle.write(content)
tmp_path.replace(final_path)
for existing in THUMBNAIL_ROOT.glob(f"{stem}.*"):
if existing != final_path and existing.is_file():
existing.unlink(missing_ok=True)
debug_log("thumbnail.cache.write", show_id=show_id, path=final_path, bytes=len(content), content_type=content_type)
return final_path.name
def decode_image_data_url(data_url):
text = str(data_url or "").strip()
match = re.match(r"^data:(image/[-a-zA-Z0-9.+]+);base64,(.+)$", text, re.DOTALL)
if not match:
raise ValueError("Thumbnail upload must be a base64 image data URL.")
content_type = match.group(1).strip().lower()
try:
content = base64.b64decode(match.group(2), validate=True)
except (ValueError, BinasciiError) as exc:
raise ValueError("Thumbnail upload data was not valid base64.") from exc
if len(content) > 8 * 1024 * 1024:
raise ValueError("Thumbnail upload is too large. Keep it under 8 MB.")
return content_type, content
def animeschedule_request(path, params=None, timeout=25):
params = params or {}
query = "&".join(f"{quote(str(key), safe='')}={quote(str(value), safe='')}" for key, value in params.items())
url = f"{ANIMESCHEDULE_API}{path}"
if query:
url = f"{url}?{query}"
debug_log("animeschedule.request", url=url, params=params)
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"User-Agent": AGENT,
},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
debug_log(
"animeschedule.response",
url=url,
status=getattr(response, "status", "unknown"),
bytes=len(raw.encode("utf-8")),
snippet=raw[:200],
)
except urllib.error.URLError as exc:
debug_log("animeschedule.error", url=url, error=exc)
raise RuntimeError(f"Could not reach AnimeSchedule: {exc}") from exc
except TimeoutError as exc:
debug_log("animeschedule.timeout", url=url)
raise RuntimeError("AnimeSchedule request timed out") from exc
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError("AnimeSchedule returned unreadable JSON") from exc
def encode_alternative_titles(alternative_titles):
normalized = []
for item in alternative_titles or []:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
if label and value:
normalized.append({"label": label, "value": value})
return json.dumps(normalized, ensure_ascii=True)
def decode_alternative_titles(payload):
if not payload:
return []
try:
raw = json.loads(payload)
except (TypeError, ValueError, json.JSONDecodeError):
return []
normalized = []
seen = set()
for item in raw if isinstance(raw, list) else []:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
normalized_key = (label.lower(), normalize_title_key(value) or value.casefold())
if not label or not value or not normalized_key[1] or normalized_key in seen:
continue
seen.add(normalized_key)
normalized.append({"label": label, "value": value})
return normalized
def extract_anidb_alternative_titles(aid, primary_title=""):
normalized_aid = str(aid or "").strip()
if not normalized_aid:
return []
primary_key = normalize_title_key(primary_title) or str(primary_title or "").strip().casefold()
results = []
seen = set()
type_labels = {
"official": "English",
"main": "English",
"short": "English",
"syn": "English",
}
for entry in get_cached_anidb_titles():
if str(entry.get("aid") or "").strip() != normalized_aid:
continue
if str(entry.get("lang") or "").strip().lower() != "en":
continue
text = str(entry.get("title") or "").strip()
normalized = normalize_title_key(text) or text.casefold()
if not normalized or normalized == primary_key or normalized in seen:
continue
seen.add(normalized)
label = type_labels.get(str(entry.get("type") or "").strip().lower(), "English")
results.append({"label": label, "value": text})
return results
def parse_nonnegative_int(value, default=0):
try:
number = int(value)
except (TypeError, ValueError):
return default
return number if number >= 0 else default
def normalize_animeschedule_status(value):
text = str(value or "").strip()
return text.title() if text else ""
def normalize_watchlist_category(value, default="watching"):
text = str(value or "").strip().lower()
aliases = {
"watching": "watching",
"planned": "planned",
"planed": "planned",
"finished": "finished",
"dropped": "dropped",
}
return aliases.get(text, default)
def watchlist_category_label(value):
return WATCHLIST_CATEGORY_LABELS[normalize_watchlist_category(value)]
def media_type_label(value):
return "Movie" if normalize_media_type(value) == "movie" else "TV"
PROVIDER_LABELS = {
"anikoto": "Anikoto",
"anineko": "AniNeko",
"pahe": "AnimePahe",
}
def normalize_provider(value, fallback="anikoto"):
provider = str(value or "").strip().lower()
fallback_provider = str(fallback or "anikoto").strip().lower()
if provider in provider_bridge.PROVIDERS:
return provider
if fallback_provider in provider_bridge.PROVIDERS:
return fallback_provider
return "anikoto"
def provider_label(value):
return PROVIDER_LABELS.get(normalize_provider(value), PROVIDER_LABELS["anikoto"])
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 extract_anidb_alternative_titles_for_title(title, primary_title=""):
clean_title = str(title or "").strip()
if not clean_title:
return []
try:
match = resolve_anidb_aid(clean_title)
except Exception as exc:
debug_log("anidb.alt_titles.resolve_failed", title=clean_title, error=exc)
return []
try:
return extract_anidb_alternative_titles(match["aid"], primary_title=primary_title or clean_title)
except Exception as exc:
debug_log("anidb.alt_titles.extract_failed", title=clean_title, aid=match.get("aid"), error=exc)
return []
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 resolve_search_thumbnail(title):
clean_title = str(title or "").strip()
if not clean_title:
raise ValueError("Missing title for thumbnail lookup")
cache_key = search_thumbnail_cache_key(clean_title)
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
for existing in THUMBNAIL_ROOT.glob(f"{cache_key}.*"):
if existing.is_file():
return existing
cover = None
last_error = None
for resolver in (fetch_animeschedule_cover, fetch_anidb_cover):
try:
cover = resolver(clean_title)
break
except Exception as exc:
last_error = exc
if not cover:
raise RuntimeError("Could not find a thumbnail for this search result") from last_error
cached_name = cache_thumbnail_image(cache_key, cover["url"])
path = thumbnail_file_path(cached_name)
if not path or not path.exists():
raise RuntimeError("Could not cache the search thumbnail")
return path
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 episode_language_summary(show_id, provider):
summary = {
"sub": False,
"dub": False,
"sub_count": 0,
"dub_count": 0,
"total": 0,
}
try:
rows = provider_bridge.episodes(show_id, provider=provider)
except Exception as exc:
debug_log("search.episode_language_summary_failed", show_id=show_id, provider=provider, error=exc)
return summary
sub_values = set()
dub_values = set()
all_values = set()
for row in rows:
value = str(row.get("number") or "").strip()
if value:
all_values.add(value)
langs = {str(lang or "").strip().lower() for lang in row.get("langs") or []}
if not langs:
langs.add("sub")
if "hsub" in langs:
langs.add("sub")
if "sub" in langs and value:
sub_values.add(value)
if "dub" in langs and value:
dub_values.add(value)
summary["sub"] = bool(sub_values)
summary["dub"] = bool(dub_values)
summary["sub_count"] = len(sub_values)
summary["dub_count"] = len(dub_values)
summary["total"] = len(all_values)
return summary
def search_anime(query, mode, provider=None):
runtime = ensure_runtime()
configured_provider = str((runtime.get("config") or {}).get("provider") or "anikoto").strip().lower()
provider = str(provider or configured_provider).strip().lower()
if provider not in provider_bridge.PROVIDERS:
provider = configured_provider if configured_provider in provider_bridge.PROVIDERS else "anikoto"
data = provider_bridge.search(query, provider=provider, page=1)
shows = data.get("results") or []
results = []
for show in shows:
title = str(show.get("title") or "").replace('\\"', '"').strip()
show_id = str(show.get("id") or "").strip()
if not show_id or not title:
continue
language_summary = episode_language_summary(show_id, show.get("provider") or provider)
alternative_titles = extract_anidb_alternative_titles_for_title(title, primary_title=title)
index = len(results) + 1
results.append(
{
"id": show_id,
"title": title,
"episodes": language_summary["total"],
"has_sub": language_summary["sub"],
"has_dub": language_summary["dub"],
"sub_episodes": language_summary["sub_count"],
"dub_episodes": language_summary["dub_count"],
"image": show.get("image") or "",
"index": index,
"query": query,
"mode": mode,
"provider": show.get("provider") or provider,
"alternative_titles": alternative_titles,
}
)
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):
runtime = ensure_runtime()
provider = str((runtime.get("config") or {}).get("provider") or "anikoto").strip().lower()
info = provider_bridge.info(show_id, provider=provider)
rows = provider_bridge.episodes(show_id, provider=provider)
episodes = {choice: [] for choice in MODE_CHOICES}
for row in rows:
value = str(row.get("number") or "").strip()
if not value:
continue
langs = {str(lang or "").strip().lower() for lang in row.get("langs") or []}
if not langs:
langs.add("sub")
if "hsub" in langs:
langs.add("sub")
for choice in MODE_CHOICES:
if choice in langs and value not in episodes[choice]:
episodes[choice].append(value)
for choice in MODE_CHOICES:
episodes[choice] = sorted(episodes[choice], key=numeric_episode_key)
return {
"show_id": str(info.get("id") or show_id),
"title": str(info.get("title") or "").replace('\\"', '"').strip(),
"episodes": episodes,
}
def episode_list(show_id, mode):
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub")
return fetch_show_episode_snapshot(show_id)["episodes"][normalized_mode]
def format_episode_progress(available_count, expected_count):
available = parse_nonnegative_int(available_count)
expected = parse_nonnegative_int(expected_count)
if expected > 0:
return f"{available}/{expected}"
return str(available)
def mode_is_complete(available_count, expected_count, airing_status):
expected = parse_nonnegative_int(expected_count)
available = parse_nonnegative_int(available_count)
return expected > 0 and normalize_animeschedule_status(airing_status) == "Finished" and available >= expected
def watchlist_status_message(sub_count, dub_count, expected_count=0, airing_status=""):
parts = [
f"Sub: {format_episode_progress(sub_count, expected_count)} available",
f"Dub: {format_episode_progress(dub_count, expected_count)} available",
]
status = normalize_animeschedule_status(airing_status)
if status:
parts.append(f"AnimeSchedule: {status}")
if mode_is_complete(sub_count, expected_count, status):
parts.append("Sub ready")
if mode_is_complete(dub_count, expected_count, status):
parts.append("Dub ready")
return ". ".join(parts) + "."
def watchlist_download_library_name(item):
return sanitize_path_component(
item.get("auto_download_name") or item.get("title") or item.get("show_id") or "Anime",
sanitize_path_component(item.get("title") or item.get("show_id") or "Anime", "Anime"),
)
def watchlist_source_library_dir(item, config):
media_type = normalize_media_type(item.get("media_type"))
return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item)
def watchlist_source_season_dir(item, config):
source_root = watchlist_source_library_dir(item, config)
if normalize_media_type(item.get("media_type")) == "movie":
return source_root
season = normalize_season((item or {}).get("auto_download_series"))
return source_root / f"Season {int(season):02d}"
def jellyfin_target_library_dir(item, config):
media_type = normalize_media_type(item.get("media_type"))
key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
root = str((config or {}).get(key) or "").strip()
if not root:
return None
return Path(root).expanduser() / watchlist_download_library_name(item)
def watchlist_item_ready_for_jellyfin(item):
if not isinstance(item, dict):
return False, "invalid_item"
if not bool(item.get("downloaded")):
return False, "not_downloaded"
if normalize_watchlist_category(item.get("category")) != "finished":
return False, "category"
if normalize_media_type(item.get("media_type")) == "movie":
return True, "ready"
preferred_mode = watchlist_preferred_completion_mode(item)
return watchlist_item_download_complete(item, mode=preferred_mode)
def watchlist_preferred_completion_mode(item, fallback_mode=""):
fallback = str(fallback_mode or "").strip().lower()
if fallback in MODE_CHOICES:
return fallback
configured = str((item or {}).get("auto_download_mode") or "").strip().lower()
if configured in MODE_CHOICES:
return configured
downloaded_dub = len((item or {}).get("downloaded_dub_episodes") or [])
downloaded_sub = len((item or {}).get("downloaded_sub_episodes") or [])
if downloaded_dub and not downloaded_sub:
return "dub"
if downloaded_sub and not downloaded_dub:
return "sub"
return "dub"
def watchlist_item_download_complete(item, mode=""):
if not isinstance(item, dict):
return False, "invalid_item"
normalized_mode = watchlist_preferred_completion_mode(item, fallback_mode=mode)
expected_count = int(item.get("expected_count") or 0)
if expected_count < 1:
return False, "expected_count"
if normalize_animeschedule_status(item.get("airing_status")) != "Finished":
return False, "airing_status"
available_count = int(item.get(f"{normalized_mode}_count") or 0)
if available_count < expected_count:
return False, "episodes_unavailable"
downloaded_count = len(item.get(f"downloaded_{normalized_mode}_episodes") or [])
if downloaded_count < expected_count:
return False, "episodes_missing"
return True, "ready"
def normalize_downloaded_episode_value(value):
text = str(value or "").strip()
if not text:
return ""
match = re.match(r"^0*([0-9]+)(.*)$", text)
if not match:
return text
number = int(match.group(1) or "0", 10)
suffix = str(match.group(2) or "")
return f"{number}{suffix}" if number > 0 else text
def reverse_episode_offset(value, offset):
normalized_offset = normalize_episode_offset(offset)
text = str(value or "").strip()
if not normalized_offset or not text:
return normalize_downloaded_episode_value(text)
match = re.match(r"^0*([0-9]+)(.*)$", text)
if not match:
return normalize_downloaded_episode_value(text)
number = int(match.group(1) or "0", 10)
if number < 1:
return normalize_downloaded_episode_value(text)
logical_number = number - int(normalized_offset, 10) + 1
if logical_number < 1:
return ""
suffix = str(match.group(2) or "")
return f"{logical_number}{suffix}"
def watchlist_downloaded_episode_values_from_filesystem(item, config, mode):
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in MODE_CHOICES:
return []
season_dir = watchlist_source_season_dir(item, config)
if not season_dir.exists() or not season_dir.is_dir():
return []
season = normalize_season((item or {}).get("auto_download_series"))
pattern = re.compile(rf"\bS{int(season):02d}E([0-9][0-9A-Za-z.\-]*)\b", re.IGNORECASE)
values = []
seen = set()
offset = (item or {}).get("auto_download_offset")
for path in sorted(season_dir.rglob("*")):
if not path.is_file():
continue
match = pattern.search(path.stem)
if not match:
continue
logical_value = reverse_episode_offset(match.group(1), offset)
normalized_value = normalize_downloaded_episode_value(logical_value)
if normalized_value and normalized_value not in seen:
seen.add(normalized_value)
values.append(normalized_value)
return values
def _move_tree_contents(source_dir, target_dir, progress_fn=None):
moved = []
file_paths = [
source_path
for source_path in sorted(source_dir.rglob("*"), key=lambda path: (len(path.parts), str(path)))
if source_path.is_file()
]
total_files = len(file_paths)
for index, source_path in enumerate(file_paths, start=1):
relative = source_path.relative_to(source_dir)
destination = target_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
final_destination = app_support.unique_destination(destination)
if progress_fn is not None:
progress_fn(
{
"file_index": index,
"file_total": total_files,
"source": str(source_path),
"current_file": str(final_destination),
}
)
shutil.move(str(source_path), str(final_destination))
moved.append(str(final_destination))
for path in sorted(source_dir.rglob("*"), key=lambda path: len(path.parts), reverse=True):
if path.is_dir():
try:
path.rmdir()
except OSError:
pass
try:
source_dir.rmdir()
except OSError:
pass
return moved
def jellyfin_media_files(target_dir):
video_suffixes = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".m4v", ".ts"}
return [
path
for path in target_dir.rglob("*")
if path.is_file() and path.suffix.lower() in video_suffixes
]
def jellyfin_target_looks_complete(item, target_dir):
files = jellyfin_media_files(target_dir)
if normalize_media_type(item.get("media_type")) == "movie":
return bool(files)
expected_count = int(item.get("expected_count") or 0)
if expected_count < 1:
return False
return len(files) >= expected_count
def move_watchlist_item_to_jellyfin(item, config, progress_fn=None):
source_dir = watchlist_source_library_dir(item, config)
target_dir = jellyfin_target_library_dir(item, config)
if target_dir is None:
return {"moved": False, "reason": "target_missing", "item": item}
try:
if source_dir.resolve() == target_dir.resolve():
return {"moved": False, "reason": "same_path", "item": item, "source": str(source_dir), "target": str(target_dir)}
except OSError:
pass
if not source_dir.exists():
if target_dir.exists():
reason = "already_moved" if jellyfin_target_looks_complete(item, target_dir) else "target_incomplete"
return {"moved": False, "reason": reason, "item": item, "source": str(source_dir), "target": str(target_dir)}
return {"moved": False, "reason": "source_missing", "item": item, "source": str(source_dir), "target": str(target_dir)}
target_dir.parent.mkdir(parents=True, exist_ok=True)
target_dir.mkdir(parents=True, exist_ok=True)
moved_files = _move_tree_contents(source_dir, target_dir, progress_fn=progress_fn)
return {
"moved": True,
"reason": "moved",
"item": item,
"source": str(source_dir),
"target": str(target_dir),
"moved_files": moved_files,
}
def notify_jellyfin_sync_result(item, result, config, automatic=False):
reason = str(result.get("reason") or "").strip()
if result.get("moved"):
event_name = "jellyfin_sync_success"
elif reason in {"already_moved", "same_path", "disabled", "not_downloaded", "category", "expected_count", "airing_status", "episodes_missing", "ready"}:
return {"sent": False, "reason": "skip"}
else:
event_name = "jellyfin_sync_failed"
payload = {
"show_id": item.get("show_id"),
"title": item.get("title"),
"media_type": normalize_media_type(item.get("media_type")),
"source": "automatic" if automatic else "manual",
"reason": reason or ("moved" if result.get("moved") else "unknown"),
"target": result.get("target"),
"source_path": result.get("source"),
"moved_files": result.get("moved_files") if isinstance(result.get("moved_files"), list) else [],
"error": result.get("error"),
}
send_discord_webhook_event(config or get_config_snapshot(), event_name, payload)
return {"sent": True, "event": event_name}
def episode_specs_for_values(values, available_episodes):
ordered = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
targets = {str(value).strip() for value in values or [] if str(value).strip()}
if not ordered or not targets:
return []
indices = [index for index, value in enumerate(ordered) if value in targets]
if not indices:
return []
ranges = []
start = indices[0]
end = indices[0]
for index in indices[1:]:
if index == end + 1:
end = index
continue
ranges.append((start, end))
start = end = index
ranges.append((start, end))
specs = []
for start, end in ranges:
specs.append(ordered[start] if start == end else f"{ordered[start]}-{ordered[end]}")
return specs
def encode_episode_values(values):
normalized = []
seen = set()
for value in values or []:
text = str(value or "").strip()
if text and text not in seen:
seen.add(text)
normalized.append(text)
return json.dumps(normalized, ensure_ascii=True)
def decode_episode_values(payload):
if not payload:
return []
try:
raw = json.loads(payload)
except (TypeError, ValueError, json.JSONDecodeError):
return []
normalized = []
seen = set()
for value in raw if isinstance(raw, list) else []:
text = str(value or "").strip()
if text and text not in seen:
seen.add(text)
normalized.append(text)
return normalized
def expand_episode_spec_against_available(episode_spec, available_episodes):
specs = [part for part in re.split(r"\s+", str(episode_spec or "").strip()) if part]
values = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
if not specs or not values:
return []
index_map = {value: index for index, value in enumerate(values)}
expanded = []
seen = set()
for spec in specs:
if "-" in spec:
start, end = [piece.strip() for piece in spec.split("-", 1)]
if start in index_map and end in index_map:
start_index = index_map[start]
end_index = index_map[end]
if start_index > end_index:
start_index, end_index = end_index, start_index
for value in values[start_index : end_index + 1]:
if value not in seen:
seen.add(value)
expanded.append(value)
continue
if spec in index_map and spec not in seen:
seen.add(spec)
expanded.append(spec)
return expanded
class WatchlistStore:
def __init__(self, config_getter=None, start_worker=True):
self.lock = threading.RLock()
self.config_getter = config_getter or (lambda: dict(DEFAULT_CONFIG))
self.auto_download_fn = None
self.jellyfin_sync_fn = None
self.thumbnail_events = {}
self.refresh_pending = []
self.refresh_pending_set = set()
self.refresh_inflight_set = set()
self.refresh_wakeup = threading.Event()
self.refresh_stop_event = threading.Event()
self.refresh_worker = None
self.worker_enabled = bool(start_worker)
self._init_db()
self._migrate_legacy_json()
self._restore_queued_refreshes()
if start_worker:
self.ensure_worker()
def ensure_worker(self):
if not self.worker_enabled:
return
with self.lock:
if self.refresh_worker is not None and self.refresh_worker.is_alive():
return
self.refresh_stop_event.clear()
self.refresh_worker = threading.Thread(target=self._refresh_loop, daemon=True)
self.refresh_worker.start()
def shutdown(self, wait=False):
self.refresh_stop_event.set()
self.refresh_wakeup.set()
worker = self.refresh_worker
if wait and worker is not None and worker.is_alive():
worker.join(timeout=WORKER_SHUTDOWN_TIMEOUT_SECONDS)
return worker is None or not worker.is_alive()
def _refresh_request_timeout(self):
return WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS
def _next_pending_refresh(self):
with self.lock:
if not self.refresh_pending:
return None, None
show_id, source = self.refresh_pending.pop(0)
self.refresh_pending_set.discard(show_id)
self.refresh_inflight_set.add(show_id)
return show_id, source
def _enqueue_refresh_locked(self, show_id, source="manual"):
normalized_show_id = str(show_id or "").strip()
if (
not normalized_show_id
or normalized_show_id in self.refresh_pending_set
or normalized_show_id in self.refresh_inflight_set
):
return False
self.refresh_pending.append((normalized_show_id, str(source or "manual").strip().lower() or "manual"))
self.refresh_pending_set.add(normalized_show_id)
return True
def _drop_refresh_locked(self, show_id):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
return False
removed = False
if normalized_show_id in self.refresh_pending_set:
self.refresh_pending = [
value for value in self.refresh_pending if str((value[0] if isinstance(value, tuple) else value) or "").strip() != normalized_show_id
]
self.refresh_pending_set.discard(normalized_show_id)
removed = True
if normalized_show_id in self.refresh_inflight_set:
self.refresh_inflight_set.discard(normalized_show_id)
removed = True
return removed
def _restore_queued_refreshes(self):
with self.lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT show_id
FROM watchlist
WHERE status = 'queued'
ORDER BY updated_at ASC, created_at ASC, title COLLATE NOCASE ASC
"""
).fetchall()
for row in rows:
self._enqueue_refresh_locked(str(row["show_id"]).strip(), source="manual")
if self.refresh_pending:
self.refresh_wakeup.set()
def _refresh_loop(self):
while not self.refresh_stop_event.is_set():
show_id, refresh_source = self._next_pending_refresh()
if not show_id:
self.refresh_wakeup.wait(2)
self.refresh_wakeup.clear()
continue
try:
self.refresh(show_id, refresh_source=refresh_source or "manual")
except Exception as exc:
debug_log("watchlist.refresh_worker.error", show_id=show_id, error=exc)
finally:
with self.lock:
self.refresh_inflight_set.discard(show_id)
def schedule_refresh(self, show_id, source="manual"):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
raise ValueError("Missing show_id.")
self.ensure_worker()
with self.lock:
self._enqueue_refresh_locked(normalized_show_id, source=source)
self.refresh_wakeup.set()
return self.get(normalized_show_id)
def _auto_download_defaults(self, title):
config = self.config_getter() or {}
normalized_title = str(title or "").strip() or "Anime"
return {
"auto_download_mode": str(config.get("auto_download_mode") or "dub").strip().lower() or "dub",
"auto_download_quality": str(config.get("auto_download_quality") or "best").strip().lower() or "best",
"auto_download_name": sanitize_path_component(normalized_title, "Anime"),
"auto_download_source_name": normalized_title,
"auto_download_series": "1",
"auto_download_offset": None,
"media_type": "tv",
}
@contextmanager
def _connect(self):
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _table_columns_conn(self, conn, table_name):
return [str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall()]
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,
provider TEXT,
title TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'watching',
downloaded INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
status_message TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_checked TEXT,
sub_count INTEGER NOT NULL DEFAULT 0,
dub_count INTEGER NOT NULL DEFAULT 0,
expected_count INTEGER NOT NULL DEFAULT 0,
airing_status TEXT,
sub_latest_episode TEXT,
dub_latest_episode TEXT,
animeschedule_route TEXT,
animeschedule_title TEXT,
anilist_id INTEGER,
anilist_title TEXT,
anilist_site_url TEXT,
alternative_titles_json TEXT,
anidb_aid TEXT,
anidb_title TEXT,
media_type TEXT NOT NULL DEFAULT 'tv',
auto_download_mode TEXT,
auto_download_quality TEXT,
auto_download_name TEXT,
auto_download_source_name TEXT,
auto_download_series TEXT,
auto_download_offset TEXT,
downloaded_sub_episodes_json TEXT,
downloaded_dub_episodes_json TEXT,
thumbnail_path TEXT,
thumbnail_checked_at TEXT
)
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()}
if "provider" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN provider TEXT")
if "category" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN category TEXT NOT NULL DEFAULT 'watching'")
if "downloaded" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded INTEGER NOT NULL DEFAULT 0")
if "thumbnail_path" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN thumbnail_path TEXT")
if "thumbnail_checked_at" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN thumbnail_checked_at TEXT")
if "expected_count" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN expected_count INTEGER NOT NULL DEFAULT 0")
if "airing_status" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN airing_status TEXT")
if "anidb_aid" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_aid TEXT")
if "anidb_title" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_title TEXT")
if "media_type" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN media_type TEXT NOT NULL DEFAULT 'tv'")
if "animeschedule_route" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT")
if "animeschedule_title" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_title TEXT")
if "anilist_id" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_id INTEGER")
if "anilist_title" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_title TEXT")
if "anilist_site_url" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_site_url TEXT")
if "alternative_titles_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN alternative_titles_json TEXT")
if "auto_download_mode" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_mode TEXT")
if "auto_download_quality" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_quality TEXT")
if "auto_download_name" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_name TEXT")
if "auto_download_source_name" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_source_name TEXT")
if "auto_download_series" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_series TEXT")
if "auto_download_offset" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_offset TEXT")
if "downloaded_sub_episodes_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_sub_episodes_json TEXT")
if "downloaded_dub_episodes_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_dub_episodes_json TEXT")
conn.execute(
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
)
conn.execute(
"UPDATE watchlist SET media_type = 'tv' WHERE media_type IS NULL OR TRIM(media_type) = ''"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_title ON watchlist(title)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_category ON watchlist(category)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_updated_at ON watchlist(updated_at DESC)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS watchlist_removals (
show_id TEXT PRIMARY KEY,
title TEXT,
removed_at TEXT NOT NULL
)
"""
)
def _remember_removal_conn(self, conn, show_id, title):
conn.execute(
"""
INSERT INTO watchlist_removals (show_id, title, removed_at)
VALUES (?, ?, ?)
ON CONFLICT(show_id) DO UPDATE SET
title = excluded.title,
removed_at = excluded.removed_at
""",
(str(show_id or "").strip(), str(title or "").strip(), now_iso()),
)
def _clear_removal_conn(self, conn, show_id):
conn.execute("DELETE FROM watchlist_removals WHERE show_id = ?", (str(show_id or "").strip(),))
def _was_removed_conn(self, conn, show_id):
row = conn.execute(
"SELECT 1 FROM watchlist_removals WHERE show_id = ? LIMIT 1",
(str(show_id or "").strip(),),
).fetchone()
return row is not None
def _row_to_item(self, row):
item = dict(row)
defaults = self._auto_download_defaults(item.get("title"))
parsed_provider, _provider_id = provider_bridge.parse_provider_id(item.get("show_id"), item.get("provider") or "anikoto")
item["provider"] = normalize_provider(item.get("provider"), parsed_provider)
item["provider_label"] = provider_label(item["provider"])
item["category"] = normalize_watchlist_category(item.get("category"))
item["category_label"] = watchlist_category_label(item["category"])
item["media_type"] = normalize_media_type(item.get("media_type"))
item["media_type_label"] = media_type_label(item["media_type"])
item["downloaded"] = bool(int(item.get("downloaded") or 0))
item["sub_count"] = int(item.get("sub_count") or 0)
item["dub_count"] = int(item.get("dub_count") or 0)
item["expected_count"] = int(item.get("expected_count") or 0)
item["airing_status"] = normalize_animeschedule_status(item.get("airing_status"))
item["total_count"] = item["sub_count"] + item["dub_count"]
item["sub_progress"] = format_episode_progress(item["sub_count"], item["expected_count"])
item["dub_progress"] = format_episode_progress(item["dub_count"], item["expected_count"])
item["sub_complete"] = mode_is_complete(item["sub_count"], item["expected_count"], item["airing_status"])
item["dub_complete"] = mode_is_complete(item["dub_count"], item["expected_count"], item["airing_status"])
item["finished_badge"] = (
"downloaded" if item["category"] == "finished" and item["downloaded"] else "manual" if item["category"] == "finished" else ""
)
item["source_url"] = anime_source_url(item.get("show_id"), item.get("title"), item.get("provider"))
thumb_path = thumbnail_file_path(item.get("thumbnail_path"))
item["thumbnail_ready"] = bool(thumb_path and thumb_path.exists())
item["thumbnail_url"] = cover_route(item.get("show_id"))
item["alternative_titles"] = decode_alternative_titles(item.get("alternative_titles_json"))
item["has_alternative_titles"] = bool(item["alternative_titles"])
mode = str(item.get("auto_download_mode") or defaults["auto_download_mode"]).strip().lower()
quality = str(item.get("auto_download_quality") or defaults["auto_download_quality"]).strip().lower()
item["auto_download_mode"] = mode if mode in MODE_CHOICES else defaults["auto_download_mode"]
item["auto_download_quality"] = quality if quality in QUALITY_CHOICES else defaults["auto_download_quality"]
item["auto_download_name"] = sanitize_path_component(item.get("auto_download_name") or defaults["auto_download_name"], defaults["auto_download_name"])
item["auto_download_source_name"] = str(item.get("auto_download_source_name") or defaults["auto_download_source_name"]).strip() or defaults["auto_download_source_name"]
item["auto_download_series"] = normalize_season(item.get("auto_download_series"))
item["auto_download_offset"] = normalize_episode_offset(item.get("auto_download_offset"))
item["downloaded_sub_episodes"] = decode_episode_values(item.get("downloaded_sub_episodes_json"))
item["downloaded_dub_episodes"] = decode_episode_values(item.get("downloaded_dub_episodes_json"))
if item.get("animeschedule_route"):
title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title"
item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}"
elif item.get("anidb_aid"):
title = str(item.get("anidb_title") or item.get("title") or "").strip() or "unknown title"
item["thumbnail_debug"] = f"AniDB: {item['anidb_aid']} - {title}"
elif item["thumbnail_ready"]:
item["thumbnail_debug"] = "Thumbnail source: manual upload"
else:
item["thumbnail_debug"] = "Thumbnail source: unresolved"
return item
def _upsert_conn(self, conn, item):
conn.execute(
"""
INSERT INTO watchlist (
show_id, provider, title, category, downloaded, status, status_message, created_at, updated_at,
last_checked, sub_count, dub_count, expected_count, airing_status,
sub_latest_episode, dub_latest_episode,
animeschedule_route, animeschedule_title,
anilist_id, anilist_title, anilist_site_url, alternative_titles_json,
anidb_aid, anidb_title, media_type,
auto_download_mode, auto_download_quality, auto_download_name, auto_download_source_name, auto_download_series, auto_download_offset,
downloaded_sub_episodes_json, downloaded_dub_episodes_json,
thumbnail_path, thumbnail_checked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(show_id) DO UPDATE SET
provider = excluded.provider,
title = excluded.title,
category = excluded.category,
downloaded = excluded.downloaded,
status = excluded.status,
status_message = excluded.status_message,
updated_at = excluded.updated_at,
last_checked = excluded.last_checked,
sub_count = excluded.sub_count,
dub_count = excluded.dub_count,
expected_count = excluded.expected_count,
airing_status = excluded.airing_status,
sub_latest_episode = excluded.sub_latest_episode,
dub_latest_episode = excluded.dub_latest_episode,
animeschedule_route = excluded.animeschedule_route,
animeschedule_title = excluded.animeschedule_title,
anilist_id = excluded.anilist_id,
anilist_title = excluded.anilist_title,
anilist_site_url = excluded.anilist_site_url,
alternative_titles_json = excluded.alternative_titles_json,
anidb_aid = excluded.anidb_aid,
anidb_title = excluded.anidb_title,
media_type = excluded.media_type,
auto_download_mode = excluded.auto_download_mode,
auto_download_quality = excluded.auto_download_quality,
auto_download_name = excluded.auto_download_name,
auto_download_source_name = excluded.auto_download_source_name,
auto_download_series = excluded.auto_download_series,
auto_download_offset = excluded.auto_download_offset,
downloaded_sub_episodes_json = excluded.downloaded_sub_episodes_json,
downloaded_dub_episodes_json = excluded.downloaded_dub_episodes_json,
thumbnail_path = excluded.thumbnail_path,
thumbnail_checked_at = excluded.thumbnail_checked_at
""",
(
item["show_id"],
normalize_provider(item.get("provider"), provider_bridge.parse_provider_id(item.get("show_id"), "anikoto")[0]),
item["title"],
normalize_watchlist_category(item.get("category")),
1 if item.get("downloaded") else 0,
item["status"],
item["status_message"],
item["created_at"],
item["updated_at"],
item["last_checked"],
int(item.get("sub_count") or 0),
int(item.get("dub_count") or 0),
int(item.get("expected_count") or 0),
item.get("airing_status"),
item.get("sub_latest_episode"),
item.get("dub_latest_episode"),
item.get("animeschedule_route"),
item.get("animeschedule_title"),
item.get("anilist_id"),
item.get("anilist_title"),
item.get("anilist_site_url"),
item.get("alternative_titles_json"),
item.get("anidb_aid"),
item.get("anidb_title"),
normalize_media_type(item.get("media_type")),
item.get("auto_download_mode"),
item.get("auto_download_quality"),
item.get("auto_download_name"),
str(item.get("auto_download_source_name") or "").strip() or str(item["title"]).strip(),
item.get("auto_download_series"),
normalize_episode_offset(item.get("auto_download_offset")),
item.get("downloaded_sub_episodes_json"),
item.get("downloaded_dub_episodes_json"),
item.get("thumbnail_path"),
item.get("thumbnail_checked_at"),
),
)
def _migrate_legacy_json(self):
if not WATCHLIST_JSON_PATH.exists():
return
legacy = load_json(WATCHLIST_JSON_PATH, {})
if not legacy:
WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated"))
return
aggregated = {}
for value in legacy.values():
if not isinstance(value, dict):
continue
show_id = str(value.get("show_id") or "").strip()
if not show_id:
continue
mode = str(value.get("mode") or "").strip().lower()
if mode not in MODE_CHOICES:
continue
item = aggregated.setdefault(
show_id,
{
"show_id": show_id,
"title": str(value.get("title") or "Unknown Anime").strip() or "Unknown Anime",
"category": "watching",
"downloaded": False,
"status": str(value.get("status") or "tracked"),
"status_message": str(value.get("status_message") or "Migrated from legacy watchlist."),
"created_at": str(value.get("added_at") or now_iso()),
"updated_at": now_iso(),
"last_checked": value.get("last_checked"),
"sub_count": 0,
"dub_count": 0,
"expected_count": 0,
"airing_status": None,
"sub_latest_episode": None,
"dub_latest_episode": None,
"animeschedule_route": None,
"animeschedule_title": None,
"anilist_id": None,
"anilist_title": None,
"anilist_site_url": None,
"alternative_titles_json": None,
"anidb_aid": None,
"anidb_title": None,
"media_type": "tv",
"auto_download_mode": None,
"auto_download_quality": None,
"auto_download_name": None,
"auto_download_source_name": None,
"auto_download_series": None,
"auto_download_offset": None,
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
},
)
episodes = value.get("episodes_available") if isinstance(value.get("episodes_available"), list) else []
count = len(episodes)
latest = episodes[-1] if episodes else None
if mode == "sub":
item["sub_count"] = max(item["sub_count"], count)
item["sub_latest_episode"] = latest or item["sub_latest_episode"]
else:
item["dub_count"] = max(item["dub_count"], count)
item["dub_latest_episode"] = latest or item["dub_latest_episode"]
if item["title"] == "Unknown Anime" and str(value.get("title") or "").strip():
item["title"] = str(value.get("title")).strip()
with self.lock, self._connect() as conn:
for item in aggregated.values():
self._upsert_conn(conn, item)
WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated"))
def list(self, page=1, per_page=30, category=None):
page = max(1, int(page or 1))
per_page = min(60, max(1, int(per_page or 30)))
active_category = normalize_watchlist_category(category) if category else None
with self.lock, self._connect() as conn:
overall_total = conn.execute("SELECT COUNT(*) AS count FROM watchlist").fetchone()["count"]
if active_category:
total = conn.execute(
"SELECT COUNT(*) AS count FROM watchlist WHERE category = ?",
(active_category,),
).fetchone()["count"]
else:
total = conn.execute("SELECT COUNT(*) AS count FROM watchlist").fetchone()["count"]
totals = conn.execute(
"""
SELECT
COALESCE(SUM(sub_count), 0) AS sub_total,
COALESCE(SUM(dub_count), 0) AS dub_total
FROM watchlist
"""
).fetchone()
category_rows = conn.execute(
"SELECT category, COUNT(*) AS count FROM watchlist GROUP BY category"
).fetchall()
queued_total = conn.execute("SELECT COUNT(*) AS count FROM watchlist WHERE status = 'queued'").fetchone()["count"]
pages = max(1, (total + per_page - 1) // per_page)
page = min(page, pages)
offset = (page - 1) * per_page
if active_category:
rows = conn.execute(
"SELECT * FROM watchlist WHERE category = ? ORDER BY title COLLATE NOCASE ASC LIMIT ? OFFSET ?",
(active_category, per_page, offset),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC LIMIT ? OFFSET ?",
(per_page, offset),
).fetchall()
items = [self._row_to_item(row) for row in rows]
category_counts = {key: 0 for key in WATCHLIST_CATEGORY_CHOICES}
for row in category_rows:
category_counts[normalize_watchlist_category(row["category"])] = int(row["count"] or 0)
return {
"items": items,
"total": total,
"page": page,
"pages": pages,
"per_page": per_page,
"active_category": active_category,
"active_category_label": watchlist_category_label(active_category) if active_category else "",
"categories": category_counts,
"queued_count": int(queued_total or 0),
"summary": {
"shows": int(overall_total or 0),
"sub": int(totals["sub_total"] or 0),
"dub": int(totals["dub_total"] or 0),
},
}
def homepage_summary(self):
with self.lock, self._connect() as conn:
rows = conn.execute(
"SELECT category, COUNT(*) AS count FROM watchlist GROUP BY category"
).fetchall()
counts = {key: 0 for key in WATCHLIST_CATEGORY_CHOICES}
for row in rows:
counts[normalize_watchlist_category(row["category"])] = int(row["count"] or 0)
total = sum(counts.values())
return {
"watchlist": counts["watching"],
"planned": counts["planned"],
"finished": counts["finished"],
"dropped": counts["dropped"],
"total": total,
}
def get(self, show_id):
with self.lock, self._connect() as conn:
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (str(show_id).strip(),)).fetchone()
if not row:
raise KeyError("Anime not found in watchlist.")
return self._row_to_item(row)
def all_show_ids(self, categories=None):
normalized_categories = []
for category in categories or []:
normalized = normalize_watchlist_category(category)
if normalized not in normalized_categories:
normalized_categories.append(normalized)
with self.lock, self._connect() as conn:
if normalized_categories:
placeholders = ", ".join("?" for _ in normalized_categories)
rows = conn.execute(
f"SELECT show_id FROM watchlist WHERE category IN ({placeholders}) ORDER BY title COLLATE NOCASE ASC",
tuple(normalized_categories),
).fetchall()
else:
rows = conn.execute("SELECT show_id FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
return [str(row["show_id"]).strip() for row in rows if str(row["show_id"]).strip()]
def add(self, payload):
show_id = str(payload.get("show_id") or "").strip()
title = str(payload.get("title") or "Unknown Anime").strip() or "Unknown Anime"
if not show_id:
raise ValueError("Missing show_id.")
provider = normalize_provider(payload.get("provider"), provider_bridge.parse_provider_id(show_id, "anikoto")[0])
with self.lock, self._connect() as conn:
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (show_id,)).fetchone()
if row:
item = self._row_to_item(row)
return {"message": f"Already tracking {item['title']}.", "item": item, "created": False}
now = now_iso()
item = {
**self._auto_download_defaults(title),
"show_id": show_id,
"provider": provider,
"title": title,
"category": normalize_watchlist_category(payload.get("category")),
"downloaded": False,
"status": "queued",
"status_message": "Queued initial watchlist refresh.",
"created_at": now,
"updated_at": now,
"last_checked": None,
"sub_count": 0,
"dub_count": 0,
"expected_count": 0,
"airing_status": None,
"sub_latest_episode": None,
"dub_latest_episode": None,
"animeschedule_route": None,
"animeschedule_title": None,
"anilist_id": None,
"anilist_title": None,
"anilist_site_url": None,
"alternative_titles_json": None,
"anidb_aid": None,
"anidb_title": None,
"media_type": normalize_media_type(payload.get("media_type")),
"thumbnail_path": None,
"thumbnail_checked_at": None,
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": None,
}
requested_series = payload.get("auto_download_series")
if requested_series is None or str(requested_series).strip() == "":
requested_series = item.get("auto_download_series")
item["auto_download_series"] = normalize_season(requested_series)
requested_mode = str(payload.get("auto_download_mode") or item.get("auto_download_mode") or "").strip().lower()
if requested_mode in MODE_CHOICES:
item["auto_download_mode"] = requested_mode
item["auto_download_offset"] = normalize_episode_offset(payload.get("auto_download_offset"))
item["auto_download_source_name"] = (
str(payload.get("auto_download_source_name") or item.get("auto_download_source_name") or title).strip() or title
)
self._upsert_conn(conn, item)
self._clear_removal_conn(conn, show_id)
queued = self.schedule_refresh(show_id, source="initial")
return {"message": f"Added {queued['title']} to the watchlist. Refresh queued.", "item": queued, "created": True}
def refresh(self, show_id, include_thumbnail=True, refresh_source="manual"):
existing = self.get(show_id)
normalized_show_id = existing["show_id"]
previous_sub_count = int(existing.get("sub_count") or 0)
previous_dub_count = int(existing.get("dub_count") or 0)
previous_last_checked = existing.get("last_checked")
now = now_iso()
request_timeout = self._refresh_request_timeout()
try:
snapshot = fetch_show_episode_snapshot(normalized_show_id, timeout=request_timeout)
if self.refresh_stop_event.is_set():
return None
if not self.has_show(normalized_show_id):
return None
sub_episodes = snapshot["episodes"]["sub"]
dub_episodes = snapshot["episodes"]["dub"]
schedule = None
try:
schedule = resolve_animeschedule_anime(
snapshot["title"] or existing["title"],
route=existing.get("animeschedule_route"),
timeout=request_timeout,
)
except Exception:
schedule = None
anidb_match = None
try:
if existing.get("anidb_aid"):
anidb_match = {
"aid": str(existing.get("anidb_aid")).strip(),
"title": str(existing.get("anidb_title") or snapshot["title"] or existing["title"]).strip(),
}
else:
anidb_match = resolve_anidb_aid(snapshot["title"] or existing["title"])
except Exception:
anidb_match = None
if self.refresh_stop_event.is_set():
return None
if not self.has_show(normalized_show_id):
return None
expected_count = parse_nonnegative_int((schedule or {}).get("episodes")) or int(existing.get("expected_count") or 0)
airing_status = normalize_animeschedule_status((schedule or {}).get("status") or existing.get("airing_status"))
alternative_titles = extract_anidb_alternative_titles(
(anidb_match or {}).get("aid"),
primary_title=snapshot["title"] or existing["title"],
)
payload = (
snapshot["title"] or existing["title"],
"updated",
watchlist_status_message(
len(sub_episodes),
len(dub_episodes),
expected_count=expected_count,
airing_status=airing_status,
),
now,
now,
len(sub_episodes),
len(dub_episodes),
expected_count,
airing_status,
sub_episodes[-1] if sub_episodes else None,
dub_episodes[-1] if dub_episodes else None,
(schedule or {}).get("route") or existing.get("animeschedule_route"),
(schedule or {}).get("title") or existing.get("animeschedule_title"),
existing.get("anilist_id"),
existing.get("anilist_title"),
existing.get("anilist_site_url"),
encode_alternative_titles(alternative_titles) if anidb_match is not None else existing.get("alternative_titles_json"),
(anidb_match or {}).get("aid") or existing.get("anidb_aid"),
(anidb_match or {}).get("title") or existing.get("anidb_title"),
normalized_show_id,
)
except Exception as exc:
if self.refresh_stop_event.is_set():
return None
if not self.has_show(normalized_show_id):
return None
payload = (
existing["title"],
"error",
f"Could not refresh episodes: {exc}",
now,
now,
existing["sub_count"],
existing["dub_count"],
existing.get("expected_count"),
existing.get("airing_status"),
existing.get("sub_latest_episode"),
existing.get("dub_latest_episode"),
existing.get("animeschedule_route"),
existing.get("animeschedule_title"),
existing.get("anilist_id"),
existing.get("anilist_title"),
existing.get("anilist_site_url"),
existing.get("alternative_titles_json"),
existing.get("anidb_aid"),
existing.get("anidb_title"),
normalized_show_id,
)
with self.lock, self._connect() as conn:
if self.refresh_stop_event.is_set():
return None
cursor = conn.execute(
"""
UPDATE watchlist
SET
title = ?,
status = ?,
status_message = ?,
updated_at = ?,
last_checked = ?,
sub_count = ?,
dub_count = ?,
expected_count = ?,
airing_status = ?,
sub_latest_episode = ?,
dub_latest_episode = ?,
animeschedule_route = ?,
animeschedule_title = ?,
anilist_id = ?,
anilist_title = ?,
anilist_site_url = ?,
alternative_titles_json = ?,
anidb_aid = ?,
anidb_title = ?
WHERE show_id = ?
""",
payload,
)
if cursor.rowcount < 1:
return None
if self.refresh_stop_event.is_set():
return None
if include_thumbnail and self.has_show(normalized_show_id):
try:
self.ensure_thumbnail(normalized_show_id)
except Exception as exc:
debug_log("watchlist.refresh.thumbnail_failed", show_id=normalized_show_id, error=exc)
item = self.get(normalized_show_id)
item["previous_sub_count"] = previous_sub_count
item["previous_dub_count"] = previous_dub_count
item["previous_last_checked"] = previous_last_checked
item["sub_delta"] = max(0, int(item.get("sub_count") or 0) - previous_sub_count)
item["dub_delta"] = max(0, int(item.get("dub_count") or 0) - previous_dub_count)
item["had_new_episodes"] = bool(item["sub_delta"] or item["dub_delta"])
item["sub_episode_values"] = sub_episodes if 'sub_episodes' in locals() else []
item["dub_episode_values"] = dub_episodes if 'dub_episodes' in locals() else []
if self.auto_download_fn is not None:
try:
item["auto_download_result"] = self.auto_download_fn(item, refresh_source=refresh_source)
except Exception as exc:
debug_log("watchlist.auto_download.error", show_id=normalized_show_id, source=refresh_source, error=exc)
if self.jellyfin_sync_fn is not None:
try:
item["jellyfin_sync_result"] = self.jellyfin_sync_fn(item, automatic=True)
except Exception as exc:
debug_log("watchlist.jellyfin_sync.error", show_id=normalized_show_id, source=refresh_source, error=exc)
return item
def refresh_all(self):
refreshed = []
for show_id in self.all_show_ids(categories=("watching", "planned")):
item = self.refresh(show_id, include_thumbnail=False)
if item is not None:
refreshed.append(item)
return {"count": len(refreshed), "items": refreshed, "message": f"Refreshed {len(refreshed)} watchlist entries."}
def update_category(self, show_id, category):
existing = self.get(show_id)
now = now_iso()
with self.lock, self._connect() as conn:
conn.execute(
"UPDATE watchlist SET category = ?, updated_at = ? WHERE show_id = ?",
(normalize_watchlist_category(category), now, existing["show_id"]),
)
return self.get(show_id)
def update_media_type(self, show_id, media_type):
existing = self.get(show_id)
normalized_media_type = normalize_media_type(media_type)
with self.lock, self._connect() as conn:
conn.execute(
"UPDATE watchlist SET media_type = ?, updated_at = ? WHERE show_id = ?",
(normalized_media_type, now_iso(), existing["show_id"]),
)
return self.get(show_id)
def queue_refresh(self, show_id, status_message="Queued watchlist refresh.", source="manual"):
existing = self.get(show_id)
now = now_iso()
with self.lock, self._connect() as conn:
conn.execute(
"UPDATE watchlist SET status = 'queued', status_message = ?, updated_at = ? WHERE show_id = ?",
(str(status_message or "Queued watchlist refresh.").strip(), now, existing["show_id"]),
)
return self.schedule_refresh(existing["show_id"], source=source)
def reconcile_downloaded_filesystem(self, config=None):
active_config = normalize_config(config or self.config_getter() or {})
items = []
changed = 0
with self.lock, self._connect() as conn:
rows = conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
for row in rows:
existing_item = self._row_to_item(row)
media_type = normalize_media_type(existing_item.get("media_type"))
if media_type == "movie":
source_dir = watchlist_source_library_dir(existing_item, active_config)
has_files = source_dir.exists() and any(path.is_file() for path in source_dir.rglob("*"))
new_sub_values = []
new_dub_values = []
downloaded = has_files
else:
filesystem_values = watchlist_downloaded_episode_values_from_filesystem(existing_item, active_config, "dub")
if filesystem_values:
preferred_mode = watchlist_preferred_completion_mode(existing_item)
new_sub_values = list(existing_item.get("downloaded_sub_episodes") or [])
new_dub_values = list(existing_item.get("downloaded_dub_episodes") or [])
if preferred_mode == "sub":
new_sub_values = filesystem_values
else:
new_dub_values = filesystem_values
downloaded = bool(new_sub_values or new_dub_values)
else:
new_sub_values = []
new_dub_values = []
downloaded = False
old_sub_values = existing_item.get("downloaded_sub_episodes") or []
old_dub_values = existing_item.get("downloaded_dub_episodes") or []
sub_added = [value for value in new_sub_values if value not in old_sub_values]
sub_removed = [value for value in old_sub_values if value not in new_sub_values]
dub_added = [value for value in new_dub_values if value not in old_dub_values]
dub_removed = [value for value in old_dub_values if value not in new_dub_values]
downloaded_changed = bool(existing_item.get("downloaded")) != downloaded
row_changed = bool(sub_added or sub_removed or dub_added or dub_removed or downloaded_changed)
if row_changed:
conn.execute(
"""
UPDATE watchlist
SET downloaded = ?, downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ?, updated_at = ?
WHERE show_id = ?
""",
(
1 if downloaded else 0,
encode_episode_values(new_sub_values) if new_sub_values else None,
encode_episode_values(new_dub_values) if new_dub_values else None,
now_iso(),
existing_item["show_id"],
),
)
changed += 1
items.append(
{
"show_id": existing_item.get("show_id"),
"title": existing_item.get("title"),
"media_type": media_type,
"changed": row_changed,
"downloaded": downloaded,
"sub_added": sub_added,
"sub_removed": sub_removed,
"dub_added": dub_added,
"dub_removed": dub_removed,
}
)
return {
"message": f"Reconciled downloaded watchlist files for {len(items)} entr{'y' if len(items) == 1 else 'ies'}."
+ (f" Updated {changed}." if changed else " No changes were needed."),
"total": len(items),
"changed": changed,
"items": items,
}
def export_backup(self):
with self.lock, self._connect() as conn:
watchlist_columns = self._table_columns_conn(conn, "watchlist")
removal_columns = self._table_columns_conn(conn, "watchlist_removals")
watchlist_rows = [
{column: row[column] for column in watchlist_columns}
for row in conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
]
removal_rows = [
{column: row[column] for column in removal_columns}
for row in conn.execute("SELECT * FROM watchlist_removals ORDER BY removed_at DESC").fetchall()
]
return {
"format": "kaizoku-watchlist-backup",
"format_version": WATCHLIST_BACKUP_FORMAT_VERSION,
"app_version": VERSION,
"exported_at": now_iso(),
"tables": {
"watchlist": {
"columns": watchlist_columns,
"rows": watchlist_rows,
},
"watchlist_removals": {
"columns": removal_columns,
"rows": removal_rows,
},
},
}
def import_backup(self, backup):
if not isinstance(backup, dict):
raise ValueError("Watchlist backup must be a JSON object.")
if backup.get("format") != "kaizoku-watchlist-backup":
raise ValueError("Backup format is not recognized.")
try:
format_version = int(backup.get("format_version") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("Backup format version is invalid.") from exc
if format_version < 1 or format_version > WATCHLIST_BACKUP_FORMAT_VERSION:
raise ValueError("Backup format version is not supported by this app version.")
tables = backup.get("tables")
if not isinstance(tables, dict):
raise ValueError("Backup is missing table data.")
watchlist_rows = ((tables.get("watchlist") or {}).get("rows") if isinstance(tables.get("watchlist"), dict) else None)
removal_rows = (
(tables.get("watchlist_removals") or {}).get("rows")
if isinstance(tables.get("watchlist_removals"), dict)
else []
)
if not isinstance(watchlist_rows, list):
raise ValueError("Backup is missing watchlist rows.")
if not isinstance(removal_rows, list):
raise ValueError("Backup removal rows must be an array.")
now = now_iso()
with self.lock, self._connect() as conn:
watchlist_columns = self._table_columns_conn(conn, "watchlist")
removal_columns = self._table_columns_conn(conn, "watchlist_removals")
conn.execute("DELETE FROM watchlist")
conn.execute("DELETE FROM watchlist_removals")
imported_watchlist = 0
for row in watchlist_rows:
if not isinstance(row, dict):
raise ValueError("Backup watchlist rows must be JSON objects.")
show_id = str(row.get("show_id") or "").strip()
title = str(row.get("title") or "").strip()
if not show_id or not title:
raise ValueError("Backup watchlist rows must include show_id and title.")
values = {column: row.get(column) for column in watchlist_columns}
values["show_id"] = show_id
values["title"] = title
values["category"] = normalize_watchlist_category(values.get("category"))
values["downloaded"] = 1 if values.get("downloaded") else 0
values["status"] = str(values.get("status") or "tracked")
values["status_message"] = str(values.get("status_message") or "Imported from backup.")
values["created_at"] = str(values.get("created_at") or now)
values["updated_at"] = str(values.get("updated_at") or now)
values["sub_count"] = int(values.get("sub_count") or 0)
values["dub_count"] = int(values.get("dub_count") or 0)
values["expected_count"] = int(values.get("expected_count") or 0)
values["media_type"] = normalize_media_type(values.get("media_type"))
placeholders = ", ".join("?" for _column in watchlist_columns)
column_sql = ", ".join(watchlist_columns)
conn.execute(
f"INSERT INTO watchlist ({column_sql}) VALUES ({placeholders})",
[values.get(column) for column in watchlist_columns],
)
imported_watchlist += 1
imported_removals = 0
for row in removal_rows:
if not isinstance(row, dict):
raise ValueError("Backup removal rows must be JSON objects.")
show_id = str(row.get("show_id") or "").strip()
if not show_id:
continue
values = {column: row.get(column) for column in removal_columns}
values["show_id"] = show_id
values["title"] = str(values.get("title") or "").strip()
values["removed_at"] = str(values.get("removed_at") or now)
placeholders = ", ".join("?" for _column in removal_columns)
column_sql = ", ".join(removal_columns)
conn.execute(
f"INSERT INTO watchlist_removals ({column_sql}) VALUES ({placeholders})",
[values.get(column) for column in removal_columns],
)
imported_removals += 1
return {
"message": f"Imported {imported_watchlist} watchlist entr{'y' if imported_watchlist == 1 else 'ies'} from backup.",
"imported": imported_watchlist,
"removals": imported_removals,
}
def mark_downloaded(self, show_id, title="", mode="", episodes="", media_type=""):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
raise ValueError("Missing show_id for watchlist download sync.")
normalized_title = str(title or "").strip()
normalized_mode = str(mode or "").strip().lower()
normalized_media_type = normalize_media_type(media_type or "tv")
episode_spec = str(episodes or "").strip()
now = now_iso()
downloaded_episode_values = []
if normalized_mode in MODE_CHOICES and episode_spec:
try:
available_episodes = episode_list(normalized_show_id, normalized_mode)
except Exception:
available_episodes = []
downloaded_episode_values = expand_episode_spec_against_available(episode_spec, available_episodes)
with self.lock, self._connect() as conn:
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (normalized_show_id,)).fetchone()
if row:
existing_item = self._row_to_item(row)
sub_payload = existing_item.get("downloaded_sub_episodes_json")
dub_payload = existing_item.get("downloaded_dub_episodes_json")
if normalized_mode == "sub" and downloaded_episode_values:
sub_payload = encode_episode_values(existing_item.get("downloaded_sub_episodes", []) + downloaded_episode_values)
elif normalized_mode == "dub" and downloaded_episode_values:
dub_payload = encode_episode_values(existing_item.get("downloaded_dub_episodes", []) + downloaded_episode_values)
completed_item = dict(existing_item)
completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload)
completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload)
if normalize_media_type(existing_item.get("media_type")) == "movie":
is_complete = True
else:
is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode)
target_category = "finished" if is_complete else existing_item["category"]
conn.execute(
"""
UPDATE watchlist
SET category = ?, downloaded = 1, status = 'queued', status_message = ?, updated_at = ?,
downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ?
WHERE show_id = ?
""",
(target_category, "Queued refresh after successful download.", now, sub_payload, dub_payload, normalized_show_id),
)
else:
if self._was_removed_conn(conn, normalized_show_id):
return None
matched_row = self._find_download_title_match_conn(conn, normalized_title, exclude_show_id=normalized_show_id)
if matched_row is not None:
matched_item = self._row_to_item(matched_row)
previous_show_id = str(matched_row["show_id"]).strip()
resolved_title = normalized_title or str(matched_row["title"] or "").strip() or "Unknown Anime"
sub_payload = matched_item.get("downloaded_sub_episodes_json")
dub_payload = matched_item.get("downloaded_dub_episodes_json")
if normalized_mode == "sub" and downloaded_episode_values:
sub_payload = encode_episode_values(matched_item.get("downloaded_sub_episodes", []) + downloaded_episode_values)
elif normalized_mode == "dub" and downloaded_episode_values:
dub_payload = encode_episode_values(matched_item.get("downloaded_dub_episodes", []) + downloaded_episode_values)
completed_item = dict(matched_item)
completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload)
completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload)
if normalize_media_type(matched_item.get("media_type")) == "movie":
is_complete = True
else:
is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode)
target_category = "finished" if is_complete else matched_item["category"]
conn.execute(
"""
UPDATE watchlist
SET
show_id = ?,
title = ?,
category = ?,
downloaded = 1,
status = 'queued',
status_message = ?,
updated_at = ?,
downloaded_sub_episodes_json = ?,
downloaded_dub_episodes_json = ?
WHERE show_id = ?
""",
(
normalized_show_id,
resolved_title,
target_category,
"Queued refresh after successful download.",
now,
sub_payload,
dub_payload,
previous_show_id,
),
)
if previous_show_id != normalized_show_id:
event = self.thumbnail_events.pop(previous_show_id, None)
if event is not None:
self.thumbnail_events[normalized_show_id] = event
self._drop_refresh_locked(previous_show_id)
else:
existing = {
**self._auto_download_defaults(normalized_title or "Unknown Anime"),
"show_id": normalized_show_id,
"title": normalized_title or "Unknown Anime",
"category": "finished",
"downloaded": True,
"status": "queued",
"status_message": "Queued refresh after successful download.",
"created_at": now,
"updated_at": now,
"last_checked": None,
"sub_count": 0,
"dub_count": 0,
"expected_count": 0,
"airing_status": None,
"sub_latest_episode": None,
"dub_latest_episode": None,
"animeschedule_route": None,
"animeschedule_title": None,
"anilist_id": None,
"anilist_title": None,
"anilist_site_url": None,
"alternative_titles_json": None,
"anidb_aid": None,
"anidb_title": None,
"media_type": normalized_media_type,
"auto_download_source_name": None,
"auto_download_series": None,
"downloaded_sub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "sub" else None,
"downloaded_dub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "dub" else None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
self._upsert_conn(conn, existing)
self.schedule_refresh(normalized_show_id, source="sync")
return self.get(normalized_show_id)
def update_auto_download_settings(self, show_id, mode=None, quality=None, name=None, source_name=None, series=None, episode_offset=None):
existing = self.get(show_id)
defaults = self._auto_download_defaults(existing["title"])
normalized_mode = str(mode or defaults["auto_download_mode"]).strip().lower()
normalized_quality = str(quality or defaults["auto_download_quality"]).strip().lower()
if normalized_mode not in MODE_CHOICES:
raise ValueError("Auto-download mode must be sub or dub.")
if normalized_quality not in QUALITY_CHOICES:
raise ValueError("Unknown auto-download quality.")
normalized_name = sanitize_path_component(name or defaults["auto_download_name"], defaults["auto_download_name"])
normalized_source_name = str(source_name or existing.get("auto_download_source_name") or defaults["auto_download_source_name"]).strip()
if not normalized_source_name:
normalized_source_name = defaults["auto_download_source_name"]
requested_series = series
if requested_series is None or str(requested_series).strip() == "":
requested_series = existing.get("auto_download_series")
normalized_series = normalize_season(requested_series)
normalized_offset = (
normalize_episode_offset(existing.get("auto_download_offset"))
if episode_offset is None
else normalize_episode_offset(episode_offset)
)
with self.lock, self._connect() as conn:
conn.execute(
"""
UPDATE watchlist
SET auto_download_mode = ?, auto_download_quality = ?, auto_download_name = ?, auto_download_source_name = ?, auto_download_series = ?, auto_download_offset = ?, updated_at = ?
WHERE show_id = ?
""",
(
normalized_mode,
normalized_quality,
normalized_name,
normalized_source_name,
normalized_series,
normalized_offset,
now_iso(),
existing["show_id"],
),
)
return self.get(show_id)
def remove(self, show_id):
existing = self.get(show_id)
thumb_path = thumbnail_file_path(existing.get("thumbnail_path"))
with self.lock, self._connect() as conn:
cursor = conn.execute("DELETE FROM watchlist WHERE show_id = ?", (str(show_id).strip(),))
if cursor.rowcount < 1:
raise KeyError("Anime not found in watchlist.")
self._remember_removal_conn(conn, existing["show_id"], existing["title"])
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 as exc:
debug_log("watchlist.ensure_thumbnails.failed", show_id=show_id, error=exc)
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 get_watchlist_homepage_summary():
ensure_runtime()
return WATCHLIST.homepage_summary()
def update_watchlist_status(show_id, _mode=None):
ensure_runtime()
item = WATCHLIST.queue_refresh(show_id, source="manual")
return {"message": f"Queued refresh for {item['title']}.", "item": item}
def download_watchlist_item(show_id, mode):
runtime = ensure_runtime()
item = runtime["watchlist"].get(show_id)
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub")
query = str(item.get("auto_download_source_name") or item.get("title") or item.get("show_id") or "").strip()
if not query:
raise ValueError("Watchlist item is missing a searchable title.")
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"],
"provider": provider_bridge.parse_provider_id(item["show_id"], runtime["config"].get("provider"))[0],
"query": query,
"title": item["title"],
"anime_name": item.get("auto_download_name") or item["title"],
"media_type": item.get("media_type") or "tv",
"season": normalize_season(item.get("auto_download_series")),
"episode_offset": item.get("auto_download_offset"),
"mode": normalized_mode,
"quality": item.get("auto_download_quality")
or runtime["config"].get("auto_download_quality")
or runtime["config"]["quality"],
"episodes": episode_spec,
"download_dir": runtime["config"]["download_dir"],
}
job = runtime["download_queue"].add(payload)
return {
"message": f"Queued {item['title']} for {normalized_mode} download.",
"job": job,
"item": item,
}
def queue_watchlist_auto_download(item, refresh_source="manual"):
source = str(refresh_source or "manual").strip().lower() or "manual"
if source not in {"manual", "scheduled"}:
return {"queued": False, "reason": f"source:{source}"}
runtime = ensure_runtime()
config = runtime["config"]
if not bool(config.get("auto_download_enabled")):
return {"queued": False, "reason": "config_disabled"}
if normalize_watchlist_category(item.get("category")) != "watching":
return {"queued": False, "reason": "category"}
mode = str(item.get("auto_download_mode") or config.get("auto_download_mode") or "dub").strip().lower()
quality = str(item.get("auto_download_quality") or config.get("auto_download_quality") or "best").strip().lower()
if mode not in MODE_CHOICES:
mode = "dub"
if quality not in QUALITY_CHOICES:
quality = "best"
available_episodes = list(item.get(f"{mode}_episode_values") or [])
downloaded_episodes = set(item.get("downloaded_dub_episodes", []) if mode == "dub" else item.get("downloaded_sub_episodes", []))
if not available_episodes:
return {"queued": False, "reason": "mode_has_no_available_episodes"}
covered = runtime["download_queue"].covered_episodes(item.get("show_id"), mode, available_episodes)
missing = [episode for episode in available_episodes if episode not in downloaded_episodes and episode not in covered]
specs = episode_specs_for_values(missing, available_episodes)
if not specs:
return {"queued": False, "reason": "nothing_missing"}
query = str(item.get("auto_download_source_name") or item.get("title") or item.get("show_id") or "").strip()
if not query:
return {"queued": False, "reason": "missing_title"}
anime_name = sanitize_path_component(
item.get("auto_download_name") or item.get("title") or "Anime",
sanitize_path_component(item.get("title") or "Anime", "Anime"),
)
series = normalize_season(item.get("auto_download_series"))
episode_offset = normalize_episode_offset(item.get("auto_download_offset"))
jobs = []
for episode_spec in specs:
job = runtime["download_queue"].add(
{
"show_id": item["show_id"],
"provider": provider_bridge.parse_provider_id(item["show_id"], config.get("provider"))[0],
"query": query,
"title": item["title"],
"anime_name": anime_name,
"media_type": item.get("media_type") or "tv",
"season": series,
"episode_offset": episode_offset,
"mode": mode,
"quality": quality,
"episodes": episode_spec,
"download_dir": config["download_dir"],
}
)
jobs.append(job)
return {"queued": True, "mode": mode, "quality": quality, "episodes": specs, "jobs": jobs}
def trigger_jellyfin_sync_for_item(item, automatic=False, config=None, progress_fn=None):
active_config = normalize_config(config or get_config_snapshot())
if automatic and not bool(active_config.get("jellyfin_sync_enabled")):
return {"moved": False, "reason": "disabled", "item": item}
ready, reason = watchlist_item_ready_for_jellyfin(item)
if not ready:
return {"moved": False, "reason": reason, "item": item}
media_type = normalize_media_type(item.get("media_type"))
target_key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
if not str(active_config.get(target_key) or "").strip():
result = {"moved": False, "reason": "target_missing", "item": item}
try:
notify_jellyfin_sync_result(item, result, active_config, automatic=automatic)
except Exception as exc:
debug_log("discord_webhook.jellyfin_sync_failed", show_id=item.get("show_id"), reason=result.get("reason"), error=exc)
return result
try:
result = move_watchlist_item_to_jellyfin(item, active_config, progress_fn=progress_fn)
except Exception as exc:
result = {
"moved": False,
"reason": "error",
"item": item,
"source": str(watchlist_source_library_dir(item, active_config)),
"target": str(jellyfin_target_library_dir(item, active_config)),
"error": str(exc),
}
try:
notify_jellyfin_sync_result(item, result, active_config, automatic=automatic)
except Exception as exc:
debug_log("discord_webhook.jellyfin_sync_failed", show_id=item.get("show_id"), reason=result.get("reason"), error=exc)
if result.get("moved"):
debug_log(
"jellyfin.sync.moved",
show_id=item.get("show_id"),
media_type=media_type,
source=result.get("source"),
target=result.get("target"),
automatic=automatic,
)
return result
def run_jellyfin_sync(config=None):
runtime = ensure_runtime()
active_config = normalize_config(config or runtime["config"] or {})
items = []
moved = 0
for show_id in runtime["watchlist"].all_show_ids():
try:
item = runtime["watchlist"].get(show_id)
except KeyError:
continue
result = trigger_jellyfin_sync_for_item(item, automatic=False, config=active_config)
items.append(
{
"show_id": item.get("show_id"),
"title": item.get("title"),
"media_type": item.get("media_type"),
"moved": bool(result.get("moved")),
"reason": result.get("reason"),
"target": result.get("target"),
}
)
if result.get("moved"):
moved += 1
return {
"message": f"Moved {moved} watchlist entr{'y' if moved == 1 else 'ies'} to Jellyfin libraries.",
"moved": moved,
"total": len(items),
"items": items,
}
def start_jellyfin_sync(config=None):
ensure_runtime()
return WATCHLIST_JELLYFIN_SYNC.start(config=config)
def get_jellyfin_sync_status():
ensure_runtime()
return WATCHLIST_JELLYFIN_SYNC.status()
def export_watchlist_backup():
runtime = ensure_runtime()
return runtime["watchlist"].export_backup()
def import_watchlist_backup(payload):
runtime = ensure_runtime()
return runtime["watchlist"].import_backup(payload)
def reconcile_watchlist_downloaded_files(config=None):
runtime = ensure_runtime()
active_config = normalize_config(config or runtime["config"] or {})
return runtime["watchlist"].reconcile_downloaded_filesystem(active_config)
def update_watchlist_category(show_id, category):
ensure_runtime()
item = WATCHLIST.update_category(show_id, category)
return {"message": f"Moved {item['title']} to {item['category_label']}.", "item": item}
def update_watchlist_media_type(show_id, media_type):
ensure_runtime()
item = WATCHLIST.update_media_type(show_id, media_type)
return {"message": f"Saved {item['title']} as {item['media_type_label']}.", "item": item}
def update_watchlist_auto_download(show_id, mode=None, quality=None, name=None, source_name=None, series=None, episode_offset=None):
ensure_runtime()
item = WATCHLIST.update_auto_download_settings(
show_id,
mode=mode,
quality=quality,
name=name,
source_name=source_name,
series=series,
episode_offset=episode_offset,
)
return {"message": f"Saved auto-download settings for {item['title']}.", "item": item}
def update_all_watchlist_statuses():
ensure_runtime()
return WATCHLIST_REFRESH.start()
def get_watchlist_refresh_status():
ensure_runtime()
return WATCHLIST_REFRESH.status()
def remove_from_watchlist(show_id):
runtime = ensure_runtime()
result = WATCHLIST.remove(show_id)
runtime["download_queue"].detach_show(show_id)
return result
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()
if job.get("skip_watchlist_sync"):
return None
show_id, title, reason = resolve_job_watchlist_identity_impl(
job,
search_fn=search_anime,
normalize_title_key_fn=normalize_identity_title_key,
base_title_variants_fn=base_title_variants,
default_mode=(CONFIG or DEFAULT_CONFIG)["mode"],
return_reason=True,
)
if not show_id:
debug_log("watchlist.sync.resolve_failed", job=job, reason=reason)
raise ValueError(f"Could not resolve a watchlist show_id for the completed download: {reason}.")
return WATCHLIST.mark_downloaded(
show_id,
title=title,
mode=job.get("mode"),
episodes=job.get("episodes"),
media_type=job.get("media_type"),
)
def prepare_download_job(job):
config = ensure_runtime()["config"]
if normalize_result_index(job.get("result_index")) is None:
result_index = infer_download_job_result_index(job, config)
if result_index is not None:
job["result_index"] = result_index
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 infer_download_job_result_index(job, config):
query = str(job.get("query") or job.get("title") or job.get("anime_name") or "").strip()
mode = str(job.get("mode") or config.get("mode") or "sub").strip().lower()
if not query or mode not in MODE_CHOICES:
return None
try:
results = search_anime(query, mode)
except Exception as exc:
debug_log("watchlist.prepare_job.result_index_search_failed", job=job, error=exc)
return None
show_id = str(job.get("show_id") or "").strip()
if show_id:
for position, result in enumerate(results, start=1):
if str(result.get("id") or "").strip() == show_id:
return position
title = str(job.get("title") or job.get("anime_name") or query).strip()
matches = []
for position, result in enumerate(results, start=1):
candidate_title = str(result.get("title") or "").strip()
if titles_confidently_match(title, candidate_title, normalize_identity_title_key, base_title_variants):
matches.append(position)
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
debug_log("watchlist.prepare_job.result_index_ambiguous", job=job, matches=matches)
return None
def get_config_snapshot(fallback=None):
with RUNTIME_LOCK:
source = CONFIG if CONFIG is not None else fallback
if source is not None:
return dict(source)
return normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG))
def save_runtime_config(config):
current = get_config_snapshot(fallback=DEFAULT_CONFIG)
merged = dict(current)
if isinstance(config, dict):
merged.update(config)
normalized = normalize_config(merged)
previous_fingerprint = remote_access_session_fingerprint(current)
next_fingerprint = remote_access_session_fingerprint(normalized)
scheduler = None
with RUNTIME_LOCK:
global CONFIG
CONFIG = dict(normalized)
write_json(CONFIG_PATH, CONFIG)
scheduler = WATCHLIST_AUTO_REFRESH
if previous_fingerprint and previous_fingerprint != next_fingerprint:
clear_remote_access_sessions({"fingerprint": previous_fingerprint})
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.")
validate_discord_webhook_url(normalized["discord_webhook_url"])
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,
"watchlist_jellyfin_sync": WATCHLIST_JELLYFIN_SYNC,
}
def get_provider_update_status():
with RUNTIME_LOCK:
return json.loads(json.dumps(PROVIDER_UPDATE_STATUS))
def set_provider_update_status(status):
if not isinstance(status, dict):
return
with RUNTIME_LOCK:
global PROVIDER_UPDATE_STATUS
PROVIDER_UPDATE_STATUS = json.loads(json.dumps(status))
def run_provider_update_check():
try:
set_provider_update_status(provider_updates.check_provider_updates())
except Exception as exc:
debug_log("provider_updates.error", error=exc)
set_provider_update_status(provider_updates.error_status(exc))
def start_provider_update_check():
global PROVIDER_UPDATE_CHECK_THREAD
with RUNTIME_LOCK:
if PROVIDER_UPDATE_CHECK_THREAD is not None and PROVIDER_UPDATE_CHECK_THREAD.is_alive():
return PROVIDER_UPDATE_CHECK_THREAD
PROVIDER_UPDATE_STATUS.update(
provider_updates.default_status("checking", "Checking provider JavaScript for upstream updates...")
)
PROVIDER_UPDATE_CHECK_THREAD = threading.Thread(
target=run_provider_update_check,
name="provider-update-check",
daemon=True,
)
PROVIDER_UPDATE_CHECK_THREAD.start()
return PROVIDER_UPDATE_CHECK_THREAD
def build_runtime(start_workers=None):
ensure_project_app_root()
worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) and background_workers_enabled()
config = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG))
write_json(CONFIG_PATH, config)
watchlist = WatchlistStore(config_getter=lambda: get_config_snapshot(config), start_worker=worker_state)
download_queue = DownloadQueue(
config_getter=lambda: get_config_snapshot(config),
watchlist_sync_fn=sync_downloaded_job_to_watchlist,
job_prepare_fn=prepare_download_job,
start_worker=worker_state,
)
watchlist.auto_download_fn = queue_watchlist_auto_download
watchlist.jellyfin_sync_fn = trigger_jellyfin_sync_for_item
watchlist_refresh = WatchlistRefreshJobs(
watchlist,
config_getter=lambda: get_config_snapshot(config),
start_worker=worker_state,
)
watchlist_jellyfin_sync = JellyfinSyncJobs(
watchlist,
sync_fn=trigger_jellyfin_sync_for_item,
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,
"watchlist_jellyfin_sync": watchlist_jellyfin_sync,
}
def initialize_runtime(start_workers=None):
global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH, WATCHLIST_JELLYFIN_SYNC
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
and WATCHLIST_JELLYFIN_SYNC 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()
WATCHLIST_JELLYFIN_SYNC.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"]
WATCHLIST_JELLYFIN_SYNC = runtime["watchlist_jellyfin_sync"]
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
or WATCHLIST_JELLYFIN_SYNC 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
and WATCHLIST_JELLYFIN_SYNC is None
):
return False
services = (WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH, WATCHLIST_JELLYFIN_SYNC)
watchlist, download_queue, watchlist_refresh, watchlist_auto_refresh, watchlist_jellyfin_sync = 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
if watchlist_jellyfin_sync is not None:
stopped = watchlist_jellyfin_sync.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, WATCHLIST_JELLYFIN_SYNC
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
WATCHLIST_JELLYFIN_SYNC = 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,
export_watchlist_backup=export_watchlist_backup,
get_config_snapshot=get_config_snapshot,
get_provider_update_status=get_provider_update_status,
get_watchlist_homepage_summary=get_watchlist_homepage_summary,
get_jellyfin_sync_status=get_jellyfin_sync_status,
get_watchlist=get_watchlist,
get_watchlist_refresh_status=get_watchlist_refresh_status,
http_error=HttpError,
index_html=INDEX_HTML,
import_watchlist_backup=import_watchlist_backup,
queue_html=QUEUE_HTML,
reconcile_watchlist_downloaded_files=reconcile_watchlist_downloaded_files,
remove_from_watchlist=remove_from_watchlist,
run_jellyfin_sync=run_jellyfin_sync,
runtime_state=runtime_state,
save_runtime_config=save_runtime_config,
search_anime=search_anime,
resolve_search_thumbnail=resolve_search_thumbnail,
test_discord_webhook_config=test_discord_webhook_config,
thumbnail_file_path=thumbnail_file_path,
start_jellyfin_sync=start_jellyfin_sync,
update_all_watchlist_statuses=update_all_watchlist_statuses,
update_watchlist_auto_download=update_watchlist_auto_download,
update_watchlist_category=update_watchlist_category,
update_watchlist_media_type=update_watchlist_media_type,
update_watchlist_status=update_watchlist_status,
upload_watchlist_thumbnail=upload_watchlist_thumbnail,
watchlist_html=WATCHLIST_HTML,
)
)
def parse_runtime_flags(argv):
remaining = []
for arg in argv:
if arg in {"--debug", "---debug"}:
app_support.DEBUG_MODE = True
continue
remaining.append(arg)
return remaining
def main():
parse_runtime_flags(sys.argv[1:])
host = server_host()
port = server_port()
server = ThreadingHTTPServer((host, port), Handler)
start_provider_update_check()
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()