diff --git a/CHANGELOG.md b/CHANGELOG.md index 599430a..ec1188a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,156 @@ # Changelog +## 0.8.13 - 2026-05-12 + +- Added a manual thumbnail upload button for watchlist cards that still have no poster, saving the selected image into the local thumbnail cache for that anime. + +## 0.8.12 - 2026-05-12 + +- Improved thumbnail lookup for season-labeled titles by retrying AnimeSchedule searches with simplified base-title queries before falling back to AniDB. +- Stopped failed thumbnail fetch attempts from refreshing the retry window, so broken lookups do not get stuck in an immediate 404 state after one bad fetch. +- Added extra request headers to AniDB page lookups to improve compatibility with cover-page fetches that were returning `403 Forbidden`. + +## 0.8.11 - 2026-05-12 + +- Added a small per-anime thumbnail reload button on watchlist cards that forces a redownload of that show's cached cover image. + +## 0.8.10 - 2026-05-12 + +- Fixed watchlist source links to use AllManga's `bangumi/` page format instead of the earlier incorrect route pattern. + +## 0.8.9 - 2026-05-12 + +- Changed watchlist anime titles into direct links to their AllManga source pages, using the tracked show ID plus a normalized title slug. + +## 0.8.8 - 2026-05-11 + +- Moved `config.json` into the project-local `.ani-cli-web/` folder so app settings now live alongside the queue database, watchlist data, staging files, and thumbnail cache. +- Added automatic migration from the legacy XDG config location so existing web app settings are preserved on first startup after the move. + +## 0.8.7 - 2026-05-11 + +- Moved all project runtime state that previously lived under `~/.local/state/ani-cli-web/` into the project-local `.ani-cli-web/` folder. +- Added one-time migration for the legacy queue database, queue JSON, watchlist JSON, AniDB title dump, staging directory, and thumbnails so existing local data is carried into the project folder automatically. + +## 0.8.6 - 2026-05-11 + +- Changed watchlist cards to append into the DOM before assigning poster image URLs, avoiding detached-image and lazy-load edge cases during poster rendering. +- Added a per-page thumbnail nonce to the local poster URL so a fresh watchlist load always bypasses stale browser-cached misses for `/api/watchlist/thumb/...`. + +## 0.8.5 - 2026-05-11 + +- Fixed watchlist poster rendering for browser-cached images by attaching thumbnail load and error handlers before setting `img.src`, then immediately showing already-complete images. +- This resolves the case where posters existed on disk and the watchlist route was healthy, but cached images stayed hidden because the browser had already finished loading them before the old listener was attached. + +## 0.8.4 - 2026-05-11 + +- Fixed the watchlist thumbnail renderer to always request the local `/api/watchlist/thumb/...` image route instead of trusting stale `thumbnail_ready` state from older cached entries. +- This lets already-downloaded project-local thumbnails render on the page even when the cached file exists but the watchlist row has not yet been refreshed to mark it ready. + +## 0.8.3 - 2026-05-11 + +- Migrated any legacy watchlist thumbnails from the old state-directory cache into the new project-local `.ani-cli-web/thumbnails/` folder on access. +- Fixed thumbnail retry gating so the app no longer skips refetch attempts when a thumbnail filename exists in the database but the actual cached file is missing on disk. +- Expanded thumbnail debug logging to show whether the expected cached file actually exists before the retry-window decision is made. + +## 0.8.2 - 2026-05-11 + +- Moved watchlist thumbnail storage into the project-local `.ani-cli-web/thumbnails/` folder so cached images can be inspected directly from the repository tree. +- Added debug logs for the local `/api/watchlist/thumb/...` route and file-serving path so thumbnail cache hits, misses, and served files are visible in `--debug` output. + +## 0.8.1 - 2026-05-11 + +- Added a `--debug` runtime flag for `ani-cli-web` and `./ani-cli-web` that logs thumbnail-source request flow, match decisions, responses, cache writes, and failures while fetching watchlist images. +- Added support for the typo-tolerant `---debug` form as well, so debugging still works if that form is used by mistake. + +## 0.8.0 - 2026-05-11 + +- Switched watchlist thumbnails to prefer AnimeSchedule metadata, which `ani-cli` already uses upstream for title-based metadata lookups. +- Stored the resolved AnimeSchedule route and title on watchlist entries so future poster fetches can reuse that stable route instead of repeating a title search every time. +- Restored only a one-shot watchlist thumbnail warm-up request for the visible page, avoiding the old repeated background refresh loop. + +## 0.7.5 - 2026-05-11 + +- Removed automatic watchlist thumbnail warm-up requests after page render so the watchlist no longer keeps making background refresh-style requests while the thumbnail issue is shelved. + +## 0.7.4 - 2026-05-11 + +- Added watchlist removal end-to-end with a new `Remove` button beside `Refresh` and `Open` on each watchlist card. +- Added a watchlist remove API action and cleaned up a card's cached poster file when its watchlist entry is deleted. + +## 0.7.3 - 2026-05-11 + +- Handled browser/client disconnects gracefully in the shared HTTP response writer so abandoned watchlist requests no longer cascade into `BrokenPipeError` tracebacks. +- Ignored disconnect exceptions in the request exception path to avoid attempting a second error response on an already closed socket. + +## 0.7.2 - 2026-05-11 + +- Fixed the AniDB poster extractor to handle relative `/images/main/...` image links in addition to fully qualified CDN URLs. +- Normalized AniDB poster URLs through `urljoin` before downloading, which should unblock entries where AniDB serves the image link off the anime page first and redirects afterward. +- Added an AniDB referer header to image downloads for better compatibility with direct cover fetches. + +## 0.7.1 - 2026-05-11 + +- Stored the matched AniDB `aid` and AniDB title on each watchlist entry so future thumbnail fetches reuse a stable AniDB identity instead of re-searching by name every time. +- Added lightweight AniDB debug hints to watchlist cards via hover text so matched AniDB IDs and titles can be inspected without changing the compact card layout. +- Allowed forced thumbnail retries to rematch by title when a previously stored AniDB ID fails to produce a cover. + +## 0.7.0 - 2026-05-11 + +- Switched watchlist thumbnails from AniList to AniDB. +- Added AniDB title-dump caching for local title-to-anime resolution and AniDB page poster extraction for one-at-a-time cover fetching. +- Kept local thumbnail caching so each watchlist entry only needs its AniDB poster resolved once while it remains tracked. + +## 0.6.2 - 2026-05-11 + +- Changed watchlist thumbnail warm-up to force small sequential retry batches for the currently visible page so previously failed covers can recover immediately. +- Removed the client-side `CSS.escape` dependency from thumbnail repaint logic for broader browser compatibility. +- Kept thumbnail batching capped to avoid reintroducing external source rate-limit spikes. + +## 0.6.1 - 2026-05-11 + +- Changed watchlist thumbnail loading to a controlled batched warm-up flow so missing covers are not requested all at once. +- Added per-show thumbnail request deduplication to avoid duplicate cover fetches while the same image is already being resolved. +- Updated the watchlist client to repaint cards when newly cached thumbnails become available. + +## 0.6.0 - 2026-05-11 + +- Switched the watchlist to paged loading with 30 cards per page so the desktop view can target a 5 by 6 layout without leaving large blank areas. +- Tightened the sidebar again so the main watchlist pane has more room for cards on wide screens. +- Improved thumbnail loading by prefetching missing covers in the background and retrying failed card image requests with a forced refresh. + +## 0.5.0 - 2026-05-11 + +- Reworked the watchlist into a tighter multi-column poster grid that prioritizes title, sub and dub counts, last checked time, and refresh/open actions. +- Added cached external cover lookups for watchlist entries under the app state directory. +- Reduced the watchlist sidebar width so wider screens can fit roughly 4 to 5 anime cards per row. + +## 0.4.0 - 2026-05-11 + +- Reworked the standalone watchlist page to use the same split-panel layout and navigation language as the search page. +- Added watchlist summary cards and richer tracked-show cards so status, episode totals, and search handoff all live in the same visual system. +- Updated the search page navigation styling so both pages now feel like one consistent GUI. + +## 0.3.1 - 2026-05-11 + +- Split the watchlist out to its own standalone `/watchlist` page. +- Kept the main `/` page focused on search, queue management, and downloads. +- Added a watchlist-to-search handoff that pre-fills the search page query. + +## 0.3.0 - 2026-05-11 + +- Moved the watchlist into SQLite with automatic migration from the legacy `watchlist.json` file. +- Changed the watchlist model to track one row per show with separate sub and dub episode counts. +- Added manual refresh actions for one show or the entire watchlist. +- Split the sidebar into switchable Search and Watchlist pages. +- Added direct watchlist tracking from search results. + +## 0.2.2 - 2026-05-11 + +- Fixed a broken watchlist merge that caused `app.py` to fail during startup with an `IndentationError`. +- Restored the web server entrypoint so `./ani-cli-web` starts the local HTTP server again. +- Repaired watchlist POST routes, storage lookups, and embedded page script placement so the watchlist UI can load and update cleanly. + ## 0.2.1 - 2026-05-11 - Padded season folder numbers to two digits, such as `Season 01` and `Season 02`. diff --git a/README.md b/README.md index 2b31083..0a1bd2c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # ani-cli web -A small local web UI for a system-wide `ani-cli` install. +A local web UI for a system-wide `ani-cli` install. -Current version: `0.2.1` +Current version: `0.8.13` ## Project Layout ```text ani-cli-web/ - app.py Web server, queue, search, and post-download library naming. + app.py Web server, queue, watchlist, thumbnail cache, and post-download file naming. ani-cli-web Launcher script. VERSION Project version. CHANGELOG.md Change history. @@ -17,43 +17,153 @@ ani-cli-web/ ## Run +Start the web app with: + ```sh ./ani-cli-web ``` +For thumbnail debugging, run: + +```sh +./ani-cli-web --debug +``` + +The launcher also accepts `---debug`. You can also enable debug logging with `ANI_CLI_WEB_DEBUG=1`. + Then open: ```text http://127.0.0.1:8421/ ``` -The app searches anime, queues downloads, supports original or dubbed mode, quality, episode ranges, editable library names, season numbers, and a default download folder. +By default the server binds to `127.0.0.1:8421`. -The queue is stored in SQLite, shown 10 jobs at a time, and includes bulk actions for removing finished jobs and retrying failed jobs. +## Overview -Downloads are staged in the web app state directory first. After `ani-cli` finishes, the wrapper moves them into a Plex/Jellyfin-friendly layout: +`ani-cli-web` keeps search and download management on `/` and exposes the watchlist on `/watchlist`. + +The app currently includes: + +- Search against AllAnime with `sub` or `dub` mode selection. +- Episode loading for the selected result. +- Queueing downloads with editable library name, season, episode range, quality, and target folder. +- A paged SQLite-backed download queue with retry, cancel, remove, retry-failed, and clear-finished actions. +- A paged SQLite-backed watchlist with summary cards, per-show refresh, bulk refresh, source links, and thumbnail tools. +- Local thumbnail caching plus manual thumbnail upload when automatic cover lookup fails. +- Project-local runtime state under `.ani-cli-web/`. + +## Search Page + +The main page at `/` is where you search and queue downloads. + +Workflow: + +1. Search by anime title or keyword. +2. Pick a result to load available episodes. +3. Adjust library name, season, episode range, quality, and download folder. +4. Add the selected show either to the watchlist or directly to the queue. + +The page also includes: + +- A Defaults panel for the saved download folder, mode, and quality. +- A dependency status panel so you can see which required tools are available. +- A live queue view that refreshes automatically. + +## Download Queue + +The queue is stored in SQLite and shown 10 jobs at a time. + +Queue actions currently available: + +- `Retry` for finished or failed jobs. +- `Cancel` for pending or running jobs. +- `Remove` for completed or failed jobs. +- `Retry failed` for all failed jobs. +- `Remove finished` for completed jobs. + +Downloads are staged inside the project-local app state first. After `ani-cli` finishes, files are moved into a Plex/Jellyfin-friendly layout: ```text ANI_CLI_DOWNLOAD_DIR/anime_name/Season 01/anime_name - S01E01.mp4 ``` -## Watchlist (New) +## Watchlist -A new feature allows tracking anime that is not yet finished airing. +The watchlist stores one row per show in SQLite and keeps separate `sub` and `dub` episode counts. -1. **Add Anime:** Use the "Add to Watchlist" section to track an anime by providing its `show_id` and desired `mode` (sub/dub). -2. **Status Check:** Periodically check the status to see if the anime has finished airing. -3. **Download Trigger:** Once the status is marked as "ready\_to\_download" (indicating completion), you can manually trigger a download job for the entire series. +You can add entries in two ways: -The watchlist data is stored in the application state directory. +1. From the Search page with `Add to watchlist`. +2. Manually on `/watchlist` with a title and AllAnime `show_id`. + +Watchlist features: + +1. `Refresh` updates one tracked show. +2. `Refresh All` updates the entire watchlist. +3. `Open` jumps back to the Search page with the title pre-filled. +4. Clicking the anime title opens the AllManga source page at `bangumi/` in a new tab. +5. `Remove` deletes the watchlist entry and clears its cached poster file. +6. Poster cards use a compact multi-column grid with pagination at 30 cards per page. +7. Summary cards show tracked shows plus total `sub` and `dub` episode counts. + +### Thumbnail Behavior + +Watchlist thumbnails use the local `/api/watchlist/thumb/...` route, not direct remote image URLs. + +Current thumbnail behavior: + +- Cached thumbnails render from the local project cache when available. +- The page adds a fresh nonce to thumbnail URLs to avoid stale browser-cached misses. +- Missing visible covers are warmed up in small batches instead of one request per card all at once. +- AnimeSchedule is tried first for cover lookup. +- If a season-labeled title fails as-is, lookup retries with simplified base-title queries. +- AniDB is used as a fallback when AnimeSchedule does not resolve a cover. +- Failed fetch attempts no longer refresh the retry window, so a bad lookup does not get stuck in an immediate repeat-404 state. + +Per-card thumbnail tools: + +1. `Reload` forces a fresh redownload attempt for that show's poster. +2. `Upload` appears when no thumbnail is currently available and lets you assign a local image manually. +3. Hovering the poster tile or card shows the current thumbnail source hint. + +Manual uploads are saved into the same local thumbnail cache used by automatic cover downloads. + +## Runtime State + +All runtime state is stored under: + +```text +ani-cli-web/.ani-cli-web/ +``` + +That folder currently holds: + +- `config.json` +- `queue.sqlite3` +- legacy `queue.json` migration input when present +- legacy `watchlist.json` migration input when present +- `thumbnails/` +- AniDB title cache data +- staging files for active downloads + +Legacy files from `~/.config/ani-cli-web/` and `~/.local/state/ani-cli-web/` are migrated into the project-local folder automatically on startup. + +## Configuration + +Environment variables: - `ANI_CLI_BIN`: path or command name for `ani-cli`; defaults to `ani-cli` from `PATH`. - `ANI_CLI_DOWNLOAD_DIR`: initial default download folder. - `ANI_CLI_MODE`: initial mode, `sub` or `dub`. -- `ANI_CLI_QUALITY`: initial quality, for example `best`, `1080`, or `720`. +- `ANI_CLI_QUALITY`: initial quality such as `best`, `1080`, or `720`. - `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`. - `ANI_CLI_WEB_PORT`: server port, default `8421`. +- `ANI_CLI_WEB_DEBUG`: enable debug logging when set to `1`, `true`, `yes`, or `on`. -Settings, queue data, and staging files are stored under XDG config/state directories when writable, with a local `.ani-cli-web/` fallback inside this project. Existing `queue.json` data is migrated into SQLite automatically. +Saved defaults from the UI are written into the project-local `config.json`. -Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode cache directories at any depth are ignored by git. +## Notes + +- Watchlist and queue data are stored in the same project-local SQLite database. +- Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode cache directories at any depth are ignored by git. diff --git a/VERSION b/VERSION index 0c62199..c2f73c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 +0.8.13 diff --git a/app.py b/app.py index ec3edbb..3b14cfa 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 +import base64 +import gzip import json +import mimetypes import os import re import shutil @@ -8,30 +11,57 @@ import sqlite3 import subprocess import threading import time +import unicodedata import urllib.error import urllib.request +import xml.etree.ElementTree as ET +from binascii import Error as BinasciiError from datetime import datetime, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import parse_qs, unquote, urlparse +import sys +from urllib.parse import parse_qs, quote, unquote, urljoin, urlparse from uuid import uuid4 PROJECT_ROOT = Path(__file__).resolve().parent ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" APP_NAME = "ani-cli-web" -VERSION = "0.2.1" +VERSION = "0.8.13" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" +ALLMANGA_SITE_BASE = "https://allmanga.to" +ANIMESCHEDULE_API = "https://animeschedule.net/api/v3" +ANIMESCHEDULE_IMAGE_BASE = "https://img.animeschedule.net/production/assets/public/img/" +ANIDB_TITLES_URL = "https://anidb.net/api/anime-titles.xml.gz" +ANIDB_ANIME_PAGE = "https://anidb.net/anime/{aid}" AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" +CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) +DEBUG_MODE = False QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"} MODE_CHOICES = {"sub", "dub"} MAX_LOG_LINES = 240 +def debug_enabled(): + return DEBUG_MODE or str(os.environ.get("ANI_CLI_WEB_DEBUG") or "").strip().lower() in {"1", "true", "yes", "on"} + + +def debug_log(event, **fields): + if not debug_enabled(): + return + parts = [f"[debug] {event}"] + for key, value in fields.items(): + text = str(value) + if len(text) > 240: + text = text[:237] + "..." + parts.append(f"{key}={text}") + print(" | ".join(parts), flush=True) + + def default_download_dir(): if os.environ.get("ANI_CLI_DOWNLOAD_DIR"): return os.environ["ANI_CLI_DOWNLOAD_DIR"] @@ -52,62 +82,99 @@ def now_iso(): return datetime.now(timezone.utc).isoformat() -def app_dir(kind): - if kind == "config": - base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - else: - base = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) - path = base / APP_NAME +PROJECT_APP_ROOT = PROJECT_ROOT / ".ani-cli-web" +PROJECT_APP_ROOT.mkdir(parents=True, exist_ok=True) + + +def legacy_state_root(): + return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / APP_NAME + + +def legacy_config_root(): + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME + + +def project_state_path(*parts): + path = PROJECT_APP_ROOT.joinpath(*parts) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def project_state_dir(*parts): + path = PROJECT_APP_ROOT.joinpath(*parts) + path.mkdir(parents=True, exist_ok=True) + return path + + +def migrate_legacy_file(project_path, legacy_path, label): + if project_path.exists() or not legacy_path.exists(): + return project_path + project_path.parent.mkdir(parents=True, exist_ok=True) try: - path.mkdir(parents=True, exist_ok=True) - probe = path / ".write-test" - probe.write_text("", encoding="utf-8") - probe.unlink() - return path + shutil.move(str(legacy_path), str(project_path)) + debug_log("state.migrated", label=label, from_path=legacy_path, to_path=project_path) + except OSError as exc: + debug_log("state.migrate_failed", label=label, from_path=legacy_path, to_path=project_path, error=exc) + return project_path + + +def migrate_legacy_dir(project_dir, legacy_dir, label): + if not legacy_dir.exists(): + return project_dir + project_dir.mkdir(parents=True, exist_ok=True) + moved = 0 + for child in legacy_dir.iterdir(): + target = project_dir / child.name + if target.exists(): + if child.is_dir() and target.is_dir(): + migrate_legacy_dir(target, child, f"{label}/{child.name}") + continue + try: + shutil.move(str(child), str(target)) + moved += 1 + except OSError as exc: + debug_log("state.migrate_failed", label=label, from_path=child, to_path=target, error=exc) + try: + legacy_dir.rmdir() except OSError: - fallback = PROJECT_ROOT / ".ani-cli-web" / kind - fallback.mkdir(parents=True, exist_ok=True) - return fallback + pass + if moved: + debug_log("state.migrated", label=label, from_path=legacy_dir, to_path=project_dir, entries=moved) + return project_dir -CONFIG_PATH = app_dir("config") / "config.json" -QUEUE_PATH = app_dir("state") / "queue.json" -QUEUE_DB_PATH = app_dir("state") / "queue.sqlite3" -STAGING_ROOT = app_dir("state") / "staging" -WATCHLIST_PATH = app_dir("state") / "watchlist.json" +LEGACY_STATE_ROOT = legacy_state_root() +LEGACY_CONFIG_ROOT = legacy_config_root() +LEGACY_CONFIG_PATH = LEGACY_CONFIG_ROOT / "config.json" +LEGACY_QUEUE_PATH = LEGACY_STATE_ROOT / "queue.json" +LEGACY_QUEUE_DB_PATH = LEGACY_STATE_ROOT / "queue.sqlite3" +LEGACY_STAGING_ROOT = LEGACY_STATE_ROOT / "staging" +LEGACY_WATCHLIST_JSON_PATH = LEGACY_STATE_ROOT / "watchlist.json" +LEGACY_THUMBNAIL_ROOT = LEGACY_STATE_ROOT / "thumbnails" +LEGACY_ANIDB_TITLES_PATH = LEGACY_STATE_ROOT / "anidb-anime-titles.xml.gz" -def load_watchlist(fallback): - try: - with WATCHLIST_PATH.open("r", encoding="utf-8") as handle: - data = json.load(handle) - return data if isinstance(data, type(fallback)) else fallback - except (FileNotFoundError, json.JSONDecodeError, OSError): - return fallback +CONFIG_PATH = project_state_path("config.json") +QUEUE_PATH = project_state_path("queue.json") +QUEUE_DB_PATH = project_state_path("queue.sqlite3") +STAGING_ROOT = project_state_dir("staging") +WATCHLIST_JSON_PATH = project_state_path("watchlist.json") +THUMBNAIL_ROOT = project_state_dir("thumbnails") +THUMBNAIL_RETRY_SECONDS = 15 * 60 +ANIDB_TITLES_PATH = project_state_path("anidb-anime-titles.xml.gz") +ANIDB_TITLES_CACHE = {"mtime": None, "entries": None} -def write_watchlist(data): - tmp = WATCHLIST_PATH.with_suffix(WATCHLIST_PATH.suffix + ".tmp") - with tmp.open("w", encoding="utf-8") as handle: - json.dump(data, handle, indent=2, sort_keys=True) - tmp.replace(WATCHLIST_PATH) -WATCHLIST = load_watchlist(dict()) -WATCHLIST_PATH = app_dir("state") / "watchlist.json" +def migrate_legacy_state_storage(): + migrate_legacy_file(CONFIG_PATH, LEGACY_CONFIG_PATH, "config.json") + migrate_legacy_file(QUEUE_PATH, LEGACY_QUEUE_PATH, "queue.json") + migrate_legacy_file(QUEUE_DB_PATH, LEGACY_QUEUE_DB_PATH, "queue.sqlite3") + migrate_legacy_file(WATCHLIST_JSON_PATH, LEGACY_WATCHLIST_JSON_PATH, "watchlist.json") + migrate_legacy_file(ANIDB_TITLES_PATH, LEGACY_ANIDB_TITLES_PATH, "anidb-anime-titles.xml.gz") + migrate_legacy_dir(STAGING_ROOT, LEGACY_STAGING_ROOT, "staging") + migrate_legacy_dir(THUMBNAIL_ROOT, LEGACY_THUMBNAIL_ROOT, "thumbnails") -def load_watchlist(fallback): - try: - with WATCHLIST_PATH.open("r", encoding="utf-8") as handle: - data = json.load(handle) - return data if isinstance(data, type(fallback)) else fallback - except (FileNotFoundError, json.JSONDecodeError, OSError): - return fallback -def write_watchlist(data): - tmp = WATCHLIST_PATH.with_suffix(WATCHLIST_PATH.suffix + ".tmp") - with tmp.open("w", encoding="utf-8") as handle: - json.dump(data, handle, indent=2, sort_keys=True) - tmp.replace(WATCHLIST_PATH) - -WATCHLIST = load_watchlist(dict()) +migrate_legacy_state_storage() def load_json(path, fallback): @@ -178,6 +245,603 @@ def graph_request(query, variables): return data.get("data", {}) +def parse_iso_timestamp(value): + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + + +def thumbnail_retry_due(checked_at): + timestamp = parse_iso_timestamp(checked_at) + if not timestamp: + return True + age = datetime.now(timezone.utc) - timestamp.astimezone(timezone.utc) + return age.total_seconds() >= THUMBNAIL_RETRY_SECONDS + + +def thumbnail_file_path(name): + filename = Path(str(name or "")).name + if not filename: + return None + project_path = THUMBNAIL_ROOT / filename + if project_path.exists(): + return project_path + legacy_path = LEGACY_THUMBNAIL_ROOT / filename + if legacy_path.exists(): + THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True) + try: + shutil.move(str(legacy_path), str(project_path)) + debug_log("thumbnail.cache.migrated", from_path=legacy_path, to_path=project_path) + return project_path + except OSError as exc: + debug_log("thumbnail.cache.migrate_failed", from_path=legacy_path, to_path=project_path, error=exc) + return legacy_path + return project_path + + +def cover_route(show_id): + return f"/api/watchlist/thumb/{quote(str(show_id).strip(), safe='')}" + + +def anime_source_url(show_id, title): + normalized_show_id = str(show_id or "").strip() + if not normalized_show_id: + return f"{ALLMANGA_SITE_BASE}/search-anime?tr=sub&cty=ALL&query={quote(str(title or '').strip(), safe='')}" + return f"{ALLMANGA_SITE_BASE}/bangumi/{quote(normalized_show_id, safe='')}" + + +def infer_image_extension(url, content_type): + normalized_type = str(content_type or "").split(";", 1)[0].strip().lower() + extension = mimetypes.guess_extension(normalized_type, strict=False) + if extension == ".jpe": + extension = ".jpg" + if extension: + return extension + suffix = Path(urlparse(str(url or "")).path).suffix.lower() + if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}: + return suffix + return ".jpg" + + +def save_thumbnail_bytes(show_id, source_name, content_type, content): + if not content: + raise RuntimeError("Thumbnail content was empty") + THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True) + stem = sanitize_path_component(show_id, "anime") + extension = infer_image_extension(source_name, content_type) + final_path = THUMBNAIL_ROOT / f"{stem}{extension}" + tmp_path = final_path.with_suffix(final_path.suffix + ".tmp") + with tmp_path.open("wb") as handle: + handle.write(content) + tmp_path.replace(final_path) + for existing in THUMBNAIL_ROOT.glob(f"{stem}.*"): + if existing != final_path and existing.is_file(): + existing.unlink(missing_ok=True) + debug_log("thumbnail.cache.write", show_id=show_id, path=final_path, bytes=len(content), content_type=content_type) + return final_path.name + + +def decode_image_data_url(data_url): + text = str(data_url or "").strip() + match = re.match(r"^data:(image/[-a-zA-Z0-9.+]+);base64,(.+)$", text, re.DOTALL) + if not match: + raise ValueError("Thumbnail upload must be a base64 image data URL.") + content_type = match.group(1).strip().lower() + try: + content = base64.b64decode(match.group(2), validate=True) + except (ValueError, BinasciiError) as exc: + raise ValueError("Thumbnail upload data was not valid base64.") from exc + if len(content) > 8 * 1024 * 1024: + raise ValueError("Thumbnail upload is too large. Keep it under 8 MB.") + return content_type, content + + +def normalize_title_key(value): + text = unicodedata.normalize("NFKD", str(value or "")) + text = "".join(char for char in text if not unicodedata.combining(char)) + text = text.lower() + text = re.sub(r"\((19|20)\d{2}\)", " ", text) + text = re.sub(r"[^a-z0-9]+", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def base_title_variants(value): + text = str(value or "").strip() + variants = [] + if text: + variants.append(text) + patterns = [ + r"\s+\((19|20)\d{2}\)\s*$", + r"\s+season\s+\d+\s*$", + r"\s+part\s+\d+\s*$", + r"\s+cour\s+\d+\s*$", + r"\s+\d+(st|nd|rd|th)\s+season\s*$", + ] + changed = True + current = text + while current and changed: + changed = False + for pattern in patterns: + trimmed = re.sub(pattern, "", current, flags=re.IGNORECASE).strip(" -:") + if trimmed and trimmed != current: + current = trimmed + if current not in variants: + variants.append(current) + changed = True + return variants + + +def title_match_variants(value): + variants = set() + for raw_value in base_title_variants(value): + normalized = normalize_title_key(raw_value) + if normalized: + variants.add(normalized) + stripped = re.sub(r"\b(19|20)\d{2}\b", " ", normalized) + stripped = re.sub(r"\s+", " ", stripped).strip() + if stripped: + variants.add(stripped) + return variants + + +def title_lookup_queries(value): + queries = [] + for raw_value in base_title_variants(value): + text = str(raw_value or "").strip() + if text and text not in queries: + queries.append(text) + return queries + + +def animeschedule_request(path, params=None): + params = params or {} + query = "&".join(f"{quote(str(key), safe='')}={quote(str(value), safe='')}" for key, value in params.items()) + url = f"{ANIMESCHEDULE_API}{path}" + if query: + url = f"{url}?{query}" + debug_log("animeschedule.request", url=url, params=params) + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": AGENT, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=25) as response: + raw = response.read().decode("utf-8") + debug_log( + "animeschedule.response", + url=url, + status=getattr(response, "status", "unknown"), + bytes=len(raw.encode("utf-8")), + snippet=raw[:200], + ) + except urllib.error.URLError as exc: + debug_log("animeschedule.error", url=url, error=exc) + raise RuntimeError(f"Could not reach AnimeSchedule: {exc}") from exc + except TimeoutError as exc: + debug_log("animeschedule.timeout", url=url) + raise RuntimeError("AnimeSchedule request timed out") from exc + + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError("AnimeSchedule returned unreadable JSON") from exc + + +def collect_animeschedule_entries(payload): + entries = [] + + def walk(node): + if isinstance(node, dict): + route = str(node.get("route") or "").strip() + image_route = str(node.get("imageVersionRoute") or "").strip() + title = str(node.get("title") or "").strip() + names = node.get("names") if isinstance(node.get("names"), dict) else {} + if route and (title or names): + entries.append( + { + "route": route, + "title": title, + "imageVersionRoute": image_route, + "names": names, + } + ) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(payload) + return entries + + +def animeschedule_title_candidates(entry): + values = [] + if entry.get("title"): + values.append(entry["title"]) + names = entry.get("names") if isinstance(entry.get("names"), dict) else {} + for key in ("english", "romaji", "native", "abbreviation"): + text = str(names.get(key) or "").strip() + if text: + values.append(text) + synonyms = names.get("synonyms") if isinstance(names.get("synonyms"), list) else [] + for value in synonyms: + text = str(value or "").strip() + if text: + values.append(text) + deduped = [] + seen = set() + for value in values: + normalized = normalize_title_key(value) + if normalized and normalized not in seen: + seen.add(normalized) + deduped.append(value) + return deduped + + +def score_animeschedule_match(query_variants, entry): + best = None + for candidate in animeschedule_title_candidates(entry): + normalized = normalize_title_key(candidate) + if not normalized: + continue + score = None + if normalized in query_variants: + score = 110 + else: + for variant in query_variants: + if variant and (normalized in variant or variant in normalized): + score = max(score or 0, 80 - abs(len(normalized) - len(variant))) + if score is not None: + best = max(best or score, score) + return best + + +def fetch_animeschedule_cover_by_route(route, fallback_title=""): + debug_log("thumbnail.source.route_lookup", source="AnimeSchedule", route=route, fallback_title=fallback_title) + payload = animeschedule_request(f"/anime/{quote(str(route).strip(), safe='')}") + if not isinstance(payload, dict): + raise RuntimeError("AnimeSchedule anime response was not an object") + image_route = str(payload.get("imageVersionRoute") or "").strip() + if not image_route: + raise RuntimeError("AnimeSchedule anime did not provide an image route") + debug_log( + "thumbnail.source.route_match", + source="AnimeSchedule", + route=str(payload.get("route") or route).strip(), + image_route=image_route, + title=str(payload.get("title") or fallback_title or "").strip(), + ) + return { + "route": str(payload.get("route") or route).strip(), + "title": str(payload.get("title") or fallback_title or "").strip(), + "url": urljoin(ANIMESCHEDULE_IMAGE_BASE, image_route), + } + + +def fetch_animeschedule_cover(title): + query_variants = title_match_variants(title) + if not query_variants: + raise RuntimeError("Missing title for AnimeSchedule lookup") + queries = title_lookup_queries(title) + debug_log("thumbnail.source.title_lookup", source="AnimeSchedule", title=title, variants=sorted(query_variants), queries=queries) + best = None + candidate_count = 0 + last_error = None + for query in queries: + try: + payload = animeschedule_request("/anime", {"q": query, "st": "alphabetic"}) + except Exception as exc: + last_error = exc + debug_log("thumbnail.source.query_failed", source="AnimeSchedule", title=title, query=query, error=exc) + continue + for entry in collect_animeschedule_entries(payload): + candidate_count += 1 + score = score_animeschedule_match(query_variants, entry) + if score is None: + continue + if best is None or score > best["score"]: + best = { + "route": entry["route"], + "title": next(iter(animeschedule_title_candidates(entry)), entry.get("title") or str(title)), + "imageVersionRoute": str(entry.get("imageVersionRoute") or "").strip(), + "score": score, + } + if best is not None: + break + if not best: + debug_log("thumbnail.source.no_match", source="AnimeSchedule", title=title, candidates=candidate_count, queries=queries) + if last_error is not None: + raise last_error + raise RuntimeError("AnimeSchedule lookup did not find a matching anime") + debug_log( + "thumbnail.source.best_match", + source="AnimeSchedule", + title=title, + route=best["route"], + matched_title=best["title"], + image_route=best["imageVersionRoute"], + score=best["score"], + candidates=candidate_count, + ) + if best["imageVersionRoute"]: + return { + "route": best["route"], + "title": str(best["title"]).strip(), + "url": urljoin(ANIMESCHEDULE_IMAGE_BASE, best["imageVersionRoute"]), + } + return fetch_animeschedule_cover_by_route(best["route"], fallback_title=best["title"]) + + +def anidb_titles_cache_due(): + if not ANIDB_TITLES_PATH.exists(): + return True + age = time.time() - ANIDB_TITLES_PATH.stat().st_mtime + return age >= 24 * 60 * 60 + + +def ensure_anidb_titles_dump(force=False): + if not force and not anidb_titles_cache_due(): + return ANIDB_TITLES_PATH + debug_log("anidb.titles.request", url=ANIDB_TITLES_URL, force=force) + request = urllib.request.Request( + ANIDB_TITLES_URL, + headers={ + "Accept": "application/gzip, application/octet-stream;q=0.9, */*;q=0.5", + "User-Agent": AGENT, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = response.read() + debug_log( + "anidb.titles.response", + status=getattr(response, "status", "unknown"), + bytes=len(payload), + content_type=response.headers.get("Content-Type", ""), + ) + except urllib.error.URLError as exc: + debug_log("anidb.titles.error", error=exc) + if ANIDB_TITLES_PATH.exists(): + return ANIDB_TITLES_PATH + raise RuntimeError(f"Could not download AniDB titles dump: {exc}") from exc + except TimeoutError as exc: + if ANIDB_TITLES_PATH.exists(): + return ANIDB_TITLES_PATH + raise RuntimeError("AniDB titles dump download timed out") from exc + + if not payload: + if ANIDB_TITLES_PATH.exists(): + return ANIDB_TITLES_PATH + raise RuntimeError("AniDB titles dump was empty") + + tmp_path = ANIDB_TITLES_PATH.with_suffix(".xml.gz.tmp") + tmp_path.write_bytes(payload) + tmp_path.replace(ANIDB_TITLES_PATH) + return ANIDB_TITLES_PATH + + +def iter_anidb_titles(): + path = ensure_anidb_titles_dump() + mtime = path.stat().st_mtime if path.exists() else None + if ANIDB_TITLES_CACHE["entries"] is not None and ANIDB_TITLES_CACHE["mtime"] == mtime: + for entry in ANIDB_TITLES_CACHE["entries"]: + yield entry + return + + entries = [] + with gzip.open(path, "rb") as handle: + context = ET.iterparse(handle, events=("end",)) + for _, elem in context: + if elem.tag != "anime": + continue + aid = str(elem.attrib.get("aid") or "").strip() + if not aid: + elem.clear() + continue + for title_elem in elem.findall("title"): + title = str(title_elem.text or "").strip() + if not title: + continue + entries.append( + { + "aid": aid, + "title": title, + "type": str(title_elem.attrib.get("type") or "").strip().lower(), + "lang": str(title_elem.attrib.get("{http://www.w3.org/XML/1998/namespace}lang") or "").strip().lower(), + } + ) + elem.clear() + ANIDB_TITLES_CACHE["mtime"] = mtime + ANIDB_TITLES_CACHE["entries"] = entries + for entry in entries: + yield entry + + +def score_anidb_title_match(query_variants, candidate): + normalized = normalize_title_key(candidate["title"]) + if not normalized: + return None + score = None + if normalized in query_variants: + score = 100 + else: + for variant in query_variants: + if variant and (normalized in variant or variant in normalized): + score = max(score or 0, 72 - abs(len(normalized) - len(variant))) + if score is None: + return None + + type_bonus = { + "official": 12, + "main": 10, + "short": 6, + "syn": 4, + }.get(candidate["type"], 0) + lang_bonus = { + "en": 5, + "x-jat": 4, + "ja": 2, + }.get(candidate["lang"], 0) + return score + type_bonus + lang_bonus + + +def resolve_anidb_aid(title): + query_variants = title_match_variants(title) + if not query_variants: + raise RuntimeError("Missing title for AniDB lookup") + + best = None + candidate_count = 0 + for candidate in iter_anidb_titles(): + candidate_count += 1 + score = score_anidb_title_match(query_variants, candidate) + if score is None: + continue + if best is None or score > best["score"]: + best = { + "aid": candidate["aid"], + "title": candidate["title"], + "score": score, + } + if not best: + debug_log("thumbnail.source.no_match", source="AniDB", title=title, candidates=candidate_count) + raise RuntimeError("AniDB title lookup did not find a matching anime") + debug_log( + "thumbnail.source.best_match", + source="AniDB", + title=title, + aid=best["aid"], + matched_title=best["title"], + score=best["score"], + candidates=candidate_count, + ) + return best + + +def fetch_anidb_cover(title): + match = resolve_anidb_aid(title) + return fetch_anidb_cover_by_aid(match["aid"], fallback_title=match["title"]) + + +def fetch_anidb_cover_by_aid(aid, fallback_title=""): + page_url = ANIDB_ANIME_PAGE.format(aid=aid) + debug_log("thumbnail.source.route_lookup", source="AniDB", aid=aid, fallback_title=fallback_title, page_url=page_url) + request = urllib.request.Request( + page_url, + headers={ + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Referer": "https://anidb.net/", + "User-Agent": AGENT, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=25) as response: + html = response.read().decode("utf-8", errors="replace") + debug_log( + "anidb.page.response", + aid=aid, + status=getattr(response, "status", "unknown"), + bytes=len(html.encode("utf-8")), + snippet=html[:200], + ) + except urllib.error.URLError as exc: + debug_log("anidb.page.error", aid=aid, error=exc) + raise RuntimeError(f"Could not reach AniDB anime page: {exc}") from exc + except TimeoutError as exc: + raise RuntimeError("AniDB anime page request timed out") from exc + + candidates = [ + r'((?:https?:)?//cdn-[^/"\']+\.anidb\.net/images/main/[^"\'\s>]+)', + r'((?:https?:)?//anidb\.net/images/main/[^"\'\s>]+)', + r'(["\'])(/images/main/[^"\']+)\1', + r'(["\'])(/[^"\']*images/main/[^"\']+)\1', + ] + image_url = None + for pattern in candidates: + match = re.search(pattern, html, re.IGNORECASE) + if not match: + continue + image_url = match.group(1) if match.lastindex == 1 else match.group(2) + break + + if not image_url: + debug_log("anidb.page.no_image", aid=aid) + raise RuntimeError("AniDB page did not expose a cover image") + if image_url.startswith("//"): + image_url = f"https:{image_url}" + elif image_url.startswith("/"): + image_url = urljoin(page_url, image_url) + debug_log("anidb.page.image", aid=aid, image_url=image_url) + return { + "aid": str(aid).strip(), + "title": str(fallback_title or "").strip(), + "url": image_url, + } + + +def cache_thumbnail_image(show_id, image_url): + THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True) + debug_log("thumbnail.download.request", show_id=show_id, image_url=image_url) + request = urllib.request.Request( + image_url, + headers={ + "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + "Referer": "https://anidb.net/", + "User-Agent": AGENT, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=25) as response: + content = response.read() + content_type = response.headers.get("Content-Type", "") + debug_log( + "thumbnail.download.response", + show_id=show_id, + status=getattr(response, "status", "unknown"), + bytes=len(content), + content_type=content_type, + final_url=getattr(response, "url", image_url), + ) + except urllib.error.URLError as exc: + debug_log("thumbnail.download.error", show_id=show_id, image_url=image_url, error=exc) + raise RuntimeError(f"Could not download cover image: {exc}") from exc + except TimeoutError as exc: + debug_log("thumbnail.download.timeout", show_id=show_id, image_url=image_url) + raise RuntimeError("Cover image download timed out") from exc + + if not content: + raise RuntimeError("Cover image response was empty") + + stem = sanitize_path_component(show_id, "anime") + extension = infer_image_extension(image_url, content_type) + final_path = THUMBNAIL_ROOT / f"{stem}{extension}" + tmp_path = final_path.with_suffix(final_path.suffix + ".tmp") + with tmp_path.open("wb") as handle: + handle.write(content) + tmp_path.replace(final_path) + + for existing in THUMBNAIL_ROOT.glob(f"{stem}.*"): + if existing != final_path and existing.is_file(): + existing.unlink(missing_ok=True) + debug_log("thumbnail.cache.write", show_id=show_id, path=final_path, bytes=len(content), content_type=content_type) + return final_path.name + + def search_anime(query, mode): search_gql = ( "query( $search: SearchInput $limit: Int $page: Int " @@ -226,36 +890,534 @@ def numeric_episode_key(value): return key -def get_anime_status(show_id, mode): - """Fetches the episode list and determines if the anime is complete.""" - try: - episodes = episode_list(show_id, mode) - if not episodes: - return {"status": "unknown", "message": "Could not retrieve episode list."} +def fetch_show_episode_snapshot(show_id): + query = "query ($showId: String!) { show(_id: $showId) { _id name availableEpisodesDetail } }" + data = graph_request(query, {"showId": show_id}) + show = data.get("show") or {} + detail = show.get("availableEpisodesDetail") or {} + episodes = {} + for mode in MODE_CHOICES: + values = [str(item).strip() for item in detail.get(mode, []) if str(item).strip()] + episodes[mode] = sorted(values, key=numeric_episode_key) + return { + "show_id": str(show.get("_id") or show_id), + "title": str(show.get("name") or "").replace('\\"', '"').strip(), + "episodes": episodes, + } - # Check if the last episode number matches the total available episodes - # This is a heuristic: if the last episode number is significantly lower - # than the total available episodes, it might not be finished. - # For simplicity, we'll just check if the episode count is high enough - # or if the last episode is marked as the final one. - # A more robust check would require knowing the total count from the API. - # For now, we'll assume it's "complete" if we have a reasonable number of episodes. - # A better check would be to compare the max episode number to the total count. - - # Simple check: if the last episode number is less than 10, assume not finished. - # This is a placeholder for better logic. - last_episode_num = int(re.findall(r'\d+', episodes[-1])[0]) - if last_episode_num < 10 and len(episodes) < 5: - return {"status": "ongoing", "message": f"Only {len(episodes)} episodes found. Might be ongoing."} - - return {"status": "available", "message": f"Found {len(episodes)} episodes."} - except Exception as e: - return {"status": "error", "message": f"Error checking status: {str(e)}"} - episodes_gql = "query ($showId: String!) { show( _id: $showId ) { _id availableEpisodesDetail }}" - data = graph_request(episodes_gql, {"showId": show_id}) - detail = ((data.get("show") or {}).get("availableEpisodesDetail")) or {} - episodes = [str(item) for item in detail.get(mode, []) if str(item).strip()] - return sorted(episodes, key=numeric_episode_key) + +def episode_list(show_id, mode): + normalized_mode = str(mode or "").strip().lower() + if normalized_mode not in MODE_CHOICES: + raise ValueError("Mode must be sub or dub") + return fetch_show_episode_snapshot(show_id)["episodes"][normalized_mode] + + +class WatchlistStore: + def __init__(self): + self.lock = threading.RLock() + self.thumbnail_events = {} + self._init_db() + self._migrate_legacy_json() + + def _connect(self): + conn = sqlite3.connect(QUEUE_DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + return conn + + def _init_db(self): + QUEUE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS watchlist ( + show_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL, + status_message TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_checked TEXT, + sub_count INTEGER NOT NULL DEFAULT 0, + dub_count INTEGER NOT NULL DEFAULT 0, + sub_latest_episode TEXT, + dub_latest_episode TEXT, + animeschedule_route TEXT, + animeschedule_title TEXT, + anidb_aid TEXT, + anidb_title TEXT, + thumbnail_path TEXT, + thumbnail_checked_at TEXT + ) + """ + ) + columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()} + if "thumbnail_path" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN thumbnail_path TEXT") + if "thumbnail_checked_at" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN thumbnail_checked_at TEXT") + if "anidb_aid" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_aid TEXT") + if "anidb_title" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_title TEXT") + if "animeschedule_route" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT") + if "animeschedule_title" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_title TEXT") + conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_title ON watchlist(title)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_updated_at ON watchlist(updated_at DESC)") + + def _row_to_item(self, row): + item = dict(row) + item["sub_count"] = int(item.get("sub_count") or 0) + item["dub_count"] = int(item.get("dub_count") or 0) + item["total_count"] = item["sub_count"] + item["dub_count"] + item["source_url"] = anime_source_url(item.get("show_id"), item.get("title")) + thumb_path = thumbnail_file_path(item.get("thumbnail_path")) + item["thumbnail_ready"] = bool(thumb_path and thumb_path.exists()) + item["thumbnail_url"] = cover_route(item.get("show_id")) + if item.get("animeschedule_route"): + title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title" + item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}" + elif item.get("anidb_aid"): + title = str(item.get("anidb_title") or item.get("title") or "").strip() or "unknown title" + item["thumbnail_debug"] = f"AniDB: {item['anidb_aid']} - {title}" + elif item["thumbnail_ready"]: + item["thumbnail_debug"] = "Thumbnail source: manual upload" + else: + item["thumbnail_debug"] = "Thumbnail source: unresolved" + return item + + def _upsert_conn(self, conn, item): + conn.execute( + """ + INSERT INTO watchlist ( + show_id, title, status, status_message, created_at, updated_at, + last_checked, sub_count, dub_count, sub_latest_episode, dub_latest_episode, + animeschedule_route, animeschedule_title, anidb_aid, anidb_title, + thumbnail_path, thumbnail_checked_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(show_id) DO UPDATE SET + title = excluded.title, + status = excluded.status, + status_message = excluded.status_message, + updated_at = excluded.updated_at, + last_checked = excluded.last_checked, + sub_count = excluded.sub_count, + dub_count = excluded.dub_count, + sub_latest_episode = excluded.sub_latest_episode, + dub_latest_episode = excluded.dub_latest_episode, + animeschedule_route = excluded.animeschedule_route, + animeschedule_title = excluded.animeschedule_title, + anidb_aid = excluded.anidb_aid, + anidb_title = excluded.anidb_title, + thumbnail_path = excluded.thumbnail_path, + thumbnail_checked_at = excluded.thumbnail_checked_at + """, + ( + item["show_id"], + item["title"], + item["status"], + item["status_message"], + item["created_at"], + item["updated_at"], + item["last_checked"], + int(item.get("sub_count") or 0), + int(item.get("dub_count") or 0), + item.get("sub_latest_episode"), + item.get("dub_latest_episode"), + item.get("animeschedule_route"), + item.get("animeschedule_title"), + item.get("anidb_aid"), + item.get("anidb_title"), + item.get("thumbnail_path"), + item.get("thumbnail_checked_at"), + ), + ) + + def _migrate_legacy_json(self): + if not WATCHLIST_JSON_PATH.exists(): + return + legacy = load_json(WATCHLIST_JSON_PATH, {}) + if not legacy: + WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated")) + return + + aggregated = {} + for value in legacy.values(): + if not isinstance(value, dict): + continue + show_id = str(value.get("show_id") or "").strip() + if not show_id: + continue + mode = str(value.get("mode") or "").strip().lower() + if mode not in MODE_CHOICES: + continue + item = aggregated.setdefault( + show_id, + { + "show_id": show_id, + "title": str(value.get("title") or "Unknown Anime").strip() or "Unknown Anime", + "status": str(value.get("status") or "tracked"), + "status_message": str(value.get("status_message") or "Migrated from legacy watchlist."), + "created_at": str(value.get("added_at") or now_iso()), + "updated_at": now_iso(), + "last_checked": value.get("last_checked"), + "sub_count": 0, + "dub_count": 0, + "sub_latest_episode": None, + "dub_latest_episode": None, + "animeschedule_route": None, + "animeschedule_title": None, + "anidb_aid": None, + "anidb_title": None, + "thumbnail_path": None, + "thumbnail_checked_at": None, + }, + ) + episodes = value.get("episodes_available") if isinstance(value.get("episodes_available"), list) else [] + count = len(episodes) + latest = episodes[-1] if episodes else None + if mode == "sub": + item["sub_count"] = max(item["sub_count"], count) + item["sub_latest_episode"] = latest or item["sub_latest_episode"] + else: + item["dub_count"] = max(item["dub_count"], count) + item["dub_latest_episode"] = latest or item["dub_latest_episode"] + if item["title"] == "Unknown Anime" and str(value.get("title") or "").strip(): + item["title"] = str(value.get("title")).strip() + + with self.lock, self._connect() as conn: + for item in aggregated.values(): + self._upsert_conn(conn, item) + WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated")) + + def list(self, page=1, per_page=30): + page = max(1, int(page or 1)) + per_page = min(60, max(1, int(per_page or 30))) + offset = (page - 1) * per_page + with self.lock, self._connect() as conn: + total = conn.execute("SELECT COUNT(*) AS count FROM watchlist").fetchone()["count"] + totals = conn.execute( + """ + SELECT + COALESCE(SUM(sub_count), 0) AS sub_total, + COALESCE(SUM(dub_count), 0) AS dub_total + FROM watchlist + """ + ).fetchone() + rows = conn.execute( + "SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + items = [self._row_to_item(row) for row in rows] + pages = max(1, (total + per_page - 1) // per_page) + return { + "items": items, + "total": total, + "page": min(page, pages), + "pages": pages, + "per_page": per_page, + "summary": { + "shows": int(total), + "sub": int(totals["sub_total"] or 0), + "dub": int(totals["dub_total"] or 0), + }, + } + + def get(self, show_id): + with self.lock, self._connect() as conn: + row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (str(show_id).strip(),)).fetchone() + if not row: + raise KeyError("Anime not found in watchlist.") + return self._row_to_item(row) + + def add(self, payload): + show_id = str(payload.get("show_id") or "").strip() + title = str(payload.get("title") or "Unknown Anime").strip() or "Unknown Anime" + if not show_id: + raise ValueError("Missing show_id.") + + with self.lock, self._connect() as conn: + row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (show_id,)).fetchone() + if row: + item = self._row_to_item(row) + return {"message": f"Already tracking {item['title']}.", "item": item, "created": False} + + now = now_iso() + item = { + "show_id": show_id, + "title": title, + "status": "tracked", + "status_message": "Waiting for the first manual refresh.", + "created_at": now, + "updated_at": now, + "last_checked": None, + "sub_count": 0, + "dub_count": 0, + "sub_latest_episode": None, + "dub_latest_episode": None, + "animeschedule_route": None, + "animeschedule_title": None, + "anidb_aid": None, + "anidb_title": None, + "thumbnail_path": None, + "thumbnail_checked_at": None, + } + self._upsert_conn(conn, item) + + refreshed = self.refresh(show_id) + return {"message": f"Added {refreshed['title']} to the watchlist.", "item": refreshed, "created": True} + + def refresh(self, show_id): + existing = self.get(show_id) + now = now_iso() + try: + snapshot = fetch_show_episode_snapshot(show_id) + sub_episodes = snapshot["episodes"]["sub"] + dub_episodes = snapshot["episodes"]["dub"] + item = { + "show_id": existing["show_id"], + "title": snapshot["title"] or existing["title"], + "status": "updated", + "status_message": f"Sub: {len(sub_episodes)} episodes available. Dub: {len(dub_episodes)} episodes available.", + "created_at": existing["created_at"], + "updated_at": now, + "last_checked": now, + "sub_count": len(sub_episodes), + "dub_count": len(dub_episodes), + "sub_latest_episode": sub_episodes[-1] if sub_episodes else None, + "dub_latest_episode": dub_episodes[-1] if dub_episodes else None, + "animeschedule_route": existing.get("animeschedule_route"), + "animeschedule_title": existing.get("animeschedule_title"), + "anidb_aid": existing.get("anidb_aid"), + "anidb_title": existing.get("anidb_title"), + "thumbnail_path": existing.get("thumbnail_path"), + "thumbnail_checked_at": existing.get("thumbnail_checked_at"), + } + except Exception as exc: + item = { + "show_id": existing["show_id"], + "title": existing["title"], + "status": "error", + "status_message": f"Could not refresh episodes: {exc}", + "created_at": existing["created_at"], + "updated_at": now, + "last_checked": now, + "sub_count": existing["sub_count"], + "dub_count": existing["dub_count"], + "sub_latest_episode": existing.get("sub_latest_episode"), + "dub_latest_episode": existing.get("dub_latest_episode"), + "animeschedule_route": existing.get("animeschedule_route"), + "animeschedule_title": existing.get("animeschedule_title"), + "anidb_aid": existing.get("anidb_aid"), + "anidb_title": existing.get("anidb_title"), + "thumbnail_path": existing.get("thumbnail_path"), + "thumbnail_checked_at": existing.get("thumbnail_checked_at"), + } + + with self.lock, self._connect() as conn: + self._upsert_conn(conn, item) + try: + self.ensure_thumbnail(show_id) + except Exception: + pass + return self.get(show_id) + + def refresh_all(self): + items = self.list(page=1, per_page=10000)["items"] + refreshed = [self.refresh(item["show_id"]) for item in items] + return {"count": len(refreshed), "items": refreshed, "message": f"Refreshed {len(refreshed)} watchlist entries."} + + def remove(self, show_id): + existing = self.get(show_id) + thumb_path = thumbnail_file_path(existing.get("thumbnail_path")) + with self.lock, self._connect() as conn: + cursor = conn.execute("DELETE FROM watchlist WHERE show_id = ?", (str(show_id).strip(),)) + if cursor.rowcount < 1: + raise KeyError("Anime not found in watchlist.") + self.thumbnail_events.pop(str(show_id).strip(), None) + if thumb_path and thumb_path.exists(): + thumb_path.unlink(missing_ok=True) + return {"ok": True, "message": f"Removed {existing['title']} from the watchlist.", "item": existing} + + def set_manual_thumbnail(self, show_id, filename, data_url): + existing = self.get(show_id) + content_type, content = decode_image_data_url(data_url) + cached_name = save_thumbnail_bytes(existing["show_id"], filename or existing["title"], content_type, content) + checked_at = now_iso() + with self.lock, self._connect() as conn: + conn.execute( + """ + UPDATE watchlist + SET thumbnail_path = ?, thumbnail_checked_at = ?, updated_at = ? + WHERE show_id = ? + """, + (cached_name, checked_at, checked_at, existing["show_id"]), + ) + item = self.get(show_id) + return {"message": f"Uploaded thumbnail for {item['title']}.", "item": item} + + def ensure_thumbnail(self, show_id, force=False): + item = self.get(show_id) + current_path = thumbnail_file_path(item.get("thumbnail_path")) + current_exists = bool(current_path and current_path.exists()) + debug_log( + "thumbnail.ensure.start", + show_id=show_id, + force=force, + title=item.get("title"), + cached_path=current_path, + cached_exists=current_exists, + thumbnail_ready=item.get("thumbnail_ready"), + route=item.get("animeschedule_route"), + aid=item.get("anidb_aid"), + ) + if current_exists and not force: + debug_log("thumbnail.ensure.cached", show_id=show_id, path=current_path) + return current_path + if not force and current_path is None and not thumbnail_retry_due(item.get("thumbnail_checked_at")): + debug_log("thumbnail.ensure.skipped_retry_window", show_id=show_id, checked_at=item.get("thumbnail_checked_at")) + return None + if current_path and not current_exists: + debug_log("thumbnail.ensure.missing_cached_file", show_id=show_id, expected_path=current_path) + + with self.lock: + event = self.thumbnail_events.get(show_id) + if event: + wait_event = event + else: + wait_event = threading.Event() + self.thumbnail_events[show_id] = wait_event + event = None + + if event: + wait_event.wait(timeout=20) + refreshed = self.get(show_id) + refreshed_path = thumbnail_file_path(refreshed.get("thumbnail_path")) + debug_log("thumbnail.ensure.waited_for_inflight", show_id=show_id, resolved_path=refreshed_path) + return refreshed_path if refreshed_path and refreshed_path.exists() else None + + cached_name = None + cover = None + checked_at = now_iso() + try: + stored_route = str(item.get("animeschedule_route") or "").strip() + stored_schedule_title = str(item.get("animeschedule_title") or item.get("title") or "").strip() + if stored_route: + try: + debug_log("thumbnail.ensure.try_route", source="AnimeSchedule", show_id=show_id, route=stored_route) + cover = fetch_animeschedule_cover_by_route(stored_route, fallback_title=stored_schedule_title) + except Exception: + debug_log("thumbnail.ensure.route_failed", source="AnimeSchedule", show_id=show_id, route=stored_route) + if not force: + raise + if cover is None: + try: + debug_log("thumbnail.ensure.try_title", source="AnimeSchedule", show_id=show_id, title=item["title"]) + cover = fetch_animeschedule_cover(item["title"]) + except Exception: + debug_log("thumbnail.ensure.title_failed", source="AnimeSchedule", show_id=show_id, title=item["title"]) + cover = None + stored_aid = str(item.get("anidb_aid") or "").strip() + stored_title = str(item.get("anidb_title") or item.get("title") or "").strip() + if cover is None and stored_aid: + try: + debug_log("thumbnail.ensure.try_route", source="AniDB", show_id=show_id, aid=stored_aid) + cover = fetch_anidb_cover_by_aid(stored_aid, fallback_title=stored_title) + except Exception: + debug_log("thumbnail.ensure.route_failed", source="AniDB", show_id=show_id, aid=stored_aid) + if not force: + raise + if cover is None: + debug_log("thumbnail.ensure.try_title", source="AniDB", show_id=show_id, title=item["title"]) + cover = fetch_anidb_cover(item["title"]) + debug_log("thumbnail.ensure.cover_selected", show_id=show_id, source="AnimeSchedule" if cover.get("route") else "AniDB", cover=cover) + cached_name = cache_thumbnail_image(item["show_id"], cover["url"]) + except Exception as exc: + debug_log("thumbnail.ensure.failed", show_id=show_id, error=exc) + cached_name = None + checked_at = item.get("thumbnail_checked_at") + + with self.lock, self._connect() as conn: + conn.execute( + """ + UPDATE watchlist + SET thumbnail_path = ?, thumbnail_checked_at = ?, + animeschedule_route = ?, animeschedule_title = ?, + anidb_aid = ?, anidb_title = ? + WHERE show_id = ? + """, + ( + cached_name, + checked_at, + (cover or {}).get("route"), + (cover or {}).get("title") if (cover or {}).get("route") else item.get("animeschedule_title"), + (cover or {}).get("aid"), + (cover or {}).get("title") if (cover or {}).get("aid") else item.get("anidb_title"), + item["show_id"], + ), + ) + inflight = self.thumbnail_events.get(show_id) + if inflight is wait_event: + inflight.set() + self.thumbnail_events.pop(show_id, None) + debug_log( + "thumbnail.ensure.finish", + show_id=show_id, + cached_name=cached_name, + route=(cover or {}).get("route"), + aid=(cover or {}).get("aid"), + checked_at=checked_at, + ) + return thumbnail_file_path(cached_name) + + def ensure_thumbnails(self, show_ids, limit=6, force=False): + normalized = [] + for show_id in show_ids: + text = str(show_id or "").strip() + if text and text not in normalized: + normalized.append(text) + updated = [] + for show_id in normalized[: max(1, min(int(limit or 6), 10))]: + item = self.get(show_id) + if not force and item.get("thumbnail_ready"): + updated.append(item) + continue + try: + self.ensure_thumbnail(show_id, force=force) + except Exception: + pass + updated.append(self.get(show_id)) + return {"items": updated} + + +WATCHLIST = WatchlistStore() + + +def add_to_watchlist(payload): + return WATCHLIST.add(payload) + + +def get_watchlist(page=1, per_page=30): + return WATCHLIST.list(page=page, per_page=per_page) + + +def update_watchlist_status(show_id, _mode=None): + item = WATCHLIST.refresh(show_id) + return {"message": f"Updated {item['title']}.", "item": item} + + +def update_all_watchlist_statuses(): + return WATCHLIST.refresh_all() + + +def remove_from_watchlist(show_id): + return WATCHLIST.remove(show_id) + + +def upload_watchlist_thumbnail(payload): + return WATCHLIST.set_manual_thumbnail(payload["show_id"], payload.get("filename"), payload.get("data_url")) class DownloadQueue: @@ -754,6 +1916,8 @@ class Handler(BaseHTTPRequestHandler): try: if parsed.path == "/": self.html(INDEX_HTML) + elif parsed.path == "/watchlist": + self.html(WATCHLIST_HTML) elif parsed.path == "/api/config": self.json(CONFIG) elif parsed.path == "/api/version": @@ -781,77 +1945,80 @@ class Handler(BaseHTTPRequestHandler): page = (params.get("page") or ["1"])[0] per_page = (params.get("per_page") or ["10"])[0] self.json(DOWNLOAD_QUEUE.list(page=page, per_page=per_page)) - elif parsed.path == "/api/watchlist/add": - payload = self.body_json() - return self.json(add_to_watchlist(payload)) - elif parsed.path == "/api/watchlist/get": - return self.json(get_watchlist()) - elif parsed.path == "/api/watchlist/update-status": - payload = self.body_json() - return self.json(update_watchlist_status(payload["show_id"], payload["mode"])) + elif parsed.path.startswith("/api/watchlist/thumb/"): + params = parse_qs(parsed.query) + show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip() + force = (params.get("force") or ["0"])[0] == "1" + debug_log("thumbnail.route.request", show_id=show_id, force=force, path=parsed.path, query=parsed.query) + path = WATCHLIST.ensure_thumbnail(show_id, force=force) + if not path or not path.exists(): + debug_log("thumbnail.route.miss", show_id=show_id, resolved_path=path) + self.error(HTTPStatus.NOT_FOUND, "Thumbnail not found") + return + debug_log("thumbnail.route.hit", show_id=show_id, resolved_path=path) + self.file(path) + elif parsed.path in {"/api/watchlist", "/api/watchlist/get"}: + params = parse_qs(parsed.query) + page = (params.get("page") or ["1"])[0] + per_page = (params.get("per_page") or ["30"])[0] + return self.json(get_watchlist(page=page, per_page=per_page)) else: self.error(HTTPStatus.NOT_FOUND, "Not found") except Exception as exc: self.exception(exc) - def add_to_watchlist(payload): - """Adds an anime to the watchlist.""" - show_id = payload.get("show_id") - mode = payload.get("mode") - if not show_id or not mode: - return {"error": "Missing show_id or mode."} - - # Check if already exists - for item in WATCHLIST: - if item["show_id"] == show_id and item["mode"] == mode: - return {"message": f"Already tracked: {item['title']} ({mode})."} - - # Add new item - new_item = { - "show_id": show_id, - "title": payload.get("title", "Unknown Anime"), - "mode": mode, - "added_at": now_iso(), - "status": "pending_check", - "last_checked": now_iso(), - "episodes_available": [], - "is_complete": False, - } - WATCHLIST[str(uuid4())] = new_item - write_watchlist(WATCHLIST) - return {"message": f"Successfully added {payload.get('title', 'Anime')} to watchlist for {mode}."} - -def get_watchlist(): - """Retrieves the entire watchlist.""" - return WATCHLIST - -def update_watchlist_status(show_id, mode): - """Updates the status of a specific anime in the watchlist.""" - if str(show_id) not in WATCHLIST: - return {"error": "Anime not found in watchlist."} - - item = WATCHLIST[str(show_id)] - - # 1. Check episode availability - status_check = get_anime_status(show_id, mode) - item["episodes_available"] = status_check["message"] - - # 2. Determine completion status (Placeholder logic) - # In a real scenario, we'd check if the last episode number matches the total count. - # For now, we'll just update the status based on the check result. - if status_check["status"] == "available" and "Found" in status_check["message"]: - item["is_complete"] = True # Assume available means complete for now - item["status"] = "ready_to_download" - elif status_check["status"] == "ongoing": - item["is_complete"] = False - item["status"] = "ongoing" - else: - item["is_complete"] = False - item["status"] = "pending_check" - - item["last_checked"] = now_iso() - write_watchlist(WATCHLIST) - return {"message": f"Status updated for {item['title']}. Status: {item['status']}"} + def do_POST(self): + parsed = urlparse(self.path) + try: + if parsed.path == "/api/config": + self.update_config(self.body_json()) + elif parsed.path == "/api/queue": + self.json(DOWNLOAD_QUEUE.add(self.body_json()), HTTPStatus.CREATED) + elif parsed.path == "/api/queue/retry-failed": + self.json(DOWNLOAD_QUEUE.retry_all_failed()) + elif parsed.path == "/api/queue/clear-finished": + self.json(DOWNLOAD_QUEUE.clear_finished()) + elif parsed.path.startswith("/api/queue/"): + parts = parsed.path.strip("/").split("/") + if len(parts) != 4: + self.error(HTTPStatus.NOT_FOUND, "Not found") + return + _, _, job_id, action = parts + actions = { + "retry": DOWNLOAD_QUEUE.retry, + "cancel": DOWNLOAD_QUEUE.cancel, + "remove": DOWNLOAD_QUEUE.remove, + } + if action not in actions: + self.error(HTTPStatus.NOT_FOUND, "Not found") + return + self.json(actions[action](job_id)) + elif parsed.path in {"/api/watchlist", "/api/watchlist/add"}: + self.json(add_to_watchlist(self.body_json()), HTTPStatus.CREATED) + elif parsed.path == "/api/watchlist/ensure-thumbnails": + payload = self.body_json() + self.json( + WATCHLIST.ensure_thumbnails( + payload.get("show_ids") or [], + limit=payload.get("limit", 6), + force=bool(payload.get("force")), + ) + ) + elif parsed.path == "/api/watchlist/update-status": + payload = self.body_json() + self.json(update_watchlist_status(payload["show_id"], payload.get("mode"))) + elif parsed.path == "/api/watchlist/update-all": + self.json(update_all_watchlist_statuses()) + elif parsed.path == "/api/watchlist/remove": + payload = self.body_json() + self.json(remove_from_watchlist(payload["show_id"])) + elif parsed.path == "/api/watchlist/upload-thumbnail": + payload = self.body_json() + self.json(upload_watchlist_thumbnail(payload)) + else: + self.error(HTTPStatus.NOT_FOUND, "Not found") + except Exception as exc: + self.exception(exc) def update_config(self, payload): global CONFIG @@ -871,24 +2038,45 @@ def update_watchlist_status(show_id, mode): def html(self, content): data = content.encode("utf-8") - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=utf-8"}) + + def file(self, path): + payload = Path(path).read_bytes() + content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + debug_log("file.serve", path=path, content_type=content_type, bytes=len(payload)) + self.write_response_bytes( + payload, + HTTPStatus.OK, + { + "Content-Type": content_type, + "Cache-Control": "public, max-age=86400", + }, + ) def json(self, payload, status=HTTPStatus.OK): data = json.dumps(payload).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + self.write_response_bytes(data, status, {"Content-Type": "application/json"}) + + def write_response_bytes(self, payload, status, headers): + try: + self.send_response(status) + for key, value in headers.items(): + self.send_header(key, value) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + except CLIENT_DISCONNECT_ERRORS: + return def error(self, status, message): - self.json({"error": message}, status) + try: + self.json({"error": message}, status) + except CLIENT_DISCONNECT_ERRORS: + return def exception(self, exc): + if isinstance(exc, CLIENT_DISCONNECT_ERRORS): + return if isinstance(exc, KeyError): self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'")) elif isinstance(exc, ValueError): @@ -1013,6 +2201,29 @@ INDEX_HTML = r""" display: grid; gap: 12px; } + .page-links { + display: flex; + gap: 10px; + flex-wrap: wrap; + } + .page-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 12px; + color: var(--text); + background: var(--panel-2); + font-weight: 700; + text-decoration: none; + } + .page-link.active { + background: #17242d; + border-color: var(--focus); + color: var(--text); + } .segmented { display: grid; grid-template-columns: 1fr 1fr; @@ -1136,6 +2347,11 @@ INDEX_HTML = r""" color: var(--accent-2); font-size: 13px; } + .statline { + display: flex; + flex-wrap: wrap; + gap: 8px; + } @media (max-width: 900px) { .app { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid var(--line); } @@ -1152,17 +2368,21 @@ INDEX_HTML = r""" local - + + - -
-

Anime Watchlist

-
- -
-
- - - - - - - - -
-
- -
+
+
+
@@ -1206,7 +2404,10 @@ INDEX_HTML = r"""

Select a result

Episodes will appear here.

- +
+ + +