Release ani-cli-web 0.26.0
This commit is contained in:
+100
@@ -1,5 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## 0.26.0 - 2026-05-15
|
||||
|
||||
- Made controlled runtime shutdown stricter by canceling active download workers during blocking teardown paths such as app exit and runtime reset, reducing the chance of orphaned background work outliving the server.
|
||||
- Hardened watchlist thumbnail warm-up batches so shows removed mid-request are skipped cleanly instead of bubbling a stale lookup back as a route error.
|
||||
- Added shared serial browser polling helpers and switched the Search and Watchlist pages to use them, preventing overlapping queue and watchlist poll requests from stacking up when the server responds slowly.
|
||||
- Improved background refresh diagnostics by logging per-show worker failures and preserving the last bulk-refresh item error in refresh-job state.
|
||||
- Expanded regression coverage for deterministic download-queue shutdown, removed-entry thumbnail warm-up handling, shutdown-on-exit behavior, and shared serial polling usage in page templates.
|
||||
|
||||
## 0.25.0 - 2026-05-15
|
||||
|
||||
- Stopped the watchlist page from repeatedly rewarming the same unresolved thumbnails during silent queued-refresh polling by caching per-show warm-up attempts in the browser until thumbnail state changes.
|
||||
- Made background `Refresh All` lighter and faster by skipping inline thumbnail downloads during bulk refreshes and refreshing multiple watchlist shows in parallel.
|
||||
- Taught per-show refreshes to bail out quietly when a show disappears mid-refresh, avoiding stale watchlist writes and extra thumbnail work after removal.
|
||||
- Woke waiting thumbnail resolvers immediately when a show is removed, so stale in-flight cover waits no longer sit on the timeout path.
|
||||
- Expanded regression coverage for thumbnail warm-up deduplication, bulk refresh thumbnail skipping, thumbnail wait cleanup, and removed-entry handling during background refresh jobs.
|
||||
|
||||
## 0.24.0 - 2026-05-15
|
||||
|
||||
- Moved single-item watchlist refresh requests onto the existing per-show background refresh queue instead of doing episode, AnimeSchedule, and thumbnail work synchronously in the request handler.
|
||||
- Added queued-item counts to watchlist listing responses and taught the watchlist page to silently refresh itself while queued per-show refresh work is still draining.
|
||||
- Cleared removed shows out of the in-memory per-show refresh queue and in-flight tracking sets so deleted entries no longer consume stale queued refresh work.
|
||||
- Expanded regression coverage for queued watchlist counts, queued single-item refreshes, queued refresh removal cleanup, and watchlist-page queued polling behavior.
|
||||
|
||||
## 0.23.0 - 2026-05-15
|
||||
|
||||
- Fixed the per-show watchlist refresh queue so a show already being refreshed is no longer re-enqueued while its refresh is still in flight.
|
||||
- Added regression coverage for in-flight watchlist refresh deduplication and cleanup of the in-flight marker after refresh completion.
|
||||
|
||||
## 0.22.0 - 2026-05-15
|
||||
|
||||
- Added explicit runtime shutdown and reset hooks so shared globals and background workers can be torn down cleanly between in-process runs and tests.
|
||||
- Fixed deferred worker startup to keep respecting `ANI_CLI_WEB_DISABLE_WORKER`, preventing runtime-backed handler calls from silently enabling background workers in worker-disabled environments.
|
||||
- Split download-queue persistence into structured SQLite columns plus a separate log column, leaving JSON payload storage only for extra fields instead of rewriting the whole job record on routine state changes.
|
||||
- Reduced server-side exception noise by only printing full tracebacks in debug mode.
|
||||
- Centralized the shared browser `api()` fetch helper used by the Search, Config, and Watchlist pages.
|
||||
- Expanded regression coverage for runtime reset, worker-disable behavior, structured queue persistence, and the quieter exception path.
|
||||
|
||||
## 0.21.0 - 2026-05-15
|
||||
|
||||
- Persisted the per-show watchlist refresh queue across restarts by rebuilding pending refresh work from watchlist rows still marked `queued` during runtime startup.
|
||||
- Preserved queued refresh ordering during restart recovery so older queued watchlist items resume before newer ones.
|
||||
- Added regression coverage for queued watchlist refresh restoration after restart.
|
||||
|
||||
## 0.20.0 - 2026-05-15
|
||||
|
||||
- Replaced the HTTP handler's old mutable string-key service locator with an explicit configured handler context, making route dependencies clearer and surfacing wiring issues at startup time instead of during later requests.
|
||||
- Moved watchlist add and successful-download sync off the hot request path by queueing per-show background refresh work instead of doing episode, AnimeSchedule, and thumbnail fetches synchronously before the request returns.
|
||||
- Ensured runtime-backed HTTP requests start background workers when allowed, while still respecting `ANI_CLI_WEB_DISABLE_WORKER` in tests and intentionally worker-free runs.
|
||||
- Expanded regression coverage for the new non-blocking watchlist add/download-sync flow.
|
||||
|
||||
## 0.19.0 - 2026-05-15
|
||||
|
||||
- Guarded Search-page queue polling against stale out-of-order responses so an older refresh result no longer overwrites a newer queue state.
|
||||
- Added `get_cached_anidb_titles()` and switched AniDB lookup to reuse the cached parsed title list directly instead of rebuilding a duplicate list on every fallback lookup.
|
||||
- Expanded regression coverage for the queue freshness guard and AniDB cache reuse path.
|
||||
|
||||
## 0.18.0 - 2026-05-15
|
||||
|
||||
- Removed the eager runtime bootstrap from `main()`, so server startup no longer forces migrations, DB setup, worker startup, and config writes before the first runtime-backed request.
|
||||
- Batched queue log persistence so running downloads no longer rewrite the entire job payload into SQLite on every single output line.
|
||||
- Added regression coverage for lazy startup and queue log batching.
|
||||
|
||||
## 0.17.0 - 2026-05-15
|
||||
|
||||
- Tightened the remote-access guard so loopback reverse-proxy traffic with forwarded external client IPs no longer bypasses the non-local token requirement.
|
||||
- Stopped mutating the shared runtime config dict in place; queue creation now reads a stable config snapshot and config saves replace the runtime config atomically.
|
||||
- Reduced cheap-route startup overhead by serving page shells, `/api/version`, `/api/dependencies`, and `/api/config` without forcing a full runtime bootstrap first.
|
||||
- Added request-order guards on the Search and Watchlist pages so slower stale responses no longer overwrite newer searches, result selections, or category/page switches.
|
||||
- Added AniDB title lookup caching and indexed candidate narrowing, while preserving a full-scan fallback when narrowed candidates miss.
|
||||
- Expanded regression coverage for forwarded-client access checks and config snapshot behavior.
|
||||
|
||||
## 0.16.0 - 2026-05-15
|
||||
|
||||
- Hardened non-local access so `ANI_CLI_WEB_ALLOW_REMOTE=1` now also requires `ANI_CLI_WEB_REMOTE_TOKEN`, accepted through `Authorization: Bearer ...` or `X-AniCli-Web-Token`, before remote clients can use the web UI or JSON APIs.
|
||||
- Removed import-time state migration side effects from `app_support.py`; legacy state migration now runs during runtime bootstrap instead of plain module import.
|
||||
- Narrowed watchlist persistence updates so refreshes, category changes, and download-sync writes stop rewriting the full row and are less likely to clobber unrelated fields.
|
||||
- Resolve missing queue `show_id` values when jobs are queued, making completed-download watchlist sync stable even if upstream search ordering changes later.
|
||||
- Continued the backend split by moving queue workers into `queue_jobs.py`, HTTP routing into `http_handler.py`, and page bodies into `search_page.py`, `config_page.py`, and `watchlist_page.py`, while keeping `app.py` and `web_templates.py` as stable aggregation entrypoints.
|
||||
- Hardened the shared frontend `api()` helpers so page scripts handle non-JSON error responses more gracefully and only send JSON content headers when a request body is present.
|
||||
- Expanded regression coverage for the new remote token gate and queue identity enrichment path.
|
||||
|
||||
## 0.15.0 - 2026-05-15
|
||||
|
||||
- Started the backend/module split by moving shared runtime, state-path, config, and queue/file helper logic out of `app.py` into `app_support.py`.
|
||||
- Split shared page-shell helpers out of `web_templates.py` into `template_helpers.py`, so page templates no longer carry their own navigation/branding helpers inline.
|
||||
- Kept `app.py` and `web_templates.py` as stable aggregation points so the current tests and imports continue to work while the rest of the modularization can happen in smaller follow-up steps.
|
||||
|
||||
## 0.14.0 - 2026-05-15
|
||||
|
||||
- Added watchlist categories with separate `Watching`, `Planned`, `Finished`, and `Dropped` tabs, category-aware add forms, and in-card category editing for existing entries.
|
||||
- Migrated existing watchlist rows into the `Watching` category by default so older tracked shows keep working without manual cleanup.
|
||||
- Added finished-state check badges: finished anime that were downloaded now show a green check, while anime moved into `Finished` manually show a gray check.
|
||||
- Automatically sync successful downloads into the watchlist by flagging tracked shows as downloaded and creating a new `Finished` entry when the show was not already tracked.
|
||||
|
||||
## 0.13.0 - 2026-05-15
|
||||
|
||||
- Added AnimeSchedule-backed watchlist metadata refresh so tracked shows now store the expected total episode count and upstream airing status alongside the live AllAnime sub and dub availability counts.
|
||||
- Marked watchlist episode pills with `current / total` progress when the total is known, and highlight `Sub` or `Dub` in green when that release track has fully finished airing and is ready for download.
|
||||
- Added compact watchlist status tags such as `Finished`, `Sub ready`, and `Dub ready`, plus regression coverage for the new completion-state persistence and messaging.
|
||||
|
||||
## 0.12.0 - 2026-05-13
|
||||
|
||||
- Deferred runtime initialization until the server starts or a runtime-backed API/helper is first used, removing the earlier worker/thread startup and state writes from plain module import.
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
A local web UI for a system-wide `ani-cli` install.
|
||||
|
||||
Current version: `0.12.0`
|
||||
Current version: `0.26.0`
|
||||
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
ani-cli-web/
|
||||
app.py Web server, runtime wiring, request handlers, and CLI entrypoint.
|
||||
web_templates.py Shared Search, Config, and Watchlist page markup plus page-shell helpers.
|
||||
app.py Public entrypoint, runtime wiring, watchlist domain logic, and CLI startup.
|
||||
app_support.py Shared runtime/state/config helpers plus queue and library file utilities.
|
||||
queue_jobs.py Download queue and background watchlist-refresh workers.
|
||||
http_handler.py HTTP route handling, request parsing, and remote-access guard.
|
||||
web_templates.py Shared Search, Config, and Watchlist page markup aggregation.
|
||||
search_page.py Search page template.
|
||||
config_page.py Config page template.
|
||||
watchlist_page.py Watchlist page template.
|
||||
template_helpers.py Shared page-shell navigation and sidebar brand helpers.
|
||||
ani-cli-web Launcher script.
|
||||
test_app.py Regression tests with isolated temp-state setup.
|
||||
VERSION Project version.
|
||||
@@ -53,11 +60,26 @@ The app currently includes:
|
||||
- A dedicated Config page for saved default folder, mode, and quality settings.
|
||||
- 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.
|
||||
- Background `Refresh All` execution with status polling instead of a long blocking HTTP request.
|
||||
- Watchlist category tabs for `Watching`, `Planned`, `Finished`, and `Dropped`, with per-anime category editing.
|
||||
- AnimeSchedule-backed watchlist completion tracking that compares live sub/dub availability with the expected total episode count when known.
|
||||
- Automatic watchlist sync after successful downloads, including downloaded-vs-manual finished markers.
|
||||
- Per-show background watchlist refresh queueing so adding a show or syncing a finished download does not block the request on upstream episode, schedule, and thumbnail lookups.
|
||||
- Queued per-show watchlist refresh work now survives app restarts and resumes from rows still marked `queued`.
|
||||
- Per-show watchlist refresh queueing now also deduplicates in-flight refreshes, so the same show is not fetched twice back-to-back while one refresh is already running.
|
||||
- The watchlist page now silently refreshes itself while queued per-show refresh work is still being processed, so queued entries settle into their updated state without a manual reload.
|
||||
- The watchlist page now also avoids re-triggering the same unresolved thumbnail warm-up request on every silent poll cycle while a show's thumbnail state is unchanged.
|
||||
- Background `Refresh All` execution with status polling, parallel per-show refresh work, and no inline thumbnail downloads during the bulk job.
|
||||
- Search and Watchlist polling now use a shared serial browser polling helper so slow responses do not cause overlapping poll requests to pile up in the background.
|
||||
- Controlled shutdown and runtime reset now cancel active download workers when waiting for teardown, reducing the chance of orphaned background downloads outliving the web process.
|
||||
- Persistent watchlist refresh history that survives restarts and marks interrupted bulk refresh jobs clearly.
|
||||
- Local thumbnail caching plus manual thumbnail upload when automatic cover lookup fails.
|
||||
- A refreshed glassy dark UI inspired by the Tsuki frontend design language.
|
||||
- Shared page templates moved out of `app.py` plus broader regression tests for higher-risk queue, watchlist, and handler flows.
|
||||
- Shared page templates and backend services split into dedicated modules while keeping `app.py` and `web_templates.py` as stable public aggregation points.
|
||||
- Broader regression coverage for higher-risk queue, watchlist, remote-access, and handler flows.
|
||||
- Search and watchlist requests guard against stale out-of-order async responses during rapid repeated actions.
|
||||
- Search-page queue polling also guards against stale out-of-order refresh responses now.
|
||||
- Queue log writes are batched during active downloads to reduce SQLite churn on noisy jobs.
|
||||
- Queue persistence now stores core job metadata and logs in structured SQLite columns instead of treating the whole job as one large JSON blob.
|
||||
- Project-local runtime state under `.ani-cli-web/`.
|
||||
|
||||
## Search Page
|
||||
@@ -97,14 +119,24 @@ If you intentionally want LAN or remote access, set:
|
||||
|
||||
```sh
|
||||
ANI_CLI_WEB_ALLOW_REMOTE=1
|
||||
ANI_CLI_WEB_REMOTE_TOKEN=choose-a-long-random-token
|
||||
```
|
||||
|
||||
This keeps the queue, config, and watchlist mutation APIs safer by default when the app is rebound away from localhost.
|
||||
Non-local clients must then send either:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
- `X-AniCli-Web-Token: <token>`
|
||||
|
||||
When the app is placed behind a loopback reverse proxy, forwarded external client IP headers such as `X-Forwarded-For` are also treated as non-local, so proxied internet or LAN traffic still needs the token.
|
||||
|
||||
This keeps the queue, config, and watchlist APIs safer when the app is intentionally exposed beyond localhost.
|
||||
|
||||
## Download Queue
|
||||
|
||||
The queue is stored in SQLite and shown 10 jobs at a time.
|
||||
|
||||
Core queue fields and logs are now stored in structured SQLite columns, while JSON payload storage is kept only for extra fields that do not have dedicated columns yet.
|
||||
|
||||
Queue actions currently available:
|
||||
|
||||
- `Retry` for finished or failed jobs.
|
||||
@@ -121,22 +153,43 @@ ANI_CLI_DOWNLOAD_DIR/anime_name/Season 01/anime_name - S01E01.mp4
|
||||
|
||||
## Watchlist
|
||||
|
||||
The watchlist stores one row per show in SQLite and keeps separate `sub` and `dub` episode counts.
|
||||
The watchlist stores one row per show in SQLite, keeps separate `sub` and `dub` episode counts, and assigns each show to one of four categories:
|
||||
|
||||
- `Watching`
|
||||
- `Planned`
|
||||
- `Finished`
|
||||
- `Dropped`
|
||||
|
||||
You can add entries in two ways:
|
||||
|
||||
1. From the Search page with `Add to watchlist`.
|
||||
2. Manually on `/watchlist` with a title and AllAnime `show_id`.
|
||||
1. From the Search page with `Add to watchlist`, choosing the category first.
|
||||
2. Manually on `/watchlist` with a title, AllAnime `show_id`, and category.
|
||||
|
||||
Watchlist features:
|
||||
|
||||
1. `Refresh` updates one tracked show.
|
||||
2. `Refresh All` runs in the background, rejects duplicate queueing while a refresh is already pending or running, and preserves interrupted job status across restarts.
|
||||
2. `Refresh All` runs in the background, rejects duplicate queueing while a refresh is already pending or running, preserves interrupted job status across restarts, and refreshes shows in parallel without doing inline thumbnail downloads for every entry.
|
||||
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/<show_id>` 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.
|
||||
8. Episode pills show `current / total` progress when AnimeSchedule exposes the planned episode count.
|
||||
9. `Sub` and `Dub` pills turn green and gain `Sub ready` or `Dub ready` tags when that release track appears fully finished and ready for download.
|
||||
10. Each category has its own tab on the watchlist page.
|
||||
11. Every card includes an inline category selector so you can move tracked anime between tabs at any time.
|
||||
12. Finished shows display a small check badge: green means the anime was downloaded through the app, gray means it was marked finished manually.
|
||||
|
||||
### Download Sync
|
||||
|
||||
Successful downloads now feed back into the watchlist automatically.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- If the anime is already on the watchlist, a successful download marks it as downloaded and keeps its current category.
|
||||
- If the anime is not yet on the watchlist, `ani-cli-web` creates a new watchlist entry in `Finished` after the download succeeds.
|
||||
- Those add and download-sync paths now queue a background refresh for episode counts, AnimeSchedule metadata, and thumbnails instead of waiting for all upstream fetches before returning.
|
||||
- Existing watchlist entries from older versions are migrated into the `Watching` category automatically.
|
||||
|
||||
### Thumbnail Behavior
|
||||
|
||||
@@ -147,10 +200,13 @@ 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.
|
||||
- The page only retries an unresolved cover warm-up once per thumbnail state change during the current browser session, so silent watchlist polling does not keep hammering the same missing poster.
|
||||
- If a show disappears while a thumbnail warm-up batch is running, that entry is skipped cleanly instead of failing the whole batch request.
|
||||
- Thumbnail image tags only hit the local route for already-cached files, so opening the watchlist no longer triggers a per-card remote lookup burst.
|
||||
- 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.
|
||||
- AniDB fallback reuses the cached parsed title list directly to keep repeated lookups lighter.
|
||||
- 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:
|
||||
@@ -161,6 +217,22 @@ Per-card thumbnail tools:
|
||||
|
||||
Manual uploads are saved into the same local thumbnail cache used by automatic cover downloads.
|
||||
|
||||
### Completion Detection
|
||||
|
||||
Watchlist refresh still uses AllAnime, the same upstream source that `ani-cli` uses, to count which `sub` and `dub` episodes are actually available right now.
|
||||
|
||||
To decide whether a release track looks complete, `ani-cli-web` also checks AnimeSchedule for:
|
||||
|
||||
- the anime's expected total episode count
|
||||
- the upstream airing status such as `Finished` or `Ongoing`
|
||||
|
||||
When AnimeSchedule reports a known total and `Finished`, the watchlist compares that total with the live AllAnime `sub` and `dub` counts:
|
||||
|
||||
- `Sub` becomes ready when available sub episodes reach the expected total.
|
||||
- `Dub` becomes ready when available dub episodes reach the expected total.
|
||||
|
||||
If AnimeSchedule does not know the total yet, the watchlist keeps showing the plain available-count value without a completion highlight.
|
||||
|
||||
## Runtime State
|
||||
|
||||
All runtime state is stored under:
|
||||
@@ -193,6 +265,7 @@ Environment variables:
|
||||
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`.
|
||||
- `ANI_CLI_WEB_PORT`: server port, default `8421`.
|
||||
- `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients when set to `1`, `true`, `yes`, or `on`.
|
||||
- `ANI_CLI_WEB_REMOTE_TOKEN`: required shared secret for non-loopback clients when remote access is enabled.
|
||||
- `ANI_CLI_WEB_DEBUG`: enable debug logging when set to `1`, `true`, `yes`, or `on`.
|
||||
- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory path; useful for isolated test runs or custom storage locations.
|
||||
|
||||
@@ -201,6 +274,9 @@ Saved defaults from the UI are written into the project-local `config.json`.
|
||||
## Notes
|
||||
|
||||
- Importing `app.py` no longer boots the runtime worker threads automatically; runtime setup is deferred until startup or first use.
|
||||
- Importing `app_support.py` no longer migrates or creates runtime state on its own; migrations now happen during runtime bootstrap.
|
||||
- Cheap routes such as the page shells, `/api/version`, `/api/dependencies`, and `/api/config` do not need to boot the full runtime before responding.
|
||||
- The CLI startup path itself also stays lazy now; runtime bootstrap happens when the first runtime-backed request arrives.
|
||||
- Watchlist and queue data are stored in the same project-local SQLite database file, `state.sqlite3`.
|
||||
- Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode cache directories at any depth are ignored by git.
|
||||
- Focused regression tests live in `test_app.py`, run against an isolated temporary state root, and can be started with `python3 -m unittest test_app.py`.
|
||||
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Shared constants and helper utilities for ani-cli-web."""
|
||||
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
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.26.0"
|
||||
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"}
|
||||
WATCHLIST_CATEGORY_LABELS = {
|
||||
"watching": "Watching",
|
||||
"planned": "Planned",
|
||||
"finished": "Finished",
|
||||
"dropped": "Dropped",
|
||||
}
|
||||
WATCHLIST_CATEGORY_CHOICES = tuple(WATCHLIST_CATEGORY_LABELS.keys())
|
||||
MAX_LOG_LINES = 240
|
||||
MAX_JSON_BODY_BYTES = 12 * 1024 * 1024
|
||||
|
||||
|
||||
def env_flag(name, default=False):
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def debug_enabled():
|
||||
return DEBUG_MODE or env_flag("ANI_CLI_WEB_DEBUG")
|
||||
|
||||
|
||||
def remote_access_allowed():
|
||||
return env_flag("ANI_CLI_WEB_ALLOW_REMOTE")
|
||||
|
||||
|
||||
def remote_access_token():
|
||||
return str(os.environ.get("ANI_CLI_WEB_REMOTE_TOKEN") or "").strip()
|
||||
|
||||
|
||||
def remote_access_token_configured():
|
||||
return bool(remote_access_token())
|
||||
|
||||
|
||||
def remote_access_token_matches(value):
|
||||
provided = str(value or "").strip()
|
||||
expected = remote_access_token()
|
||||
return bool(expected and provided and hmac.compare_digest(provided, expected))
|
||||
|
||||
|
||||
def background_workers_enabled():
|
||||
return not env_flag("ANI_CLI_WEB_DISABLE_WORKER")
|
||||
|
||||
|
||||
def client_address_is_local(host):
|
||||
text = str(host or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if text == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(text).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
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"]
|
||||
home_downloads = Path.home() / "Downloads" / "Anime"
|
||||
if os.access(home_downloads.parent if home_downloads.parent.exists() else Path.home(), os.W_OK):
|
||||
return str(home_downloads)
|
||||
return str(PROJECT_ROOT / "downloads")
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"download_dir": default_download_dir(),
|
||||
"mode": os.environ.get("ANI_CLI_MODE", "sub"),
|
||||
"quality": os.environ.get("ANI_CLI_QUALITY", "best"),
|
||||
}
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def project_app_root():
|
||||
configured = str(os.environ.get("ANI_CLI_WEB_STATE_ROOT") or "").strip()
|
||||
if configured:
|
||||
return Path(configured).expanduser()
|
||||
return PROJECT_ROOT / ".ani-cli-web"
|
||||
|
||||
|
||||
PROJECT_APP_ROOT = project_app_root()
|
||||
|
||||
|
||||
def ensure_project_app_root():
|
||||
PROJECT_APP_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
return PROJECT_APP_ROOT
|
||||
|
||||
|
||||
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):
|
||||
return PROJECT_APP_ROOT.joinpath(*parts)
|
||||
|
||||
|
||||
def project_state_dir(*parts):
|
||||
return PROJECT_APP_ROOT.joinpath(*parts)
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
pass
|
||||
if moved:
|
||||
debug_log("state.migrated", label=label, from_path=legacy_dir, to_path=project_dir, entries=moved)
|
||||
return project_dir
|
||||
|
||||
|
||||
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"
|
||||
|
||||
CONFIG_PATH = project_state_path("config.json")
|
||||
QUEUE_PATH = project_state_path("queue.json")
|
||||
STATE_DB_PATH = project_state_path("state.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,
|
||||
"title_index": None,
|
||||
"token_index": None,
|
||||
"match_cache": {},
|
||||
}
|
||||
|
||||
|
||||
def migrate_legacy_state_storage():
|
||||
ensure_project_app_root()
|
||||
migrate_legacy_file(CONFIG_PATH, LEGACY_CONFIG_PATH, "config.json")
|
||||
migrate_legacy_file(QUEUE_PATH, LEGACY_QUEUE_PATH, "queue.json")
|
||||
migrate_legacy_file(STATE_DB_PATH, PROJECT_APP_ROOT / "queue.sqlite3", "queue.sqlite3")
|
||||
migrate_legacy_file(STATE_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_json(path, fallback):
|
||||
try:
|
||||
with 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_json(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2, sort_keys=True)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def normalize_config(data):
|
||||
config = dict(DEFAULT_CONFIG)
|
||||
if isinstance(data, dict):
|
||||
config.update({key: data[key] for key in DEFAULT_CONFIG if key in data})
|
||||
|
||||
mode = str(config.get("mode", "sub")).lower()
|
||||
quality = str(config.get("quality", "best")).lower()
|
||||
if quality.endswith("p") and quality[:-1].isdigit():
|
||||
quality = quality[:-1]
|
||||
download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"])
|
||||
|
||||
config["mode"] = mode if mode in MODE_CHOICES else "sub"
|
||||
config["quality"] = quality if quality in QUALITY_CHOICES else "best"
|
||||
config["download_dir"] = str(Path(download_dir).expanduser())
|
||||
return config
|
||||
|
||||
|
||||
def strip_control(value):
|
||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n")
|
||||
|
||||
|
||||
def printable_command(command):
|
||||
return " ".join(sh_quote(part) for part in command)
|
||||
|
||||
|
||||
def sh_quote(value):
|
||||
if re.match(r"^[A-Za-z0-9_./:=+-]+$", value):
|
||||
return value
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def validate_episode_spec(value):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError("Choose at least one episode")
|
||||
if not re.match(r"^[0-9.\-\s]+$", text):
|
||||
raise ValueError("Episodes can contain numbers, dots, spaces, and ranges")
|
||||
return re.sub(r"\s+", " ", text)
|
||||
|
||||
|
||||
def sanitize_path_component(value, fallback="Anime"):
|
||||
text = str(value or "").strip()
|
||||
text = re.sub(r'[\\/:*?"<>|]+', "-", text)
|
||||
text = re.sub(r"\s+", " ", text).strip(" .")
|
||||
return text or fallback
|
||||
|
||||
|
||||
def normalize_season(value):
|
||||
text = str(value or "1").strip()
|
||||
if not re.match(r"^[0-9]+$", text):
|
||||
raise ValueError("Season must be a positive number")
|
||||
number = int(text, 10)
|
||||
if number < 1:
|
||||
raise ValueError("Season must be a positive number")
|
||||
return str(number)
|
||||
|
||||
|
||||
def job_output_dir(job):
|
||||
anime_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
|
||||
return Path(job["download_dir"]).expanduser() / anime_name / f"Season {season_label(job)}"
|
||||
|
||||
|
||||
def job_staging_dir(job):
|
||||
return STAGING_ROOT / job["id"]
|
||||
|
||||
|
||||
def season_label(job):
|
||||
return f"{int(job.get('season') or 1):02d}"
|
||||
|
||||
|
||||
def episode_label(value):
|
||||
text = str(value or "").strip()
|
||||
match = re.match(r"^0*([0-9]+)(.*)$", text)
|
||||
if match:
|
||||
number = int(match.group(1) or "0", 10)
|
||||
suffix = re.sub(r"[^0-9A-Za-z.]+", "-", match.group(2)).strip("-")
|
||||
return f"{number:02d}{suffix}"
|
||||
return re.sub(r"[^0-9A-Za-z.]+", "-", text).strip("-") or "00"
|
||||
|
||||
|
||||
def extract_episode_from_filename(path):
|
||||
match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def unique_destination(path):
|
||||
if not path.exists():
|
||||
return path
|
||||
counter = 2
|
||||
while True:
|
||||
candidate = path.with_name(f"{path.stem} ({counter}){path.suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
counter += 1
|
||||
|
||||
|
||||
def finalize_library_files(job):
|
||||
staging_dir = job_staging_dir(job)
|
||||
target_dir = job_output_dir(job)
|
||||
library_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
|
||||
season = season_label(job)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
files = sorted(
|
||||
[path for path in staging_dir.iterdir() if path.is_file()],
|
||||
key=lambda path: (path.stat().st_mtime, path.name),
|
||||
)
|
||||
if not files:
|
||||
raise RuntimeError("No downloaded files were found in the staging folder")
|
||||
|
||||
moved = []
|
||||
fallback_episode = 1
|
||||
for source in files:
|
||||
ep = extract_episode_from_filename(source)
|
||||
if ep is None:
|
||||
ep = str(fallback_episode)
|
||||
fallback_episode += 1
|
||||
final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}"
|
||||
destination = unique_destination(target_dir / final_name)
|
||||
shutil.move(str(source), str(destination))
|
||||
moved.append(str(destination))
|
||||
|
||||
try:
|
||||
staging_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return moved
|
||||
|
||||
|
||||
def build_job(payload, config):
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
query = str(payload.get("query") or payload.get("title") or "").strip()
|
||||
title = str(payload.get("title") or query).strip()
|
||||
anime_name = sanitize_path_component(payload.get("anime_name") or title)
|
||||
if not query:
|
||||
raise ValueError("Missing search query")
|
||||
try:
|
||||
index = int(payload.get("index", 1))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Invalid result index") from exc
|
||||
if index < 1:
|
||||
raise ValueError("Result index must be positive")
|
||||
|
||||
mode = str(payload.get("mode") or config["mode"]).lower()
|
||||
quality = str(payload.get("quality") or config["quality"]).lower()
|
||||
download_dir = str(payload.get("download_dir") or config["download_dir"]).strip()
|
||||
if mode not in MODE_CHOICES:
|
||||
raise ValueError("Mode must be sub or dub")
|
||||
if quality not in QUALITY_CHOICES:
|
||||
raise ValueError("Unknown quality")
|
||||
|
||||
return {
|
||||
"id": uuid4().hex,
|
||||
"show_id": str(payload.get("show_id") or "").strip(),
|
||||
"title": title,
|
||||
"anime_name": anime_name,
|
||||
"season": normalize_season(payload.get("season")),
|
||||
"query": query,
|
||||
"result_index": index,
|
||||
"mode": mode,
|
||||
"quality": quality,
|
||||
"episodes": validate_episode_spec(payload.get("episodes")),
|
||||
"download_dir": str(Path(download_dir).expanduser()),
|
||||
"status": "pending",
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"exit_code": None,
|
||||
"pid": None,
|
||||
"log": [],
|
||||
}
|
||||
|
||||
|
||||
def command_for_job(job):
|
||||
command = [
|
||||
ANI_CLI,
|
||||
"-d",
|
||||
"-q",
|
||||
job["quality"],
|
||||
"-e",
|
||||
job["episodes"],
|
||||
"-S",
|
||||
str(job["result_index"]),
|
||||
]
|
||||
if job["mode"] == "dub":
|
||||
command.append("--dub")
|
||||
command.append(job["query"])
|
||||
return command
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Config page template for ani-cli-web."""
|
||||
|
||||
from template_helpers import BROWSER_API_HELPER, render_page_links, render_sidebar_brand
|
||||
|
||||
|
||||
CONFIG_HTML = r"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ani-cli web - config</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #090611;
|
||||
--bg-2: #140d21;
|
||||
--panel: rgba(25, 18, 41, 0.82);
|
||||
--panel-strong: rgba(18, 13, 31, 0.92);
|
||||
--panel-2: rgba(157, 123, 255, 0.12);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--line-strong: rgba(157, 123, 255, 0.26);
|
||||
--text: #f6f2ff;
|
||||
--muted: #b8b0cf;
|
||||
--accent: #9d7bff;
|
||||
--accent-strong: #7b5cff;
|
||||
--accent-soft: rgba(157, 123, 255, 0.14);
|
||||
--focus: #b296ff;
|
||||
--shadow: 0 24px 60px rgba(5, 2, 15, 0.42);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(117, 201, 255, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 "Segoe UI", Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
button, input, select {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
min-height: 40px;
|
||||
border-radius: 14px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--line-strong);
|
||||
background: rgba(157, 123, 255, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
border-color: transparent;
|
||||
color: #fcfaff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(10, 7, 18, 0.82);
|
||||
color: var(--text);
|
||||
padding: 0 13px;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
input:focus, select:focus {
|
||||
border-color: var(--focus);
|
||||
box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
aside, main {
|
||||
padding: 22px;
|
||||
}
|
||||
aside {
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(25, 18, 41, 0.94), rgba(14, 10, 24, 0.9));
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 20px;
|
||||
backdrop-filter: blur(18px) saturate(170%);
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
justify-items: start;
|
||||
}
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 {
|
||||
font-size: 29px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.topline, .toolbar, .row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topline, .toolbar {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.brand-wrap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.brand-word {
|
||||
color: var(--accent);
|
||||
}
|
||||
.shell-note {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
max-width: 28ch;
|
||||
}
|
||||
.row > * { flex: 1; }
|
||||
.page-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.page-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
.page-link.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 0 0 1px rgba(157, 123, 255, 0.22);
|
||||
}
|
||||
.page-link:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.settings, .deps {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.notice {
|
||||
min-height: 20px;
|
||||
color: #ddd0ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.badge {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.ok { color: #95e4ba; border-color: rgba(149, 228, 186, 0.24); }
|
||||
.badge.bad { color: #ffd0da; border-color: rgba(255, 144, 164, 0.22); }
|
||||
.field-hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.settings-main, .deps {
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; }
|
||||
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.toolbar, .row { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside>
|
||||
""" + render_sidebar_brand(
|
||||
"Tsuki-inspired settings view for saved defaults and runtime checks.",
|
||||
"Config",
|
||||
) + render_page_links("config") + r"""
|
||||
|
||||
<section class="settings">
|
||||
<h2>Saved defaults</h2>
|
||||
<div class="stack muted">
|
||||
<p>These values preload the Search page and act as the fallback folder for new queue jobs.</p>
|
||||
<p>Mode and quality can still be changed on the Search page before queueing a download.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings">
|
||||
<h2>Runtime info</h2>
|
||||
<div class="stack muted">
|
||||
<p id="versionLine">Loading version...</p>
|
||||
<p>Saved settings live in `.ani-cli-web/config.json` inside this project.</p>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<section class="settings settings-main">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>Defaults</h2>
|
||||
<p class="muted">Manage the folder, mode, and quality that the Search page starts with.</p>
|
||||
</div>
|
||||
<button class="primary" id="saveConfigBtn" type="button">Save</button>
|
||||
</div>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="stack">
|
||||
<label>Default folder
|
||||
<input id="downloadDir" placeholder="/home/me/Downloads/Anime">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>Default mode
|
||||
<select id="configMode">
|
||||
<option value="sub">Subtitled (Sub)</option>
|
||||
<option value="dub">Dubbed (Dub)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Default quality
|
||||
<select id="configQuality">
|
||||
<option value="best">Best</option>
|
||||
<option value="1080">1080p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="480">480p</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-hint">Tip: these are the saved defaults only. The active search form can still be adjusted per download.</div>
|
||||
</section>
|
||||
|
||||
<section class="deps" id="deps"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
config: { mode: "sub", quality: "best", download_dir: "" }
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
""" + BROWSER_API_HELPER + r"""
|
||||
|
||||
function setNotice(text) {
|
||||
$("notice").textContent = text || "";
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
state.config = await api("/api/config");
|
||||
$("downloadDir").value = state.config.download_dir;
|
||||
$("configMode").value = state.config.mode;
|
||||
$("configQuality").value = state.config.quality;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
const data = await api("/api/config", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
download_dir: $("downloadDir").value,
|
||||
mode: $("configMode").value,
|
||||
quality: $("configQuality").value
|
||||
})
|
||||
});
|
||||
state.config = data;
|
||||
$("downloadDir").value = data.download_dir;
|
||||
$("configMode").value = data.mode;
|
||||
$("configQuality").value = data.quality;
|
||||
setNotice("Defaults saved.");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeps() {
|
||||
const data = await api("/api/dependencies");
|
||||
const el = $("deps");
|
||||
el.innerHTML = "<h2>Dependencies</h2>";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "chips";
|
||||
for (const [name, ok] of Object.entries(data)) {
|
||||
const span = document.createElement("span");
|
||||
span.className = `badge ${ok ? "ok" : "bad"}`;
|
||||
span.textContent = `${name} ${ok ? "ok" : "missing"}`;
|
||||
wrap.appendChild(span);
|
||||
}
|
||||
el.appendChild(wrap);
|
||||
}
|
||||
|
||||
async function loadVersion() {
|
||||
const data = await api("/api/version");
|
||||
$("versionLine").textContent = `${data.name} ${data.version}`;
|
||||
}
|
||||
|
||||
$("saveConfigBtn").addEventListener("click", saveConfig);
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
await loadConfig();
|
||||
await loadDeps();
|
||||
await loadVersion();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""HTTP handler and route wiring for ani-cli-web."""
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from app_support import (
|
||||
ANI_CLI,
|
||||
APP_NAME,
|
||||
CLIENT_DISCONNECT_ERRORS,
|
||||
MAX_JSON_BODY_BYTES,
|
||||
MODE_CHOICES,
|
||||
VERSION,
|
||||
client_address_is_local,
|
||||
debug_enabled,
|
||||
debug_log,
|
||||
normalize_config,
|
||||
remote_access_allowed,
|
||||
remote_access_token_configured,
|
||||
remote_access_token_matches,
|
||||
)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HandlerContext:
|
||||
add_to_watchlist: object
|
||||
config_html: str
|
||||
ensure_runtime: object
|
||||
episode_list: object
|
||||
get_config_snapshot: object
|
||||
get_watchlist: object
|
||||
get_watchlist_refresh_status: object
|
||||
http_error: object
|
||||
index_html: str
|
||||
remove_from_watchlist: object
|
||||
runtime_state: object
|
||||
save_runtime_config: object
|
||||
search_anime: object
|
||||
thumbnail_file_path: object
|
||||
update_all_watchlist_statuses: object
|
||||
update_watchlist_category: object
|
||||
update_watchlist_status: object
|
||||
upload_watchlist_thumbnail: object
|
||||
watchlist_html: str
|
||||
|
||||
|
||||
def build_handler_class(context):
|
||||
class ConfiguredHandler(Handler):
|
||||
handler_context = context
|
||||
|
||||
return ConfiguredHandler
|
||||
|
||||
|
||||
def dependency_status():
|
||||
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
|
||||
result = {name: bool(shutil.which(name)) for name in checks}
|
||||
result["ani-cli"] = bool(shutil.which(ANI_CLI) or (Path(ANI_CLI).exists() and os.access(ANI_CLI, os.X_OK)))
|
||||
return result
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "AniCliWeb/1.0"
|
||||
|
||||
def _context(self):
|
||||
context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None)
|
||||
if context is None:
|
||||
raise RuntimeError("Handler context is not configured.")
|
||||
return context
|
||||
|
||||
def _runtime(self):
|
||||
context = Handler._context(self)
|
||||
context.ensure_runtime(start_workers=True)
|
||||
return context.runtime_state()
|
||||
|
||||
def _effective_client_host(self):
|
||||
peer_host = self.client_address[0] if self.client_address else ""
|
||||
if not client_address_is_local(peer_host):
|
||||
return peer_host
|
||||
forwarded = (
|
||||
self.headers.get("X-Forwarded-For")
|
||||
or self.headers.get("x-forwarded-for")
|
||||
or self.headers.get("X-Real-IP")
|
||||
or self.headers.get("x-real-ip")
|
||||
or ""
|
||||
)
|
||||
if forwarded:
|
||||
first = str(forwarded).split(",", 1)[0].strip().strip("[]")
|
||||
if first:
|
||||
return first
|
||||
forwarded_header = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip()
|
||||
if forwarded_header:
|
||||
for part in forwarded_header.split(";"):
|
||||
key, _, value = part.partition("=")
|
||||
if key.strip().lower() != "for":
|
||||
continue
|
||||
candidate = value.strip().strip('"')
|
||||
if candidate.startswith("[") and "]" in candidate:
|
||||
candidate = candidate[1 : candidate.index("]")]
|
||||
elif ":" in candidate and candidate.count(":") == 1:
|
||||
candidate = candidate.split(":", 1)[0]
|
||||
candidate = candidate.strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return peer_host
|
||||
|
||||
def _remote_token(self):
|
||||
return (
|
||||
self.headers.get("X-AniCli-Web-Token")
|
||||
or self.headers.get("x-ani-cli-web-token")
|
||||
or Handler._bearer_token(self)
|
||||
or ""
|
||||
)
|
||||
|
||||
def _bearer_token(self):
|
||||
header = str(self.headers.get("Authorization") or "").strip()
|
||||
if not header.startswith("Bearer "):
|
||||
return ""
|
||||
return header[len("Bearer ") :].strip()
|
||||
|
||||
def ensure_client_access(self):
|
||||
client_host = Handler._effective_client_host(self)
|
||||
if client_address_is_local(client_host):
|
||||
return
|
||||
if not remote_access_allowed():
|
||||
raise PermissionError(
|
||||
"Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients."
|
||||
)
|
||||
if not remote_access_token_configured():
|
||||
raise PermissionError(
|
||||
"Remote access requires ANI_CLI_WEB_REMOTE_TOKEN for non-local clients."
|
||||
)
|
||||
if not remote_access_token_matches(Handler._remote_token(self)):
|
||||
raise PermissionError("Remote access token missing or invalid.")
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
self.ensure_client_access()
|
||||
if parsed.path == "/":
|
||||
self.html(Handler._context(self).index_html)
|
||||
elif parsed.path == "/config":
|
||||
self.html(Handler._context(self).config_html)
|
||||
elif parsed.path == "/watchlist":
|
||||
self.html(Handler._context(self).watchlist_html)
|
||||
elif parsed.path == "/api/config":
|
||||
self.json(Handler._context(self).get_config_snapshot())
|
||||
elif parsed.path == "/api/version":
|
||||
self.json({"name": APP_NAME, "version": VERSION})
|
||||
elif parsed.path == "/api/dependencies":
|
||||
self.json(dependency_status())
|
||||
elif parsed.path == "/api/search":
|
||||
runtime = Handler._runtime(self)
|
||||
config = runtime["config"]
|
||||
params = parse_qs(parsed.query)
|
||||
query = (params.get("q") or [""])[0].strip()
|
||||
mode = (params.get("mode") or [config["mode"]])[0].lower()
|
||||
if not query:
|
||||
raise ValueError("Search query is required")
|
||||
if mode not in MODE_CHOICES:
|
||||
raise ValueError("Mode must be sub or dub")
|
||||
self.json({"results": Handler._context(self).search_anime(query, mode)})
|
||||
elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"):
|
||||
runtime = Handler._runtime(self)
|
||||
config = runtime["config"]
|
||||
show_id = unquote(parsed.path[len("/api/anime/") : -len("/episodes")])
|
||||
params = parse_qs(parsed.query)
|
||||
mode = (params.get("mode") or [config["mode"]])[0].lower()
|
||||
if mode not in MODE_CHOICES:
|
||||
raise ValueError("Mode must be sub or dub")
|
||||
self.json({"episodes": Handler._context(self).episode_list(show_id, mode)})
|
||||
elif parsed.path == "/api/queue":
|
||||
runtime = Handler._runtime(self)
|
||||
params = parse_qs(parsed.query)
|
||||
page = (params.get("page") or ["1"])[0]
|
||||
per_page = (params.get("per_page") or ["10"])[0]
|
||||
self.json(runtime["download_queue"].list(page=page, per_page=per_page))
|
||||
elif parsed.path.startswith("/api/watchlist/thumb/"):
|
||||
runtime = Handler._runtime(self)
|
||||
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
|
||||
debug_log("thumbnail.route.request", show_id=show_id, path=parsed.path, query=parsed.query)
|
||||
item = runtime["watchlist"].get(show_id)
|
||||
path = Handler._context(self).thumbnail_file_path(item.get("thumbnail_path"))
|
||||
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"}:
|
||||
Handler._runtime(self)
|
||||
params = parse_qs(parsed.query)
|
||||
page = (params.get("page") or ["1"])[0]
|
||||
per_page = (params.get("per_page") or ["30"])[0]
|
||||
category = (params.get("category") or [""])[0].strip().lower() or None
|
||||
self.json(Handler._context(self).get_watchlist(page=page, per_page=per_page, category=category))
|
||||
elif parsed.path == "/api/watchlist/refresh-status":
|
||||
Handler._runtime(self)
|
||||
self.json(Handler._context(self).get_watchlist_refresh_status())
|
||||
else:
|
||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||
except Exception as exc:
|
||||
self.exception(exc)
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
self.ensure_client_access()
|
||||
if parsed.path == "/api/config":
|
||||
self.update_config(self.body_json())
|
||||
elif parsed.path == "/api/queue":
|
||||
runtime = Handler._runtime(self)
|
||||
self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED)
|
||||
elif parsed.path == "/api/queue/retry-failed":
|
||||
runtime = Handler._runtime(self)
|
||||
self.json(runtime["download_queue"].retry_all_failed())
|
||||
elif parsed.path == "/api/queue/clear-finished":
|
||||
runtime = Handler._runtime(self)
|
||||
self.json(runtime["download_queue"].clear_finished())
|
||||
elif parsed.path.startswith("/api/queue/"):
|
||||
runtime = Handler._runtime(self)
|
||||
parts = parsed.path.strip("/").split("/")
|
||||
if len(parts) != 4:
|
||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||
return
|
||||
_, _, job_id, action = parts
|
||||
actions = {
|
||||
"retry": runtime["download_queue"].retry,
|
||||
"cancel": runtime["download_queue"].cancel,
|
||||
"remove": runtime["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"}:
|
||||
Handler._runtime(self)
|
||||
self.json(Handler._context(self).add_to_watchlist(self.body_json()), HTTPStatus.CREATED)
|
||||
elif parsed.path == "/api/watchlist/ensure-thumbnails":
|
||||
runtime = Handler._runtime(self)
|
||||
payload = self.body_json()
|
||||
self.json(
|
||||
runtime["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":
|
||||
Handler._runtime(self)
|
||||
payload = self.body_json()
|
||||
self.json(Handler._context(self).update_watchlist_status(payload["show_id"], payload.get("mode")))
|
||||
elif parsed.path == "/api/watchlist/update-category":
|
||||
Handler._runtime(self)
|
||||
payload = self.body_json()
|
||||
self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category")))
|
||||
elif parsed.path == "/api/watchlist/update-all":
|
||||
Handler._runtime(self)
|
||||
result = Handler._context(self).update_all_watchlist_statuses()
|
||||
status = HTTPStatus.ACCEPTED if result.get("created") else HTTPStatus.OK
|
||||
self.json(result, status)
|
||||
elif parsed.path == "/api/watchlist/remove":
|
||||
Handler._runtime(self)
|
||||
payload = self.body_json()
|
||||
self.json(Handler._context(self).remove_from_watchlist(payload["show_id"]))
|
||||
elif parsed.path == "/api/watchlist/upload-thumbnail":
|
||||
Handler._runtime(self)
|
||||
payload = self.body_json()
|
||||
self.json(Handler._context(self).upload_watchlist_thumbnail(payload))
|
||||
else:
|
||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||
except Exception as exc:
|
||||
self.exception(exc)
|
||||
|
||||
def update_config(self, payload):
|
||||
config = normalize_config(payload)
|
||||
Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
|
||||
context = Handler._context(self)
|
||||
context.save_runtime_config(config)
|
||||
self.json(context.get_config_snapshot())
|
||||
|
||||
def body_json(self):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid Content-Length header") from exc
|
||||
if length < 0:
|
||||
raise ValueError("Invalid Content-Length header")
|
||||
if length > MAX_JSON_BODY_BYTES:
|
||||
raise Handler._context(self).http_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.")
|
||||
raw_bytes = self.rfile.read(length) if length else b"{}"
|
||||
if len(raw_bytes) > MAX_JSON_BODY_BYTES:
|
||||
raise Handler._context(self).http_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.")
|
||||
try:
|
||||
raw = raw_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("Request body must be valid UTF-8 JSON.") from exc
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Invalid JSON body") from exc
|
||||
|
||||
def html(self, content):
|
||||
data = content.encode("utf-8")
|
||||
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.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):
|
||||
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, Handler._context(self).http_error):
|
||||
self.error(exc.status, exc.message)
|
||||
elif isinstance(exc, KeyError):
|
||||
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
|
||||
elif isinstance(exc, PermissionError):
|
||||
self.error(HTTPStatus.FORBIDDEN, str(exc))
|
||||
elif isinstance(exc, ValueError):
|
||||
self.error(HTTPStatus.BAD_REQUEST, str(exc))
|
||||
else:
|
||||
if debug_enabled():
|
||||
debug_log("handler.exception", path=getattr(self, "path", ""), error=exc)
|
||||
traceback.print_exc()
|
||||
else:
|
||||
debug_log("handler.exception", path=getattr(self, "path", ""), error=exc)
|
||||
self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.")
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"{self.address_string()} - {fmt % args}")
|
||||
|
||||
|
||||
def server_host():
|
||||
return os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
||||
|
||||
|
||||
def server_port():
|
||||
raw = str(os.environ.get("ANI_CLI_WEB_PORT", "8421")).strip() or "8421"
|
||||
try:
|
||||
port = int(raw, 10)
|
||||
except ValueError as exc:
|
||||
raise ValueError("ANI_CLI_WEB_PORT must be a valid integer") from exc
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("ANI_CLI_WEB_PORT must be between 1 and 65535")
|
||||
return port
|
||||
+840
@@ -0,0 +1,840 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Queue and background job services for ani-cli-web."""
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app_support import (
|
||||
MAX_LOG_LINES,
|
||||
PROJECT_ROOT,
|
||||
QUEUE_PATH,
|
||||
STATE_DB_PATH,
|
||||
build_job,
|
||||
command_for_job,
|
||||
finalize_library_files,
|
||||
job_output_dir,
|
||||
job_staging_dir,
|
||||
load_json,
|
||||
now_iso,
|
||||
printable_command,
|
||||
strip_control,
|
||||
debug_log,
|
||||
)
|
||||
|
||||
|
||||
LOG_PERSIST_INTERVAL_SECONDS = 0.75
|
||||
LOG_PERSIST_LINE_BATCH = 8
|
||||
WATCHLIST_REFRESH_MAX_WORKERS = 4
|
||||
WORKER_JOIN_TIMEOUT_SECONDS = 2.5
|
||||
JOB_STRUCTURED_COLUMNS = (
|
||||
"show_id",
|
||||
"title",
|
||||
"anime_name",
|
||||
"season",
|
||||
"query",
|
||||
"result_index",
|
||||
"mode",
|
||||
"quality",
|
||||
"episodes",
|
||||
"download_dir",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"exit_code",
|
||||
"pid",
|
||||
"command",
|
||||
"staging_dir",
|
||||
"target_dir",
|
||||
"cancel_requested",
|
||||
)
|
||||
JOB_LOG_COLUMN = "log_payload"
|
||||
JOB_EXTRA_PAYLOAD_COLUMN = "payload"
|
||||
|
||||
|
||||
class WatchlistRefreshJobs:
|
||||
def __init__(self, store, start_worker=True):
|
||||
self.store = store
|
||||
self.lock = threading.RLock()
|
||||
self.wakeup = threading.Event()
|
||||
self.stop_event = threading.Event()
|
||||
self.pending = []
|
||||
self.jobs = []
|
||||
self.current_job_id = None
|
||||
self.current_executor = None
|
||||
self._init_db()
|
||||
self._restore_interrupted_jobs()
|
||||
self._load_jobs()
|
||||
self.worker = None
|
||||
if start_worker:
|
||||
self.ensure_worker()
|
||||
|
||||
def _connect(self):
|
||||
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def ensure_worker(self):
|
||||
with self.lock:
|
||||
if self.worker is not None and self.worker.is_alive():
|
||||
return
|
||||
self.stop_event.clear()
|
||||
self.worker = threading.Thread(target=self._run, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def shutdown(self, wait=False):
|
||||
self.stop_event.set()
|
||||
executor = None
|
||||
with self.lock:
|
||||
executor = self.current_executor
|
||||
if executor is not None:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
self.wakeup.set()
|
||||
worker = self.worker
|
||||
if wait and worker is not None and worker.is_alive():
|
||||
worker.join(timeout=WORKER_JOIN_TIMEOUT_SECONDS)
|
||||
return worker is None or not worker.is_alive()
|
||||
|
||||
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_refresh_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_refresh_created_at ON watchlist_refresh_jobs(created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_refresh_status ON watchlist_refresh_jobs(status)")
|
||||
|
||||
def _job_from_row(self, row):
|
||||
payload = row["payload"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
return json.loads(payload)
|
||||
|
||||
def _upsert_job_conn(self, conn, job):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO watchlist_refresh_jobs (id, status, created_at, updated_at, payload)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at,
|
||||
payload = excluded.payload
|
||||
""",
|
||||
(
|
||||
job["id"],
|
||||
job["status"],
|
||||
job["created_at"],
|
||||
job["updated_at"],
|
||||
json.dumps(job, sort_keys=True),
|
||||
),
|
||||
)
|
||||
|
||||
def _trim_history_conn(self, conn):
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM watchlist_refresh_jobs ORDER BY created_at DESC LIMIT -1 OFFSET 10"
|
||||
).fetchall()
|
||||
if rows:
|
||||
conn.executemany("DELETE FROM watchlist_refresh_jobs WHERE id = ?", [(row["id"],) for row in rows])
|
||||
|
||||
def _save_job_locked(self, job):
|
||||
with self._connect() as conn:
|
||||
self._upsert_job_conn(conn, job)
|
||||
self._trim_history_conn(conn)
|
||||
|
||||
def _load_jobs(self):
|
||||
with self.lock, self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload FROM watchlist_refresh_jobs ORDER BY created_at DESC LIMIT 10"
|
||||
).fetchall()
|
||||
self.jobs = [self._job_from_row(row) for row in rows]
|
||||
|
||||
def _restore_interrupted_jobs(self):
|
||||
with self.lock, self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT payload FROM watchlist_refresh_jobs WHERE status IN ('pending', 'running')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
job = self._job_from_row(row)
|
||||
job["status"] = "interrupted"
|
||||
job["finished_at"] = now_iso()
|
||||
job["updated_at"] = job["finished_at"]
|
||||
job["current_title"] = None
|
||||
job["message"] = "Interrupted by web app restart."
|
||||
self._upsert_job_conn(conn, job)
|
||||
self._trim_history_conn(conn)
|
||||
|
||||
def _copy_job(self, job):
|
||||
return json.loads(json.dumps(job))
|
||||
|
||||
def _find_locked(self, job_id):
|
||||
for job in self.jobs:
|
||||
if job["id"] == job_id:
|
||||
return job
|
||||
return None
|
||||
|
||||
def status(self):
|
||||
with self.lock:
|
||||
job = next((candidate for candidate in self.jobs if candidate["status"] in {"pending", "running"}), None)
|
||||
if job is None:
|
||||
job = self.jobs[0] if self.jobs else None
|
||||
active = bool(job and job["status"] in {"pending", "running"})
|
||||
return {"job": self._copy_job(job) if job else None, "running": active}
|
||||
|
||||
def start(self):
|
||||
with self.lock:
|
||||
job = next((candidate for candidate in self.jobs if candidate["status"] in {"pending", "running"}), None)
|
||||
if job is not None:
|
||||
return {
|
||||
"message": "Watchlist refresh already queued." if job["status"] == "pending" else "Watchlist refresh already running.",
|
||||
"job": self._copy_job(job) if job else None,
|
||||
"created": False,
|
||||
}
|
||||
now = now_iso()
|
||||
job = {
|
||||
"id": os.urandom(16).hex(),
|
||||
"status": "pending",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"errors": 0,
|
||||
"current_title": None,
|
||||
"message": "Queued watchlist refresh.",
|
||||
}
|
||||
self.jobs.insert(0, job)
|
||||
self.jobs = self.jobs[:10]
|
||||
self._save_job_locked(job)
|
||||
self.pending.append(job["id"])
|
||||
self.wakeup.set()
|
||||
return {"message": "Watchlist refresh started.", "job": self._copy_job(job), "created": True}
|
||||
|
||||
def _next_pending(self):
|
||||
with self.lock:
|
||||
while self.pending:
|
||||
job_id = self.pending.pop(0)
|
||||
job = self._find_locked(job_id)
|
||||
if job is not None:
|
||||
self.current_job_id = job_id
|
||||
return job
|
||||
return None
|
||||
|
||||
def _run(self):
|
||||
while not self.stop_event.is_set():
|
||||
job = self._next_pending()
|
||||
if not job:
|
||||
self.wakeup.wait(2)
|
||||
self.wakeup.clear()
|
||||
continue
|
||||
self._run_job(job)
|
||||
|
||||
def _run_job(self, job):
|
||||
try:
|
||||
show_ids = self.store.all_show_ids()
|
||||
started_at = now_iso()
|
||||
with self.lock:
|
||||
job["status"] = "running"
|
||||
job["started_at"] = started_at
|
||||
job["updated_at"] = started_at
|
||||
job["total"] = len(show_ids)
|
||||
job["completed"] = 0
|
||||
job["errors"] = 0
|
||||
job["current_title"] = None
|
||||
job["message"] = f"Refreshing 0/{len(show_ids)} watchlist entries."
|
||||
self._save_job_locked(job)
|
||||
|
||||
def refresh_one(show_id):
|
||||
title = show_id
|
||||
errored = False
|
||||
skipped = False
|
||||
error_message = ""
|
||||
if self.stop_event.is_set():
|
||||
return {"title": title, "errored": False, "skipped": True, "error": ""}
|
||||
try:
|
||||
item = self.store.refresh(show_id, include_thumbnail=False)
|
||||
if item is None:
|
||||
skipped = True
|
||||
else:
|
||||
title = item.get("title") or title
|
||||
errored = item.get("status") == "error"
|
||||
error_message = str(item.get("status_message") or "").strip() if errored else ""
|
||||
except Exception as exc:
|
||||
errored = True
|
||||
error_message = str(exc)
|
||||
debug_log("watchlist.refresh_all.item_error", show_id=show_id, error=exc)
|
||||
return {"title": title, "errored": errored, "skipped": skipped, "error": error_message}
|
||||
|
||||
max_workers = max(1, min(WATCHLIST_REFRESH_MAX_WORKERS, len(show_ids) or 1))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
with self.lock:
|
||||
self.current_executor = executor
|
||||
futures = [executor.submit(refresh_one, show_id) for show_id in show_ids]
|
||||
for index, future in enumerate(concurrent.futures.as_completed(futures), start=1):
|
||||
result = future.result()
|
||||
title = result["title"]
|
||||
with self.lock:
|
||||
job["completed"] = index
|
||||
job["errors"] += 1 if result["errored"] else 0
|
||||
job["current_title"] = title
|
||||
job["updated_at"] = now_iso()
|
||||
if result["errored"] and result["error"]:
|
||||
job["last_error"] = result["error"]
|
||||
if result["skipped"]:
|
||||
job["message"] = f"Refreshing {index}/{len(show_ids)}: removed entry"
|
||||
else:
|
||||
job["message"] = f"Refreshing {index}/{len(show_ids)}: {title}"
|
||||
self._save_job_locked(job)
|
||||
with self.lock:
|
||||
self.current_executor = None
|
||||
|
||||
finished_at = now_iso()
|
||||
with self.lock:
|
||||
job["status"] = "done"
|
||||
job["finished_at"] = finished_at
|
||||
job["updated_at"] = finished_at
|
||||
job["current_title"] = None
|
||||
if job["errors"]:
|
||||
job["message"] = (
|
||||
f"Refreshed {job['completed']} watchlist entries with {job['errors']} refresh errors."
|
||||
)
|
||||
else:
|
||||
job["message"] = f"Refreshed {job['completed']} watchlist entries."
|
||||
self._save_job_locked(job)
|
||||
except Exception as exc:
|
||||
finished_at = now_iso()
|
||||
with self.lock:
|
||||
job["status"] = "failed"
|
||||
job["finished_at"] = finished_at
|
||||
job["updated_at"] = finished_at
|
||||
job["current_title"] = None
|
||||
job["message"] = f"Watchlist refresh failed: {exc}"
|
||||
self._save_job_locked(job)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.current_executor = None
|
||||
self.current_job_id = None
|
||||
|
||||
|
||||
class DownloadQueue:
|
||||
def __init__(self, config_getter, watchlist_sync_fn=None, job_prepare_fn=None, start_worker=True):
|
||||
self.config_getter = config_getter
|
||||
self.watchlist_sync_fn = watchlist_sync_fn
|
||||
self.job_prepare_fn = job_prepare_fn
|
||||
self.lock = threading.RLock()
|
||||
self.wakeup = threading.Event()
|
||||
self.stop_event = threading.Event()
|
||||
self.current_process = None
|
||||
self.current_job_id = None
|
||||
self.current_job = None
|
||||
self._init_db()
|
||||
self._migrate_json_queue()
|
||||
self._restore_interrupted_jobs()
|
||||
self.worker = None
|
||||
if start_worker:
|
||||
self.ensure_worker()
|
||||
|
||||
def _connect(self):
|
||||
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def ensure_worker(self):
|
||||
with self.lock:
|
||||
if self.worker is not None and self.worker.is_alive():
|
||||
return
|
||||
self.stop_event.clear()
|
||||
self.worker = threading.Thread(target=self._run, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def shutdown(self, wait=False, cancel_active=False):
|
||||
should_cancel = bool(cancel_active or wait)
|
||||
self.stop_event.set()
|
||||
if should_cancel:
|
||||
self._signal_active_process(signal.SIGTERM)
|
||||
self.wakeup.set()
|
||||
worker = self.worker
|
||||
if wait and worker is not None and worker.is_alive():
|
||||
worker.join(timeout=WORKER_JOIN_TIMEOUT_SECONDS)
|
||||
if worker.is_alive():
|
||||
self._signal_active_process(signal.SIGKILL)
|
||||
worker.join(timeout=WORKER_JOIN_TIMEOUT_SECONDS)
|
||||
return worker is None or not worker.is_alive()
|
||||
|
||||
def _signal_active_process(self, signum):
|
||||
with self.lock:
|
||||
process = self.current_process
|
||||
job = self.current_job
|
||||
if process is None:
|
||||
return False
|
||||
if job is not None:
|
||||
job["cancel_requested"] = True
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signum)
|
||||
return True
|
||||
except OSError:
|
||||
try:
|
||||
if signum == signal.SIGKILL:
|
||||
process.kill()
|
||||
else:
|
||||
process.terminate()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
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 jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
show_id TEXT,
|
||||
title TEXT,
|
||||
anime_name TEXT,
|
||||
season TEXT,
|
||||
query TEXT,
|
||||
result_index INTEGER,
|
||||
mode TEXT,
|
||||
quality TEXT,
|
||||
episodes TEXT,
|
||||
download_dir TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
exit_code INTEGER,
|
||||
pid INTEGER,
|
||||
command TEXT,
|
||||
staging_dir TEXT,
|
||||
target_dir TEXT,
|
||||
cancel_requested INTEGER NOT NULL DEFAULT 0,
|
||||
log_payload TEXT NOT NULL DEFAULT '[]',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {row["name"] for row in conn.execute("PRAGMA table_info(jobs)").fetchall()}
|
||||
alter_statements = (
|
||||
("show_id", "ALTER TABLE jobs ADD COLUMN show_id TEXT"),
|
||||
("title", "ALTER TABLE jobs ADD COLUMN title TEXT"),
|
||||
("anime_name", "ALTER TABLE jobs ADD COLUMN anime_name TEXT"),
|
||||
("season", "ALTER TABLE jobs ADD COLUMN season TEXT"),
|
||||
("query", "ALTER TABLE jobs ADD COLUMN query TEXT"),
|
||||
("result_index", "ALTER TABLE jobs ADD COLUMN result_index INTEGER"),
|
||||
("mode", "ALTER TABLE jobs ADD COLUMN mode TEXT"),
|
||||
("quality", "ALTER TABLE jobs ADD COLUMN quality TEXT"),
|
||||
("episodes", "ALTER TABLE jobs ADD COLUMN episodes TEXT"),
|
||||
("download_dir", "ALTER TABLE jobs ADD COLUMN download_dir TEXT"),
|
||||
("started_at", "ALTER TABLE jobs ADD COLUMN started_at TEXT"),
|
||||
("finished_at", "ALTER TABLE jobs ADD COLUMN finished_at TEXT"),
|
||||
("exit_code", "ALTER TABLE jobs ADD COLUMN exit_code INTEGER"),
|
||||
("pid", "ALTER TABLE jobs ADD COLUMN pid INTEGER"),
|
||||
("command", "ALTER TABLE jobs ADD COLUMN command TEXT"),
|
||||
("staging_dir", "ALTER TABLE jobs ADD COLUMN staging_dir TEXT"),
|
||||
("target_dir", "ALTER TABLE jobs ADD COLUMN target_dir TEXT"),
|
||||
("cancel_requested", "ALTER TABLE jobs ADD COLUMN cancel_requested INTEGER NOT NULL DEFAULT 0"),
|
||||
(JOB_LOG_COLUMN, "ALTER TABLE jobs ADD COLUMN log_payload TEXT NOT NULL DEFAULT '[]'"),
|
||||
)
|
||||
for column, statement in alter_statements:
|
||||
if column not in columns:
|
||||
conn.execute(statement)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at)")
|
||||
|
||||
def _migrate_json_queue(self):
|
||||
if not QUEUE_PATH.exists():
|
||||
return
|
||||
jobs = load_json(QUEUE_PATH, [])
|
||||
if not jobs:
|
||||
QUEUE_PATH.rename(QUEUE_PATH.with_suffix(".json.migrated"))
|
||||
return
|
||||
with self.lock, self._connect() as conn:
|
||||
existing = conn.execute("SELECT COUNT(*) AS count FROM jobs").fetchone()["count"]
|
||||
if existing:
|
||||
return
|
||||
for job in jobs:
|
||||
if isinstance(job, dict) and job.get("id"):
|
||||
self._upsert_job_conn(conn, job)
|
||||
QUEUE_PATH.rename(QUEUE_PATH.with_suffix(".json.migrated"))
|
||||
|
||||
def _restore_interrupted_jobs(self):
|
||||
with self.lock, self._connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM jobs WHERE status = 'running'").fetchall()
|
||||
for row in rows:
|
||||
job = self._job_from_row(row)
|
||||
job["status"] = "pending"
|
||||
job["pid"] = None
|
||||
job.setdefault("log", []).append("Restarted after web app restart.")
|
||||
self._upsert_job_conn(conn, job)
|
||||
|
||||
def _job_from_row(self, row):
|
||||
if not isinstance(row, sqlite3.Row):
|
||||
return json.loads(row[0])
|
||||
raw = dict(row)
|
||||
payload = raw.get(JOB_EXTRA_PAYLOAD_COLUMN) or "{}"
|
||||
try:
|
||||
job = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
job = {}
|
||||
if not isinstance(job, dict):
|
||||
job = {}
|
||||
log_payload = raw.get(JOB_LOG_COLUMN) or "[]"
|
||||
try:
|
||||
log_lines = json.loads(log_payload)
|
||||
except json.JSONDecodeError:
|
||||
log_lines = []
|
||||
if not isinstance(log_lines, list):
|
||||
log_lines = []
|
||||
for key in ("id", "status", "created_at", "updated_at"):
|
||||
value = raw.get(key)
|
||||
if value is not None:
|
||||
job[key] = value
|
||||
for key in JOB_STRUCTURED_COLUMNS:
|
||||
value = raw.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if key == "cancel_requested":
|
||||
job[key] = bool(int(value or 0))
|
||||
else:
|
||||
job[key] = value
|
||||
job["log"] = [str(line) for line in log_lines][-MAX_LOG_LINES:]
|
||||
return job
|
||||
|
||||
def _extra_job_payload(self, job):
|
||||
clean = dict(job)
|
||||
clean.pop("process", None)
|
||||
clean.pop("_last_persist_monotonic", None)
|
||||
clean.pop("_dirty_log_lines", None)
|
||||
clean.pop("log", None)
|
||||
for key in ("id", "status", "created_at", "updated_at"):
|
||||
clean.pop(key, None)
|
||||
for key in JOB_STRUCTURED_COLUMNS:
|
||||
clean.pop(key, None)
|
||||
return clean
|
||||
|
||||
def _upsert_job_conn(self, conn, job):
|
||||
log_lines = [str(line) for line in job.get("log", [])][-MAX_LOG_LINES:]
|
||||
extra_payload = self._extra_job_payload(job)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (
|
||||
id, status, created_at, updated_at,
|
||||
show_id, title, anime_name, season, query, result_index, mode, quality, episodes, download_dir,
|
||||
started_at, finished_at, exit_code, pid, command, staging_dir, target_dir, cancel_requested,
|
||||
log_payload, payload
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at,
|
||||
show_id = excluded.show_id,
|
||||
title = excluded.title,
|
||||
anime_name = excluded.anime_name,
|
||||
season = excluded.season,
|
||||
query = excluded.query,
|
||||
result_index = excluded.result_index,
|
||||
mode = excluded.mode,
|
||||
quality = excluded.quality,
|
||||
episodes = excluded.episodes,
|
||||
download_dir = excluded.download_dir,
|
||||
started_at = excluded.started_at,
|
||||
finished_at = excluded.finished_at,
|
||||
exit_code = excluded.exit_code,
|
||||
pid = excluded.pid,
|
||||
command = excluded.command,
|
||||
staging_dir = excluded.staging_dir,
|
||||
target_dir = excluded.target_dir,
|
||||
cancel_requested = excluded.cancel_requested,
|
||||
log_payload = excluded.log_payload,
|
||||
payload = excluded.payload
|
||||
""",
|
||||
(
|
||||
job["id"],
|
||||
job.get("status", "pending"),
|
||||
job.get("created_at") or now_iso(),
|
||||
job.get("updated_at") or now_iso(),
|
||||
job.get("show_id"),
|
||||
job.get("title"),
|
||||
job.get("anime_name"),
|
||||
job.get("season"),
|
||||
job.get("query"),
|
||||
job.get("result_index"),
|
||||
job.get("mode"),
|
||||
job.get("quality"),
|
||||
job.get("episodes"),
|
||||
job.get("download_dir"),
|
||||
job.get("started_at"),
|
||||
job.get("finished_at"),
|
||||
job.get("exit_code"),
|
||||
job.get("pid"),
|
||||
job.get("command"),
|
||||
job.get("staging_dir"),
|
||||
job.get("target_dir"),
|
||||
1 if job.get("cancel_requested") else 0,
|
||||
json.dumps(log_lines),
|
||||
json.dumps(extra_payload, sort_keys=True),
|
||||
),
|
||||
)
|
||||
|
||||
def _save_job_locked(self, job):
|
||||
with self._connect() as conn:
|
||||
self._upsert_job_conn(conn, job)
|
||||
job["_last_persist_monotonic"] = time.monotonic()
|
||||
job["_dirty_log_lines"] = 0
|
||||
|
||||
def list(self, page=1, per_page=10):
|
||||
page = max(1, int(page or 1))
|
||||
per_page = min(50, max(1, int(per_page or 10)))
|
||||
offset = (page - 1) * per_page
|
||||
with self.lock, self._connect() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) AS count FROM jobs").fetchone()["count"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(per_page, offset),
|
||||
).fetchall()
|
||||
pages = max(1, (total + per_page - 1) // per_page)
|
||||
return {
|
||||
"jobs": [self._job_from_row(row) for row in rows],
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
def add(self, payload):
|
||||
job = build_job(payload, self.config_getter())
|
||||
if self.job_prepare_fn is not None:
|
||||
prepared = self.job_prepare_fn(dict(job))
|
||||
if prepared is not None:
|
||||
job = prepared
|
||||
with self.lock:
|
||||
self._save_job_locked(job)
|
||||
self.wakeup.set()
|
||||
return job
|
||||
|
||||
def retry(self, job_id):
|
||||
with self.lock:
|
||||
job = self._find(job_id)
|
||||
if job["status"] == "running":
|
||||
raise ValueError("Running jobs cannot be retried")
|
||||
job["status"] = "pending"
|
||||
job["exit_code"] = None
|
||||
job["pid"] = None
|
||||
job["started_at"] = None
|
||||
job["finished_at"] = None
|
||||
job["updated_at"] = now_iso()
|
||||
job["log"] = []
|
||||
self._save_job_locked(job)
|
||||
self.wakeup.set()
|
||||
return job
|
||||
|
||||
def retry_all_failed(self):
|
||||
count = 0
|
||||
with self.lock, self._connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM jobs WHERE status = 'failed'").fetchall()
|
||||
for row in rows:
|
||||
job = self._job_from_row(row)
|
||||
job["status"] = "pending"
|
||||
job["exit_code"] = None
|
||||
job["pid"] = None
|
||||
job["started_at"] = None
|
||||
job["finished_at"] = None
|
||||
job["updated_at"] = now_iso()
|
||||
job["log"] = []
|
||||
self._upsert_job_conn(conn, job)
|
||||
count += 1
|
||||
if count:
|
||||
self.wakeup.set()
|
||||
return {"ok": True, "count": count}
|
||||
|
||||
def cancel(self, job_id):
|
||||
with self.lock:
|
||||
job = self._find(job_id)
|
||||
if job["status"] == "pending":
|
||||
job["status"] = "canceled"
|
||||
job["updated_at"] = now_iso()
|
||||
self._save_job_locked(job)
|
||||
return job
|
||||
if job["status"] != "running":
|
||||
return job
|
||||
if self.current_job_id == job_id and self.current_process:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM)
|
||||
except OSError:
|
||||
self.current_process.terminate()
|
||||
updated_at = now_iso()
|
||||
job["cancel_requested"] = True
|
||||
job["updated_at"] = updated_at
|
||||
if self.current_job is not None and self.current_job.get("id") == job_id:
|
||||
self.current_job["cancel_requested"] = True
|
||||
self.current_job["updated_at"] = updated_at
|
||||
self._save_job_locked(job)
|
||||
return job
|
||||
raise ValueError("Could not find the running process")
|
||||
|
||||
def remove(self, job_id):
|
||||
with self.lock:
|
||||
job = self._find(job_id)
|
||||
if job["status"] == "running":
|
||||
raise ValueError("Running jobs must be canceled before removing")
|
||||
with self._connect() as conn:
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
||||
return {"ok": True}
|
||||
|
||||
def clear_finished(self):
|
||||
with self.lock, self._connect() as conn:
|
||||
cursor = conn.execute("DELETE FROM jobs WHERE status IN ('done', 'canceled')")
|
||||
return {"ok": True, "count": cursor.rowcount}
|
||||
|
||||
def _find(self, job_id):
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
if row:
|
||||
return self._job_from_row(row)
|
||||
raise KeyError("Job not found")
|
||||
|
||||
def _next_pending(self):
|
||||
with self.lock:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
|
||||
).fetchone()
|
||||
if row:
|
||||
return self._job_from_row(row)
|
||||
return None
|
||||
|
||||
def _append_log(self, job, line):
|
||||
text = strip_control(line).strip()
|
||||
if not text:
|
||||
return
|
||||
with self.lock:
|
||||
job.setdefault("log", []).append(text)
|
||||
job["log"] = job["log"][-MAX_LOG_LINES:]
|
||||
job["updated_at"] = now_iso()
|
||||
job["_dirty_log_lines"] = int(job.get("_dirty_log_lines") or 0) + 1
|
||||
last_persist = float(job.get("_last_persist_monotonic") or 0.0)
|
||||
should_flush = (
|
||||
job["_dirty_log_lines"] >= LOG_PERSIST_LINE_BATCH
|
||||
or (time.monotonic() - last_persist) >= LOG_PERSIST_INTERVAL_SECONDS
|
||||
)
|
||||
if should_flush:
|
||||
self._save_job_locked(job)
|
||||
|
||||
def _run(self):
|
||||
while not self.stop_event.is_set():
|
||||
job = self._next_pending()
|
||||
if not job:
|
||||
self.wakeup.wait(2)
|
||||
self.wakeup.clear()
|
||||
continue
|
||||
self._run_job(job)
|
||||
|
||||
def _run_job(self, job):
|
||||
command = command_for_job(job)
|
||||
staging_dir = job_staging_dir(job)
|
||||
target_dir = job_output_dir(job)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
|
||||
"ANI_CLI_MODE": job["mode"],
|
||||
"ANI_CLI_QUALITY": job["quality"],
|
||||
"TERM": env.get("TERM", "xterm-256color"),
|
||||
}
|
||||
)
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with self.lock:
|
||||
job["status"] = "running"
|
||||
job["started_at"] = now_iso()
|
||||
job["updated_at"] = job["started_at"]
|
||||
job["command"] = printable_command(command)
|
||||
job["staging_dir"] = str(staging_dir)
|
||||
job["target_dir"] = str(target_dir)
|
||||
job["log"] = [
|
||||
f"Starting: {job['command']}",
|
||||
f"Staging in: {job['staging_dir']}",
|
||||
f"Final folder: {job['target_dir']}",
|
||||
]
|
||||
job["cancel_requested"] = False
|
||||
job["_dirty_log_lines"] = 0
|
||||
self._save_job_locked(job)
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
with self.lock:
|
||||
self.current_process = process
|
||||
self.current_job_id = job["id"]
|
||||
self.current_job = job
|
||||
job["pid"] = process.pid
|
||||
self._save_job_locked(job)
|
||||
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
self._append_log(job, line)
|
||||
exit_code = process.wait()
|
||||
finalize_error = None
|
||||
moved_files = []
|
||||
watchlist_sync_error = None
|
||||
if exit_code == 0 and not job.get("cancel_requested"):
|
||||
try:
|
||||
moved_files = finalize_library_files(job)
|
||||
except Exception as exc:
|
||||
finalize_error = exc
|
||||
else:
|
||||
if self.watchlist_sync_fn is not None:
|
||||
try:
|
||||
self.watchlist_sync_fn(job)
|
||||
except Exception as exc:
|
||||
watchlist_sync_error = exc
|
||||
|
||||
with self.lock:
|
||||
canceled = job.get("cancel_requested")
|
||||
job["exit_code"] = 1 if finalize_error else exit_code
|
||||
job["pid"] = None
|
||||
job["finished_at"] = now_iso()
|
||||
job["updated_at"] = job["finished_at"]
|
||||
if canceled:
|
||||
job["status"] = "canceled"
|
||||
job.setdefault("log", []).append("Canceled.")
|
||||
elif finalize_error:
|
||||
job["status"] = "failed"
|
||||
job.setdefault("log", []).append(f"Library rename failed: {finalize_error}")
|
||||
elif exit_code == 0:
|
||||
job["status"] = "done"
|
||||
for path in moved_files:
|
||||
job.setdefault("log", []).append(f"Saved: {path}")
|
||||
job.setdefault("log", []).append("Download completed.")
|
||||
if watchlist_sync_error:
|
||||
job.setdefault("log", []).append(f"Watchlist sync failed: {watchlist_sync_error}")
|
||||
elif self.watchlist_sync_fn is not None:
|
||||
job.setdefault("log", []).append("Watchlist download state synced.")
|
||||
else:
|
||||
job["status"] = "failed"
|
||||
job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.")
|
||||
self.current_process = None
|
||||
self.current_job_id = None
|
||||
self.current_job = None
|
||||
self._save_job_locked(job)
|
||||
+778
@@ -0,0 +1,778 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Search page template for ani-cli-web."""
|
||||
|
||||
from template_helpers import BROWSER_API_HELPER, BROWSER_POLL_HELPER, render_page_links, render_sidebar_brand
|
||||
|
||||
|
||||
INDEX_HTML = r"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ani-cli web</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #090611;
|
||||
--bg-2: #140d21;
|
||||
--panel: rgba(25, 18, 41, 0.82);
|
||||
--panel-strong: rgba(18, 13, 31, 0.92);
|
||||
--panel-2: rgba(157, 123, 255, 0.12);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--line-strong: rgba(157, 123, 255, 0.26);
|
||||
--text: #f6f2ff;
|
||||
--muted: #b8b0cf;
|
||||
--accent: #9d7bff;
|
||||
--accent-strong: #7b5cff;
|
||||
--accent-soft: rgba(157, 123, 255, 0.14);
|
||||
--good: #95e4ba;
|
||||
--warn: #f0c67a;
|
||||
--bad: #ff90a4;
|
||||
--focus: #b296ff;
|
||||
--shadow: 0 24px 60px rgba(5, 2, 15, 0.42);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(117, 201, 255, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 "Segoe UI", Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
button, input, select {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
min-height: 40px;
|
||||
border-radius: 14px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--line-strong);
|
||||
background: rgba(157, 123, 255, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
border-color: transparent;
|
||||
color: #fcfaff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
button.danger {
|
||||
border-color: rgba(255, 144, 164, 0.3);
|
||||
background: rgba(255, 144, 164, 0.12);
|
||||
color: #ffd7df;
|
||||
}
|
||||
button.danger:hover {
|
||||
border-color: rgba(255, 144, 164, 0.48);
|
||||
background: rgba(255, 144, 164, 0.18);
|
||||
}
|
||||
button.danger {
|
||||
border-color: rgba(255, 144, 164, 0.28);
|
||||
color: #ffd7df;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(10, 7, 18, 0.82);
|
||||
color: var(--text);
|
||||
padding: 0 13px;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
input:focus, select:focus {
|
||||
border-color: var(--focus);
|
||||
box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
aside, main {
|
||||
padding: 22px;
|
||||
}
|
||||
aside {
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(25, 18, 41, 0.94), rgba(14, 10, 24, 0.9));
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 20px;
|
||||
backdrop-filter: blur(18px) saturate(170%);
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 {
|
||||
font-size: 29px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h3 { font-size: 15px; }
|
||||
.topline, .row, .controls, .toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.topline, .toolbar { justify-content: space-between; }
|
||||
.brand-wrap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.brand-word {
|
||||
color: var(--accent);
|
||||
}
|
||||
.shell-note {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
max-width: 28ch;
|
||||
}
|
||||
.row > * { flex: 1; }
|
||||
.controls {
|
||||
align-items: end;
|
||||
}
|
||||
.search-box {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(31, 24, 50, 0.86), rgba(17, 13, 29, 0.92));
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.page-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.page-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
.page-link.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 0 0 1px rgba(157, 123, 255, 0.22);
|
||||
}
|
||||
.page-link:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.segmented button {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.segmented button.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.results, .queue {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.result, .job, .settings, .episodes-panel, .deps {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.result {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.result:hover {
|
||||
border-color: var(--line-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.result.active {
|
||||
border-color: rgba(157, 123, 255, 0.34);
|
||||
background: linear-gradient(180deg, rgba(48, 36, 80, 0.96), rgba(20, 15, 35, 0.96));
|
||||
}
|
||||
.result-title {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings, .episodes-panel, .deps {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 168px;
|
||||
overflow: auto;
|
||||
}
|
||||
.chip, .badge {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.ok { color: var(--good); border-color: rgba(149, 228, 186, 0.24); }
|
||||
.badge.bad { color: #ffd0da; border-color: rgba(255, 144, 164, 0.22); }
|
||||
.job {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
transition: border-color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
.job:hover {
|
||||
border-color: var(--line-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.job-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
.job-title {
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.status {
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--muted);
|
||||
}
|
||||
.status.running {
|
||||
color: #fcfaff;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
}
|
||||
.status.done { color: #082312; background: var(--good); }
|
||||
.status.failed { color: #2d0910; background: var(--bad); }
|
||||
.status.canceled { color: #2d1c00; background: var(--warn); }
|
||||
pre {
|
||||
margin: 0;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(7, 5, 12, 0.86);
|
||||
color: #d6d0e7;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty {
|
||||
border: 1px dashed rgba(157, 123, 255, 0.22);
|
||||
border-radius: 22px;
|
||||
color: var(--muted);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: rgba(157, 123, 255, 0.06);
|
||||
}
|
||||
.notice {
|
||||
min-height: 20px;
|
||||
color: #ddd0ff;
|
||||
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); }
|
||||
.controls, .row, .toolbar { align-items: stretch; flex-direction: column; }
|
||||
.job-head { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside>
|
||||
""" + render_sidebar_brand(
|
||||
"Tsuki-inspired control center for anime search and download flow.",
|
||||
"Search",
|
||||
badge_id="serverBadge",
|
||||
) + render_page_links("search") + r"""
|
||||
|
||||
<div class="search-box">
|
||||
<label for="search-query">Anime Title / Keyword</label>
|
||||
<input type="text" id="search-query" placeholder="e.g., Attack on Titan">
|
||||
|
||||
<label for="search-mode">Mode</label>
|
||||
<select id="search-mode">
|
||||
<option value="sub">Subtitled (Sub)</option>
|
||||
<option value="dub">Dubbed (Dub)</option>
|
||||
</select>
|
||||
|
||||
<label for="search-quality">Quality</label>
|
||||
<select id="search-quality">
|
||||
<option value="best">Best</option>
|
||||
<option value="1080">1080p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="480">480p</option>
|
||||
</select>
|
||||
|
||||
<button class="primary" id="searchBtn" type="button">Search</button>
|
||||
</div>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="results" id="results"></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<section class="episodes-panel" id="episodesPanel">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 id="selectedTitle">Select a result</h2>
|
||||
<p class="muted" id="selectedMeta">Episodes will appear here.</p>
|
||||
</div>
|
||||
<div class="row" style="flex:0 0 auto">
|
||||
<label style="min-width: 180px;">Watchlist category
|
||||
<select id="trackCategory">
|
||||
<option value="watching">Watching</option>
|
||||
<option value="planned">Planned</option>
|
||||
<option value="finished">Finished</option>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="ghost" id="trackBtn" type="button" disabled>Add to watchlist</button>
|
||||
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Library name
|
||||
<input id="animeNameInput" placeholder="Anime name">
|
||||
</label>
|
||||
<label>Season
|
||||
<input id="seasonInput" inputmode="numeric" pattern="[0-9]*" value="1">
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Episodes
|
||||
<input id="episodesInput" placeholder="1-12">
|
||||
</label>
|
||||
<label>Download folder
|
||||
<input id="jobFolder" placeholder="/home/me/Downloads/Anime">
|
||||
</label>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="chips" id="episodeChips"></div>
|
||||
<div class="row" style="flex:0 0 auto">
|
||||
<button class="ghost" id="firstEpBtn" type="button">First</button>
|
||||
<button class="ghost" id="latestEpBtn" type="button">Latest</button>
|
||||
<button class="ghost" id="allEpBtn" type="button">All</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="toolbar">
|
||||
<h2>Download queue</h2>
|
||||
<div class="row" style="flex:0 0 auto">
|
||||
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button>
|
||||
<button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue" id="queue"></div>
|
||||
<div class="toolbar" id="queuePager"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
config: { mode: "sub", quality: "best", download_dir: "" },
|
||||
selected: null,
|
||||
episodes: [],
|
||||
queuePage: 1,
|
||||
queuePages: 1,
|
||||
queueTotal: 0,
|
||||
queueTimer: null,
|
||||
queueRequestId: 0,
|
||||
searchRequestId: 0,
|
||||
episodeRequestId: 0
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r"""
|
||||
|
||||
function setNotice(text) {
|
||||
const notice = $("notice");
|
||||
if (notice) notice.textContent = text || "";
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
state.config.mode = mode;
|
||||
$("search-mode").value = mode;
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const el = $("results");
|
||||
el.innerHTML = "";
|
||||
if (!results.length) {
|
||||
el.innerHTML = '<div class="empty">No matching anime found.</div>';
|
||||
return;
|
||||
}
|
||||
for (const item of results) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "result";
|
||||
btn.innerHTML = `
|
||||
<span class="result-title"></span>
|
||||
<span class="muted"></span>
|
||||
`;
|
||||
btn.querySelector(".result-title").textContent = item.title;
|
||||
btn.querySelector(".muted").textContent = `${item.episodes} episodes · result ${item.index}`;
|
||||
btn.addEventListener("click", () => selectResult(item, btn));
|
||||
el.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectResult(item, button) {
|
||||
const requestId = ++state.episodeRequestId;
|
||||
document.querySelectorAll(".result").forEach((node) => node.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
state.selected = item;
|
||||
state.episodes = [];
|
||||
$("selectedTitle").textContent = item.title;
|
||||
$("animeNameInput").value = item.title;
|
||||
$("seasonInput").value = $("seasonInput").value || "1";
|
||||
$("selectedMeta").textContent = "Loading episodes...";
|
||||
$("episodeChips").innerHTML = "";
|
||||
$("addBtn").disabled = true;
|
||||
$("trackBtn").disabled = false;
|
||||
try {
|
||||
const data = await api(`/api/anime/${encodeURIComponent(item.id)}/episodes?mode=${state.config.mode}`);
|
||||
if (requestId !== state.episodeRequestId || state.selected !== item) return;
|
||||
state.episodes = data.episodes;
|
||||
const first = state.episodes[0] || "";
|
||||
const last = state.episodes[state.episodes.length - 1] || "";
|
||||
$("episodesInput").value = first && last ? `${first}-${last}` : "";
|
||||
$("selectedMeta").textContent = `${state.episodes.length} available episodes`;
|
||||
renderEpisodeChips();
|
||||
$("addBtn").disabled = !state.episodes.length;
|
||||
} catch (error) {
|
||||
if (requestId !== state.episodeRequestId || state.selected !== item) return;
|
||||
$("selectedMeta").textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEpisodeChips() {
|
||||
const el = $("episodeChips");
|
||||
el.innerHTML = "";
|
||||
for (const ep of state.episodes.slice(0, 90)) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "chip";
|
||||
chip.textContent = ep;
|
||||
chip.addEventListener("click", () => { $("episodesInput").value = ep; });
|
||||
el.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const requestId = ++state.searchRequestId;
|
||||
const query = $("search-query").value.trim();
|
||||
if (!query) return setNotice("Type a title first.");
|
||||
setMode($("search-mode").value);
|
||||
setNotice("Searching...");
|
||||
state.selected = null;
|
||||
state.episodeRequestId += 1;
|
||||
$("addBtn").disabled = true;
|
||||
$("trackBtn").disabled = true;
|
||||
$("results").innerHTML = "";
|
||||
try {
|
||||
const data = await api(`/api/search?q=${encodeURIComponent(query)}&mode=${state.config.mode}`);
|
||||
if (requestId !== state.searchRequestId) return;
|
||||
renderResults(data.results);
|
||||
setNotice(`${data.results.length} results`);
|
||||
} catch (error) {
|
||||
if (requestId !== state.searchRequestId) return;
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function addSelectedToWatchlist() {
|
||||
if (!state.selected) {
|
||||
setNotice("Select a search result first.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api("/api/watchlist", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
show_id: state.selected.id,
|
||||
title: state.selected.title,
|
||||
category: $("trackCategory").value
|
||||
})
|
||||
});
|
||||
setNotice(data.message || "Added to watchlist.");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function addSelected() {
|
||||
if (!state.selected) return;
|
||||
const payload = {
|
||||
show_id: state.selected.id,
|
||||
query: state.selected.query,
|
||||
title: state.selected.title,
|
||||
anime_name: $("animeNameInput").value || state.selected.title,
|
||||
season: $("seasonInput").value || "1",
|
||||
index: state.selected.index,
|
||||
mode: state.config.mode,
|
||||
quality: $("search-quality").value,
|
||||
episodes: $("episodesInput").value,
|
||||
download_dir: $("jobFolder").value || state.config.download_dir
|
||||
};
|
||||
try {
|
||||
await api("/api/queue", { method: "POST", body: JSON.stringify(payload) });
|
||||
setNotice("Added to queue.");
|
||||
await loadQueue();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderQueue(data) {
|
||||
const jobs = data.jobs || [];
|
||||
state.queuePage = data.page || 1;
|
||||
state.queuePages = data.pages || 1;
|
||||
state.queueTotal = data.total || 0;
|
||||
const el = $("queue");
|
||||
el.innerHTML = "";
|
||||
if (!jobs.length) {
|
||||
el.innerHTML = '<div class="empty">Queue is empty.</div>';
|
||||
renderQueuePager();
|
||||
return;
|
||||
}
|
||||
for (const job of jobs) {
|
||||
const item = document.createElement("article");
|
||||
item.className = "job";
|
||||
const log = (job.log || []).slice(-28).join("\n");
|
||||
item.innerHTML = `
|
||||
<div class="job-head">
|
||||
<div>
|
||||
<div class="job-title"></div>
|
||||
<p class="muted"></p>
|
||||
</div>
|
||||
<span class="status"></span>
|
||||
</div>
|
||||
<pre></pre>
|
||||
<div class="toolbar">
|
||||
<span class="muted"></span>
|
||||
<div class="row" style="flex:0 0 auto"></div>
|
||||
</div>
|
||||
`;
|
||||
item.querySelector(".job-title").textContent = job.title;
|
||||
const libraryName = job.anime_name || job.title;
|
||||
const season = String(job.season || "1").padStart(2, "0");
|
||||
const seasonFolder = `Season ${season}`;
|
||||
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
|
||||
item.querySelector(".job-head .muted").textContent =
|
||||
`${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`;
|
||||
const status = item.querySelector(".status");
|
||||
status.textContent = job.status;
|
||||
status.classList.add(job.status);
|
||||
item.querySelector("pre").textContent = log || "Waiting...";
|
||||
item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`;
|
||||
const actions = item.querySelector(".row");
|
||||
if (job.status === "running") {
|
||||
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
|
||||
} else if (job.status === "pending") {
|
||||
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
|
||||
} else {
|
||||
actions.append(actionButton("Retry", () => queueAction(job.id, "retry")));
|
||||
actions.append(actionButton("Remove", () => queueAction(job.id, "remove")));
|
||||
}
|
||||
el.appendChild(item);
|
||||
}
|
||||
renderQueuePager();
|
||||
}
|
||||
|
||||
function renderQueuePager() {
|
||||
const el = $("queuePager");
|
||||
const start = state.queueTotal ? ((state.queuePage - 1) * 10) + 1 : 0;
|
||||
const end = Math.min(state.queuePage * 10, state.queueTotal);
|
||||
el.innerHTML = `
|
||||
<span class="muted"></span>
|
||||
<div class="row" style="flex:0 0 auto"></div>
|
||||
`;
|
||||
el.querySelector(".muted").textContent =
|
||||
`${start}-${end} of ${state.queueTotal} · page ${state.queuePage} of ${state.queuePages}`;
|
||||
const controls = el.querySelector(".row");
|
||||
const prev = actionButton("Previous", () => {
|
||||
if (state.queuePage > 1) loadQueue(state.queuePage - 1);
|
||||
});
|
||||
const next = actionButton("Next", () => {
|
||||
if (state.queuePage < state.queuePages) loadQueue(state.queuePage + 1);
|
||||
});
|
||||
prev.disabled = state.queuePage <= 1;
|
||||
next.disabled = state.queuePage >= state.queuePages;
|
||||
controls.append(prev, next);
|
||||
}
|
||||
|
||||
function actionButton(text, handler, extra = "") {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = text;
|
||||
btn.className = extra;
|
||||
btn.addEventListener("click", handler);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function queueAction(id, action) {
|
||||
try {
|
||||
await api(`/api/queue/${id}/${action}`, { method: "POST", body: "{}" });
|
||||
await loadQueue(state.queuePage);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function queueBulkAction(path, message) {
|
||||
try {
|
||||
const data = await api(path, { method: "POST", body: "{}" });
|
||||
setNotice(`${message}: ${data.count || 0}`);
|
||||
await loadQueue(state.queuePage);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue(page = state.queuePage || 1) {
|
||||
const requestId = ++state.queueRequestId;
|
||||
const data = await api(`/api/queue?page=${page}&per_page=10`);
|
||||
if (requestId !== state.queueRequestId) return data;
|
||||
if (data.page > data.pages && data.pages > 0) return loadQueue(data.pages);
|
||||
renderQueue(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
state.config = await api("/api/config");
|
||||
$("jobFolder").value = state.config.download_dir;
|
||||
$("search-quality").value = state.config.quality;
|
||||
setMode(state.config.mode);
|
||||
}
|
||||
|
||||
$("searchBtn").addEventListener("click", search);
|
||||
$("search-query").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") search();
|
||||
});
|
||||
$("search-mode").addEventListener("change", (event) => setMode(event.target.value));
|
||||
$("trackBtn").addEventListener("click", addSelectedToWatchlist);
|
||||
$("addBtn").addEventListener("click", addSelected);
|
||||
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs"));
|
||||
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
|
||||
$("search-quality").addEventListener("change", () => { state.config.quality = $("search-quality").value; });
|
||||
$("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; });
|
||||
$("latestEpBtn").addEventListener("click", () => {
|
||||
$("episodesInput").value = state.episodes[state.episodes.length - 1] || "";
|
||||
});
|
||||
$("allEpBtn").addEventListener("click", () => {
|
||||
const first = state.episodes[0] || "";
|
||||
const last = state.episodes[state.episodes.length - 1] || "";
|
||||
$("episodesInput").value = first && last ? `${first}-${last}` : "";
|
||||
});
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const query = (params.get("query") || "").trim();
|
||||
if (query) $("search-query").value = query;
|
||||
await loadConfig();
|
||||
await loadQueue();
|
||||
if (query) await search();
|
||||
state.queueTimer = startSerialPoll(() => loadQueue(), 2500);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Shared page-shell helpers for inline HTML templates."""
|
||||
|
||||
PAGE_LINKS = (
|
||||
("search", "Search", "/"),
|
||||
("config", "Config", "/config"),
|
||||
("watchlist", "Watchlist", "/watchlist"),
|
||||
)
|
||||
|
||||
|
||||
BROWSER_API_HELPER = r"""
|
||||
async function api(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (options.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
const raw = await response.text();
|
||||
let data = {};
|
||||
if (raw) {
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
data = { error: raw };
|
||||
}
|
||||
}
|
||||
if (!response.ok) throw new Error(data.error || response.statusText || "Request failed");
|
||||
return data;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
BROWSER_POLL_HELPER = r"""
|
||||
function startSerialPoll(callback, intervalMs) {
|
||||
let inFlight = false;
|
||||
return window.setInterval(async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
await callback();
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def render_sidebar_brand(note, badge, badge_id=""):
|
||||
badge_attr = f' id="{badge_id}"' if badge_id else ""
|
||||
return (
|
||||
' <div class="topline">\n'
|
||||
' <div class="brand-wrap">\n'
|
||||
' <h1>ani-cli <span class="brand-word">web</span></h1>\n'
|
||||
f" <p class=\"shell-note\">{note}</p>\n"
|
||||
" </div>\n"
|
||||
f" <span class=\"badge\"{badge_attr}>{badge}</span>\n"
|
||||
" </div>\n"
|
||||
)
|
||||
|
||||
|
||||
def render_page_links(active_page):
|
||||
links = []
|
||||
for key, label, href in PAGE_LINKS:
|
||||
class_name = "page-link active" if key == active_page else "page-link"
|
||||
links.append(f' <a class="{class_name}" href="{href}">{label}</a>')
|
||||
return " <div class=\"page-links\">\n" + "\n".join(links) + "\n </div>\n"
|
||||
+640
-6
@@ -18,6 +18,9 @@ TEMP_STATE = tempfile.TemporaryDirectory()
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import queue_jobs
|
||||
import http_handler
|
||||
|
||||
os.environ.setdefault("ANI_CLI_WEB_DISABLE_WORKER", "1")
|
||||
os.environ["ANI_CLI_WEB_STATE_ROOT"] = TEMP_STATE.name
|
||||
|
||||
@@ -29,12 +32,16 @@ IMPORT_RUNTIME_WAS_DEFERRED = (
|
||||
APP.CONFIG is None and APP.WATCHLIST is None and APP.DOWNLOAD_QUEUE is None and APP.WATCHLIST_REFRESH is None
|
||||
)
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
APP.reset_runtime(wait=True)
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
unittest.addModuleCleanup(lambda: APP.reset_runtime(wait=True))
|
||||
|
||||
|
||||
class DummyHandler:
|
||||
def __init__(self, path, body=b"{}", content_length=None):
|
||||
self.path = path
|
||||
self.client_address = ("127.0.0.1", 8421)
|
||||
self.handler_context = APP.Handler.handler_context
|
||||
self.headers = {}
|
||||
if content_length is not None:
|
||||
self.headers["Content-Length"] = str(content_length)
|
||||
@@ -82,8 +89,46 @@ class NetworkGuardTests(unittest.TestCase):
|
||||
self.assertFalse(APP.client_address_is_local("192.168.1.25"))
|
||||
self.assertFalse(APP.client_address_is_local("10.0.0.8"))
|
||||
|
||||
def test_remote_access_requires_token_for_non_local_clients(self):
|
||||
handler = DummyHandler("/api/config")
|
||||
handler.client_address = ("192.168.1.25", 8421)
|
||||
|
||||
with mock.patch.dict(os.environ, {"ANI_CLI_WEB_ALLOW_REMOTE": "1"}, clear=False):
|
||||
with self.assertRaises(PermissionError) as ctx:
|
||||
APP.Handler.ensure_client_access(handler)
|
||||
|
||||
self.assertIn("REMOTE_TOKEN", str(ctx.exception))
|
||||
|
||||
def test_remote_access_accepts_valid_token_for_non_local_clients(self):
|
||||
handler = DummyHandler("/api/config")
|
||||
handler.client_address = ("192.168.1.25", 8421)
|
||||
handler.headers["Authorization"] = "Bearer secret-token"
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
|
||||
clear=False,
|
||||
):
|
||||
APP.Handler.ensure_client_access(handler)
|
||||
|
||||
def test_loopback_proxy_with_forwarded_remote_ip_requires_token(self):
|
||||
handler = DummyHandler("/api/config")
|
||||
handler.client_address = ("127.0.0.1", 8421)
|
||||
handler.headers["X-Forwarded-For"] = "203.0.113.10"
|
||||
|
||||
with mock.patch.dict(os.environ, {"ANI_CLI_WEB_ALLOW_REMOTE": "1"}, clear=False):
|
||||
with self.assertRaises(PermissionError) as ctx:
|
||||
APP.Handler.ensure_client_access(handler)
|
||||
|
||||
self.assertIn("REMOTE_TOKEN", str(ctx.exception))
|
||||
|
||||
|
||||
class DownloadQueueCancelTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
||||
with queue._connect() as conn:
|
||||
conn.execute("DELETE FROM jobs")
|
||||
|
||||
def test_cancel_updates_inflight_job_state(self):
|
||||
queue = object.__new__(APP.DownloadQueue)
|
||||
queue.lock = threading.RLock()
|
||||
@@ -104,19 +149,94 @@ class DownloadQueueCancelTests(unittest.TestCase):
|
||||
self.assertEqual(saved[-1]["id"], "job-1")
|
||||
self.assertTrue(saved[-1]["cancel_requested"])
|
||||
|
||||
def test_append_log_batches_persistence(self):
|
||||
queue = object.__new__(APP.DownloadQueue)
|
||||
queue.lock = threading.RLock()
|
||||
saved = []
|
||||
|
||||
def fake_save(job):
|
||||
saved.append(list(job.get("log", [])))
|
||||
job["_dirty_log_lines"] = 0
|
||||
job["_last_persist_monotonic"] = 0.0
|
||||
|
||||
queue._save_job_locked = fake_save
|
||||
job = {"log": [], "updated_at": APP.now_iso()}
|
||||
|
||||
with mock.patch.object(queue_jobs.time, "monotonic", return_value=0.0):
|
||||
for index in range(queue_jobs.LOG_PERSIST_LINE_BATCH - 1):
|
||||
APP.DownloadQueue._append_log(queue, job, f"line {index}")
|
||||
|
||||
self.assertEqual(saved, [])
|
||||
|
||||
APP.DownloadQueue._append_log(queue, job, "line flush")
|
||||
|
||||
self.assertEqual(len(saved), 1)
|
||||
self.assertEqual(saved[0][-1], "line flush")
|
||||
|
||||
def test_save_job_splits_log_and_extra_payload_columns(self):
|
||||
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
||||
job = {
|
||||
"id": "job-structured",
|
||||
"show_id": "show-1",
|
||||
"title": "Structured Show",
|
||||
"anime_name": "Structured Show",
|
||||
"season": "1",
|
||||
"query": "Structured Show",
|
||||
"result_index": 1,
|
||||
"mode": "sub",
|
||||
"quality": "best",
|
||||
"episodes": "1-2",
|
||||
"download_dir": "/tmp/example",
|
||||
"status": "pending",
|
||||
"created_at": APP.now_iso(),
|
||||
"updated_at": APP.now_iso(),
|
||||
"log": ["line 1", "line 2"],
|
||||
"custom_flag": True,
|
||||
}
|
||||
|
||||
with queue.lock:
|
||||
queue._save_job_locked(job)
|
||||
|
||||
with queue._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job["id"],)).fetchone()
|
||||
|
||||
self.assertEqual(row["title"], "Structured Show")
|
||||
self.assertEqual(row["log_payload"], '["line 1", "line 2"]')
|
||||
self.assertEqual(APP.json.loads(row["payload"]), {"custom_flag": True})
|
||||
restored = queue._job_from_row(row)
|
||||
self.assertEqual(restored["log"], ["line 1", "line 2"])
|
||||
self.assertTrue(restored["custom_flag"])
|
||||
|
||||
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
|
||||
queue = object.__new__(APP.DownloadQueue)
|
||||
queue.lock = threading.RLock()
|
||||
queue.stop_event = threading.Event()
|
||||
queue.wakeup = threading.Event()
|
||||
queue.current_process = mock.Mock(pid=4321)
|
||||
queue.current_job = {"id": "job-1", "cancel_requested": False}
|
||||
queue.worker = mock.Mock()
|
||||
queue.worker.is_alive.side_effect = [True, False, False]
|
||||
|
||||
with mock.patch.object(APP.os, "getpgid", return_value=4321), mock.patch.object(APP.os, "killpg") as killpg:
|
||||
stopped = APP.DownloadQueue.shutdown(queue, wait=True, cancel_active=False)
|
||||
|
||||
self.assertTrue(stopped)
|
||||
self.assertTrue(queue.current_job["cancel_requested"])
|
||||
killpg.assert_called_once_with(4321, queue_jobs.signal.SIGTERM)
|
||||
|
||||
|
||||
class WatchlistRefreshAllTests(unittest.TestCase):
|
||||
def test_refresh_all_iterates_every_show_id(self):
|
||||
store = object.__new__(APP.WatchlistStore)
|
||||
calls = []
|
||||
store.all_show_ids = lambda: ["show-a", "show-b", "show-c"]
|
||||
store.refresh = lambda show_id: calls.append(show_id) or {"show_id": show_id}
|
||||
store.refresh = lambda show_id, include_thumbnail=True: calls.append((show_id, include_thumbnail)) or {"show_id": show_id}
|
||||
|
||||
result = APP.WatchlistStore.refresh_all(store)
|
||||
|
||||
self.assertEqual(calls, ["show-a", "show-b", "show-c"])
|
||||
self.assertEqual(calls, [("show-a", False), ("show-b", False), ("show-c", False)])
|
||||
self.assertEqual(result["count"], 3)
|
||||
self.assertEqual([item["show_id"] for item in result["items"]], calls)
|
||||
self.assertEqual([item["show_id"] for item in result["items"]], ["show-a", "show-b", "show-c"])
|
||||
|
||||
|
||||
class WatchlistRefreshJobTests(unittest.TestCase):
|
||||
@@ -129,7 +249,8 @@ class WatchlistRefreshJobTests(unittest.TestCase):
|
||||
def all_show_ids(self):
|
||||
return ["show-a", "show-b"]
|
||||
|
||||
def refresh(self, show_id):
|
||||
def refresh(self, show_id, include_thumbnail=True):
|
||||
self.last_include_thumbnail = include_thumbnail
|
||||
if show_id == "show-a":
|
||||
return {"title": "Show A", "status": "updated"}
|
||||
return {"title": "Show B", "status": "error"}
|
||||
@@ -147,13 +268,41 @@ class WatchlistRefreshJobTests(unittest.TestCase):
|
||||
self.assertEqual(status["status"], "done")
|
||||
self.assertEqual(status["completed"], 2)
|
||||
self.assertEqual(status["errors"], 1)
|
||||
self.assertFalse(service.store.last_include_thumbnail)
|
||||
|
||||
def test_refresh_job_skips_removed_items_without_thumbnail_warmup(self):
|
||||
calls = []
|
||||
|
||||
class FakeStore:
|
||||
def all_show_ids(self):
|
||||
return ["show-a", "show-b"]
|
||||
|
||||
def refresh(self, show_id, include_thumbnail=True):
|
||||
calls.append((show_id, include_thumbnail))
|
||||
if show_id == "show-a":
|
||||
return None
|
||||
return {"title": "Show B", "status": "updated"}
|
||||
|
||||
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
||||
started = service.start()
|
||||
self.assertTrue(started["created"])
|
||||
|
||||
job = service._next_pending()
|
||||
self.assertIsNotNone(job)
|
||||
service._run_job(job)
|
||||
|
||||
status = service.status()["job"]
|
||||
self.assertEqual(status["status"], "done")
|
||||
self.assertEqual(status["completed"], 2)
|
||||
self.assertEqual(status["errors"], 0)
|
||||
self.assertCountEqual(calls, [("show-a", False), ("show-b", False)])
|
||||
|
||||
def test_refresh_start_reuses_pending_job(self):
|
||||
class FakeStore:
|
||||
def all_show_ids(self):
|
||||
return []
|
||||
|
||||
def refresh(self, _show_id):
|
||||
def refresh(self, _show_id, include_thumbnail=True):
|
||||
return {"title": "Unused", "status": "updated"}
|
||||
|
||||
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
||||
@@ -170,7 +319,7 @@ class WatchlistRefreshJobTests(unittest.TestCase):
|
||||
def all_show_ids(self):
|
||||
return []
|
||||
|
||||
def refresh(self, _show_id):
|
||||
def refresh(self, _show_id, include_thumbnail=True):
|
||||
return {"title": "Unused", "status": "updated"}
|
||||
|
||||
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
|
||||
@@ -184,6 +333,44 @@ class WatchlistRefreshJobTests(unittest.TestCase):
|
||||
|
||||
|
||||
class ThumbnailEventRecoveryTests(unittest.TestCase):
|
||||
def test_ensure_thumbnails_skips_show_removed_during_batch(self):
|
||||
store = object.__new__(APP.WatchlistStore)
|
||||
item = {
|
||||
"show_id": "show-1",
|
||||
"thumbnail_ready": False,
|
||||
}
|
||||
store.get = mock.Mock(side_effect=[item, KeyError("Anime not found in watchlist.")])
|
||||
store.ensure_thumbnail = mock.Mock(return_value=None)
|
||||
|
||||
result = APP.WatchlistStore.ensure_thumbnails(store, ["show-1"])
|
||||
|
||||
self.assertEqual(result, {"items": []})
|
||||
store.ensure_thumbnail.assert_called_once_with("show-1", force=False)
|
||||
|
||||
def test_ensure_thumbnail_waiter_returns_none_after_show_is_removed(self):
|
||||
store = object.__new__(APP.WatchlistStore)
|
||||
store.lock = threading.RLock()
|
||||
inflight = threading.Event()
|
||||
inflight.set()
|
||||
store.thumbnail_events = {"show-1": inflight}
|
||||
store.has_show = lambda _show_id: False
|
||||
store.get = mock.Mock(return_value={
|
||||
"show_id": "show-1",
|
||||
"title": "Show One",
|
||||
"thumbnail_path": None,
|
||||
"thumbnail_checked_at": None,
|
||||
"thumbnail_ready": False,
|
||||
"animeschedule_route": None,
|
||||
"animeschedule_title": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
})
|
||||
|
||||
result = APP.WatchlistStore.ensure_thumbnail(store, "show-1")
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(store.get.call_count, 1)
|
||||
|
||||
def test_ensure_thumbnail_clears_inflight_event_after_update_failure(self):
|
||||
store = object.__new__(APP.WatchlistStore)
|
||||
store.lock = threading.RLock()
|
||||
@@ -221,7 +408,384 @@ class ThumbnailEventRecoveryTests(unittest.TestCase):
|
||||
self.assertEqual(store.thumbnail_events, {})
|
||||
|
||||
|
||||
class WatchlistCompletionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with APP.WATCHLIST._connect() as conn:
|
||||
conn.execute("DELETE FROM watchlist")
|
||||
|
||||
def seed_watchlist_item(self, **overrides):
|
||||
now = APP.now_iso()
|
||||
item = {
|
||||
"show_id": "show-1",
|
||||
"title": "Example Show",
|
||||
"category": "watching",
|
||||
"downloaded": False,
|
||||
"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,
|
||||
"expected_count": 0,
|
||||
"airing_status": None,
|
||||
"sub_latest_episode": None,
|
||||
"dub_latest_episode": None,
|
||||
"animeschedule_route": None,
|
||||
"animeschedule_title": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
"thumbnail_path": None,
|
||||
"thumbnail_checked_at": None,
|
||||
}
|
||||
item.update(overrides)
|
||||
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
||||
APP.WATCHLIST._upsert_conn(conn, item)
|
||||
|
||||
def test_watchlist_status_message_marks_ready_modes(self):
|
||||
message = APP.watchlist_status_message(12, 10, expected_count=12, airing_status="finished")
|
||||
self.assertIn("Sub: 12/12 available.", message)
|
||||
self.assertIn("Dub: 10/12 available.", message)
|
||||
self.assertIn("AnimeSchedule: Finished.", message)
|
||||
self.assertIn("Sub ready.", message)
|
||||
self.assertNotIn("Dub ready.", message)
|
||||
|
||||
def test_refresh_persists_expected_episode_total_and_ready_flags(self):
|
||||
self.seed_watchlist_item()
|
||||
|
||||
snapshot = {
|
||||
"show_id": "show-1",
|
||||
"title": "Example Show",
|
||||
"episodes": {
|
||||
"sub": [str(index) for index in range(1, 13)],
|
||||
"dub": [str(index) for index in range(1, 13)],
|
||||
},
|
||||
}
|
||||
schedule = {
|
||||
"route": "example-show",
|
||||
"title": "Example Show",
|
||||
"episodes": 12,
|
||||
"status": "Finished",
|
||||
}
|
||||
|
||||
with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot), mock.patch.object(
|
||||
APP, "resolve_animeschedule_anime", return_value=schedule
|
||||
), mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None):
|
||||
item = APP.WATCHLIST.refresh("show-1")
|
||||
|
||||
self.assertEqual(item["expected_count"], 12)
|
||||
self.assertEqual(item["airing_status"], "Finished")
|
||||
self.assertTrue(item["sub_complete"])
|
||||
self.assertTrue(item["dub_complete"])
|
||||
self.assertEqual(item["sub_progress"], "12/12")
|
||||
self.assertEqual(item["dub_progress"], "12/12")
|
||||
self.assertEqual(item["animeschedule_route"], "example-show")
|
||||
self.assertIn("Sub ready.", item["status_message"])
|
||||
self.assertIn("Dub ready.", item["status_message"])
|
||||
|
||||
def test_list_filters_by_category_and_reports_category_counts(self):
|
||||
self.seed_watchlist_item(show_id="show-1", title="Watching Show", category="watching", status="queued")
|
||||
self.seed_watchlist_item(show_id="show-2", title="Finished Show", category="finished", downloaded=True)
|
||||
|
||||
listing = APP.WATCHLIST.list(category="watching")
|
||||
|
||||
self.assertEqual(listing["active_category"], "watching")
|
||||
self.assertEqual(listing["summary"]["shows"], 2)
|
||||
self.assertEqual(listing["categories"]["watching"], 1)
|
||||
self.assertEqual(listing["categories"]["finished"], 1)
|
||||
self.assertEqual(listing["queued_count"], 1)
|
||||
self.assertEqual([item["show_id"] for item in listing["items"]], ["show-1"])
|
||||
|
||||
def test_queue_refresh_marks_item_queued_without_sync_refresh(self):
|
||||
self.seed_watchlist_item(show_id="show-11", title="Refresh Show", category="watching", status="updated")
|
||||
|
||||
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
|
||||
APP.WATCHLIST, "refresh", side_effect=AssertionError("queue_refresh should not refresh synchronously")
|
||||
):
|
||||
item = APP.WATCHLIST.queue_refresh("show-11", status_message="Queued from test.")
|
||||
|
||||
schedule_refresh.assert_called_once_with("show-11")
|
||||
self.assertEqual(item["status"], "queued")
|
||||
self.assertEqual(item["status_message"], "Queued from test.")
|
||||
|
||||
def test_mark_downloaded_keeps_existing_category_and_sets_download_flag(self):
|
||||
self.seed_watchlist_item(show_id="show-9", title="Queue Show", category="planned", downloaded=False)
|
||||
|
||||
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object(
|
||||
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
||||
):
|
||||
item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show")
|
||||
|
||||
self.assertEqual(item["category"], "planned")
|
||||
self.assertTrue(item["downloaded"])
|
||||
self.assertEqual(item["finished_badge"], "")
|
||||
self.assertEqual(item["status"], "queued")
|
||||
|
||||
def test_mark_downloaded_creates_missing_show_in_finished_category(self):
|
||||
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object(
|
||||
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
||||
):
|
||||
item = APP.WATCHLIST.mark_downloaded("show-10", title="Downloaded Show")
|
||||
|
||||
self.assertEqual(item["category"], "finished")
|
||||
self.assertTrue(item["downloaded"])
|
||||
self.assertEqual(item["finished_badge"], "downloaded")
|
||||
self.assertEqual(item["status"], "queued")
|
||||
|
||||
def test_add_queues_refresh_without_sync_refresh(self):
|
||||
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
|
||||
APP.WATCHLIST, "refresh", side_effect=AssertionError("add should not refresh synchronously")
|
||||
):
|
||||
result = APP.WATCHLIST.add({"show_id": "show-22", "title": "Queued Show", "category": "watching"})
|
||||
|
||||
schedule_refresh.assert_called_once_with("show-22")
|
||||
self.assertTrue(result["created"])
|
||||
self.assertEqual(result["item"]["status"], "queued")
|
||||
self.assertIn("Refresh queued", result["message"])
|
||||
|
||||
def test_sync_downloaded_job_to_watchlist_resolves_missing_show_id_from_search(self):
|
||||
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
|
||||
|
||||
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show"}]), mock.patch.object(
|
||||
APP.WATCHLIST, "mark_downloaded", return_value={"show_id": "show-42"}
|
||||
) as mark_downloaded:
|
||||
APP.sync_downloaded_job_to_watchlist(job)
|
||||
|
||||
mark_downloaded.assert_called_once_with("show-42", title="Queue Show")
|
||||
|
||||
def test_prepare_download_job_persists_resolved_show_id(self):
|
||||
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show", "show_id": ""}
|
||||
|
||||
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show"}]):
|
||||
prepared = APP.prepare_download_job(dict(job))
|
||||
|
||||
self.assertEqual(prepared["show_id"], "show-42")
|
||||
self.assertEqual(prepared["title"], "Queue Show")
|
||||
|
||||
|
||||
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with APP.WATCHLIST._connect() as conn:
|
||||
conn.execute("DELETE FROM watchlist")
|
||||
|
||||
def seed_watchlist_item(self, **overrides):
|
||||
now = APP.now_iso()
|
||||
item = {
|
||||
"show_id": "show-1",
|
||||
"title": "Example Show",
|
||||
"category": "watching",
|
||||
"downloaded": False,
|
||||
"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,
|
||||
"expected_count": 0,
|
||||
"airing_status": None,
|
||||
"sub_latest_episode": None,
|
||||
"dub_latest_episode": None,
|
||||
"animeschedule_route": None,
|
||||
"animeschedule_title": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
"thumbnail_path": None,
|
||||
"thumbnail_checked_at": None,
|
||||
}
|
||||
item.update(overrides)
|
||||
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
||||
APP.WATCHLIST._upsert_conn(conn, item)
|
||||
|
||||
def test_restart_restores_queued_watchlist_refreshes(self):
|
||||
self.seed_watchlist_item(
|
||||
show_id="show-b",
|
||||
title="Beta Show",
|
||||
status="queued",
|
||||
status_message="Queued refresh",
|
||||
updated_at="2026-05-15T10:00:00+00:00",
|
||||
)
|
||||
self.seed_watchlist_item(
|
||||
show_id="show-a",
|
||||
title="Alpha Show",
|
||||
status="queued",
|
||||
status_message="Queued refresh",
|
||||
updated_at="2026-05-15T09:00:00+00:00",
|
||||
)
|
||||
self.seed_watchlist_item(
|
||||
show_id="show-c",
|
||||
title="Gamma Show",
|
||||
status="updated",
|
||||
status_message="Updated",
|
||||
updated_at="2026-05-15T11:00:00+00:00",
|
||||
)
|
||||
|
||||
restored = APP.WatchlistStore(start_worker=False)
|
||||
|
||||
self.assertEqual(restored.refresh_pending, ["show-a", "show-b"])
|
||||
self.assertEqual(restored.refresh_pending_set, {"show-a", "show-b"})
|
||||
|
||||
|
||||
class WatchlistRefreshQueueTests(unittest.TestCase):
|
||||
def test_schedule_refresh_skips_show_already_in_flight(self):
|
||||
store = object.__new__(APP.WatchlistStore)
|
||||
store.lock = threading.RLock()
|
||||
store.thumbnail_events = {}
|
||||
store.refresh_pending = []
|
||||
store.refresh_pending_set = set()
|
||||
store.refresh_inflight_set = {"show-1"}
|
||||
store.refresh_wakeup = threading.Event()
|
||||
store.refresh_stop_event = threading.Event()
|
||||
store.refresh_worker = None
|
||||
store.worker_enabled = False
|
||||
store.ensure_worker = lambda: None
|
||||
store.get = lambda show_id: {"show_id": show_id, "status": "queued"}
|
||||
|
||||
item = APP.WatchlistStore.schedule_refresh(store, "show-1")
|
||||
|
||||
self.assertEqual(item["show_id"], "show-1")
|
||||
self.assertEqual(store.refresh_pending, [])
|
||||
self.assertEqual(store.refresh_pending_set, set())
|
||||
self.assertEqual(store.refresh_inflight_set, {"show-1"})
|
||||
|
||||
def test_refresh_loop_clears_inflight_marker_after_refresh(self):
|
||||
store = object.__new__(APP.WatchlistStore)
|
||||
store.lock = threading.RLock()
|
||||
store.thumbnail_events = {}
|
||||
store.refresh_pending = ["show-2"]
|
||||
store.refresh_pending_set = {"show-2"}
|
||||
store.refresh_inflight_set = set()
|
||||
store.refresh_wakeup = threading.Event()
|
||||
store.refresh_stop_event = threading.Event()
|
||||
store.refresh_worker = None
|
||||
store.worker_enabled = False
|
||||
calls = []
|
||||
|
||||
def fake_refresh(show_id):
|
||||
calls.append(show_id)
|
||||
store.refresh_stop_event.set()
|
||||
|
||||
store.refresh = fake_refresh
|
||||
|
||||
APP.WatchlistStore._refresh_loop(store)
|
||||
|
||||
self.assertEqual(calls, ["show-2"])
|
||||
self.assertEqual(store.refresh_inflight_set, set())
|
||||
self.assertEqual(store.refresh_pending, [])
|
||||
self.assertEqual(store.refresh_pending_set, set())
|
||||
|
||||
def test_remove_drops_pending_and_inflight_refresh_state(self):
|
||||
store = object.__new__(APP.WatchlistStore)
|
||||
store.lock = threading.RLock()
|
||||
thumbnail_event = threading.Event()
|
||||
store.thumbnail_events = {"show-3": thumbnail_event}
|
||||
store.refresh_pending = ["show-3", "show-4"]
|
||||
store.refresh_pending_set = {"show-3", "show-4"}
|
||||
store.refresh_inflight_set = {"show-3"}
|
||||
store._connect = APP.WATCHLIST._connect
|
||||
store.get = lambda show_id: {"show_id": show_id, "title": "Queued Show", "thumbnail_path": None}
|
||||
|
||||
class DummyCursor:
|
||||
rowcount = 1
|
||||
|
||||
class DummyConn:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, _sql, _params):
|
||||
return DummyCursor()
|
||||
|
||||
store._connect = lambda: DummyConn()
|
||||
|
||||
result = APP.WatchlistStore.remove(store, "show-3")
|
||||
|
||||
self.assertEqual(result["item"]["show_id"], "show-3")
|
||||
self.assertEqual(store.refresh_pending, ["show-4"])
|
||||
self.assertEqual(store.refresh_pending_set, {"show-4"})
|
||||
self.assertEqual(store.refresh_inflight_set, set())
|
||||
self.assertTrue(thumbnail_event.is_set())
|
||||
self.assertNotIn("show-3", store.thumbnail_events)
|
||||
|
||||
|
||||
class ConfigSnapshotTests(unittest.TestCase):
|
||||
def test_get_config_snapshot_returns_copy(self):
|
||||
original = APP.CONFIG
|
||||
APP.CONFIG = {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}
|
||||
try:
|
||||
snapshot = APP.get_config_snapshot()
|
||||
snapshot["mode"] = "dub"
|
||||
self.assertEqual(APP.CONFIG["mode"], "sub")
|
||||
finally:
|
||||
APP.CONFIG = original
|
||||
|
||||
|
||||
class StartupBehaviorTests(unittest.TestCase):
|
||||
def test_main_does_not_eagerly_initialize_runtime(self):
|
||||
server = mock.Mock()
|
||||
server.serve_forever.side_effect = KeyboardInterrupt()
|
||||
|
||||
with mock.patch.object(APP, "initialize_runtime") as initialize_runtime, mock.patch.object(
|
||||
APP, "ThreadingHTTPServer", return_value=server
|
||||
), mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime:
|
||||
APP.main()
|
||||
|
||||
initialize_runtime.assert_not_called()
|
||||
shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=True)
|
||||
server.serve_forever.assert_called_once()
|
||||
server.server_close.assert_called_once()
|
||||
|
||||
def test_reset_runtime_wait_defaults_to_cancel_active_downloads(self):
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
try:
|
||||
with mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime:
|
||||
APP.reset_runtime(wait=True)
|
||||
|
||||
shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=None)
|
||||
self.assertIsNone(APP.CONFIG)
|
||||
finally:
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
|
||||
def test_save_runtime_config_replaces_runtime_config(self):
|
||||
original = APP.CONFIG
|
||||
try:
|
||||
APP.CONFIG = {"mode": "sub", "quality": "best", "download_dir": "/tmp/old"}
|
||||
saved = APP.save_runtime_config({"mode": "dub", "quality": "720", "download_dir": "/tmp/new"})
|
||||
self.assertEqual(saved["mode"], "dub")
|
||||
self.assertEqual(APP.CONFIG["mode"], "dub")
|
||||
self.assertEqual(APP.CONFIG["quality"], "720")
|
||||
finally:
|
||||
APP.CONFIG = original
|
||||
|
||||
def test_reset_runtime_clears_runtime_services(self):
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
APP.reset_runtime(wait=True)
|
||||
try:
|
||||
self.assertIsNone(APP.CONFIG)
|
||||
self.assertIsNone(APP.WATCHLIST)
|
||||
self.assertIsNone(APP.DOWNLOAD_QUEUE)
|
||||
self.assertIsNone(APP.WATCHLIST_REFRESH)
|
||||
finally:
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
|
||||
|
||||
class HandlerRouteTests(unittest.TestCase):
|
||||
def test_runtime_backed_route_respects_worker_disable_flag(self):
|
||||
handler = DummyHandler("/api/watchlist/refresh-status")
|
||||
APP.reset_runtime(wait=True)
|
||||
try:
|
||||
with mock.patch.dict(os.environ, {"ANI_CLI_WEB_DISABLE_WORKER": "1"}, clear=False):
|
||||
APP.Handler.do_GET(handler)
|
||||
self.assertFalse(APP.WATCHLIST.worker_enabled)
|
||||
self.assertIsNone(APP.WATCHLIST.refresh_worker)
|
||||
self.assertIsNone(APP.DOWNLOAD_QUEUE.worker)
|
||||
self.assertIsNone(APP.WATCHLIST_REFRESH.worker)
|
||||
finally:
|
||||
APP.reset_runtime(wait=True)
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
|
||||
def test_body_json_rejects_oversized_request(self):
|
||||
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1)
|
||||
with self.assertRaises(APP.HttpError) as ctx:
|
||||
@@ -265,6 +829,27 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
self.assertEqual(handler.json_payload, payload)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.ACCEPTED)
|
||||
|
||||
def test_update_status_post_queues_refresh(self):
|
||||
body = b'{"show_id":"show-1"}'
|
||||
handler = DummyHandler("/api/watchlist/update-status", body=body, content_length=len(body))
|
||||
queued_item = {"show_id": "show-1", "title": "Show 1", "status": "queued"}
|
||||
with mock.patch.object(APP.WATCHLIST, "queue_refresh", return_value=queued_item):
|
||||
APP.Handler.do_POST(handler)
|
||||
self.assertEqual(handler.json_payload, {"message": "Queued refresh for Show 1.", "item": queued_item})
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
|
||||
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
||||
handler = DummyHandler("/api/config")
|
||||
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
||||
http_handler, "debug_log"
|
||||
) as debug_log, mock.patch.object(http_handler.traceback, "print_exc") as print_exc:
|
||||
APP.Handler.exception(handler, RuntimeError("boom"))
|
||||
|
||||
print_exc.assert_not_called()
|
||||
debug_log.assert_called_once()
|
||||
self.assertEqual(handler.error_status, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
self.assertEqual(handler.error_message, "Internal server error.")
|
||||
|
||||
|
||||
class TemplateHelperTests(unittest.TestCase):
|
||||
def test_page_links_marks_active_page(self):
|
||||
@@ -277,6 +862,55 @@ class TemplateHelperTests(unittest.TestCase):
|
||||
self.assertIn('id="serverBadge"', markup)
|
||||
self.assertIn("Shared note", markup)
|
||||
|
||||
def test_search_page_queue_loader_guards_against_stale_responses(self):
|
||||
self.assertIn("queueRequestId", APP.INDEX_HTML)
|
||||
self.assertIn("if (requestId !== state.queueRequestId) return data;", APP.INDEX_HTML)
|
||||
|
||||
def test_pages_share_browser_api_helper(self):
|
||||
self.assertIn("async function api(path, options = {})", APP.INDEX_HTML)
|
||||
self.assertIn("async function api(path, options = {})", APP.CONFIG_HTML)
|
||||
self.assertIn("async function api(path, options = {})", APP.WATCHLIST_HTML)
|
||||
|
||||
def test_pages_share_serial_polling_helper(self):
|
||||
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.INDEX_HTML)
|
||||
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)
|
||||
self.assertIn("startSerialPoll(() => loadQueue(), 2500)", APP.INDEX_HTML)
|
||||
self.assertIn("startSerialPoll(async () => {", APP.WATCHLIST_HTML)
|
||||
|
||||
def test_watchlist_page_polls_when_queued_items_exist(self):
|
||||
self.assertIn("queuedCount: 0", APP.WATCHLIST_HTML)
|
||||
self.assertIn("else if (state.queuedCount > 0)", APP.WATCHLIST_HTML)
|
||||
self.assertIn("fetchWatchlist(state.watchlistPage, { silent: true })", APP.WATCHLIST_HTML)
|
||||
|
||||
def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self):
|
||||
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
|
||||
self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML)
|
||||
self.assertIn("if (state.thumbnailWarmupTokens[item.show_id] === token) continue;", APP.WATCHLIST_HTML)
|
||||
self.assertIn("state.thumbnailWarmupPending.delete(showId);", APP.WATCHLIST_HTML)
|
||||
|
||||
|
||||
class AniDbCacheTests(unittest.TestCase):
|
||||
def test_get_cached_anidb_titles_reuses_cached_entries(self):
|
||||
original_cache = dict(APP.ANIDB_TITLES_CACHE)
|
||||
try:
|
||||
cached_entries = [{"aid": "1", "title": "Example", "normalized_title": "example"}]
|
||||
APP.ANIDB_TITLES_CACHE.update(
|
||||
{
|
||||
"mtime": 123.0,
|
||||
"entries": cached_entries,
|
||||
"title_index": {"example": [0]},
|
||||
"token_index": {"example": [0]},
|
||||
"match_cache": {},
|
||||
}
|
||||
)
|
||||
with mock.patch.object(APP, "ensure_anidb_titles_dump") as ensure_dump:
|
||||
ensure_dump.return_value = mock.Mock(exists=lambda: True, stat=lambda: mock.Mock(st_mtime=123.0))
|
||||
result = APP.get_cached_anidb_titles()
|
||||
self.assertIs(result, cached_entries)
|
||||
finally:
|
||||
APP.ANIDB_TITLES_CACHE.clear()
|
||||
APP.ANIDB_TITLES_CACHE.update(original_cache)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+1208
File diff suppressed because it is too large
Load Diff
+15
-2120
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user