diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab2e61e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.ani-cli-web +.kaizoku +downloads +node_modules +__pycache__ +**/__pycache__/ +*.py[cod] +*.pyo +.python-version +.venv +venv +env +*.log +_backup_/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3662553 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.ani-cli-web/ +.kaizoku/ +downloads/ +node_modules/ +__pycache__/ +**/__pycache__/ +*.py[cod] +*.pyo +.python-version +.venv/ +venv/ +env/ +*.log +_backup_/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..35f6bb1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,758 @@ +# Changelog + +## 0.51.0 - 2026-08-09 + +- Renamed the app surface to Kaizoku and switched the default state folder to `.kaizoku`. +- Replaced the `ani-cli`, `anipy-cli`, `animdl`, and AllManga/AllAnime download path with a provider bridge for Anikoto, AniNeko, and AnimePahe. +- Added a local provider downloader that resolves streams through the StrawVerse-compatible extension modules and saves episodes with the existing Jellyfin-friendly finalization flow. +- Added provider selection to Config and persisted provider identity on queue jobs. +- Updated Docker/Compose to install Node bridge dependencies and run the new Kaizoku app without the old CLI stack. + +## 0.50.2 - 2026-08-01 + +- Re-ran queue job preparation when retrying failed or canceled downloads, letting retries repair missing `ani-cli` result selections before launching. +- Added result-index inference for older or watchlist-created jobs by matching the stored show ID, or a single confident title match, against current provider search results. + +## 0.50.1 - 2026-08-01 + +- Passed the selected Search result number through queued `ani-cli` jobs as `-S`, so downloads avoid the interactive series picker when multiple similar titles are returned. +- Preserved the selected result index in queued jobs for more reliable watchlist identity sync after ambiguous downloads. + +## 0.50.0 - 2026-08-01 + +- Added `curl-impersonate` to Docker images using the maintained `lexiforest/curl-impersonate` image so `ani-cli` v5 can use browser-like curl wrappers before falling back to plain `curl`. +- Exposed `curl-impersonate` availability in the Config page dependency status. +- Updated Docker/runtime documentation for the new `ani-cli` v5 Cloudflare workaround. + +## 0.49.9 - 2026-07-26 + +- Changed the Config page `ani-cli` version probe to run with `ANI_CLI_PLAYER=download`, avoiding false missing-player failures on headless installs without `mpv` or `vlc`. +- Added regression coverage for the player-safe `ani-cli --version` environment. + +## 0.49.8 - 2026-07-21 + +- Forced queued `ani-cli` attempts into explicit download-player mode with `ANI_CLI_PLAYER=download`, preventing headless Docker jobs from failing the upstream player check when `mpv` or `vlc` are not installed. +- Expanded fallback regression coverage for the `ani-cli` download-mode environment. + +## 0.49.7 - 2026-07-21 + +- Ignored the local `_backup_/` folder in git and Docker build contexts so temporary backups do not get committed or copied into images. +- Added an agent note to preserve existing ani-cli-web configuration and watchlist data during future updates. + +## 0.49.6 - 2026-07-21 + +- Added Config page watchlist export and import actions for full watchlist database backups. +- Backup files include raw `watchlist` rows plus `watchlist_removals`, preserving tracked shows, downloaded state, metadata, auto-download settings, thumbnails, and removed-show history. +- Expanded regression coverage for backup export/import behavior and the new HTTP routes. + +## 0.49.5 - 2026-07-21 + +- Added a manual Config page action that reconciles watchlist downloaded episode flags against the current filesystem library. +- The new sync removes downloaded flags for missing files, adds flags for episodes already present on disk for the watchlist's active download mode, and updates movie entries when their library folder exists again. +- Expanded regression coverage for the new filesystem reconciliation flow and Config route wiring. + +## 0.49.4 - 2026-07-19 + +- Added optional download job stdout mirroring with `ANI_CLI_WEB_JOB_STDOUT`, enabled by default in Docker and Docker Compose so container log collectors can capture downloader output. + +## 0.49.3 - 2026-07-19 + +- Changed Docker builds to copy the current local `ani-cli-web` checkout into the image instead of cloning the configured remote branch, so rebuilt images include local Docker/runtime fixes immediately. +- Installed Docker `anipy-cli` and `animdl` in separate virtualenvs, with `animdl` pinned under Python 3.11 so its older native dependency pins resolve and remain executable. +- Removed the unused `ANI_CLI_WEB` compose build argument and updated Docker documentation to match the local-checkout build flow. + +## 0.49.2 - 2026-07-19 + +- Fixed the Config page dependency check so Docker-installed fallback tools are detected from `/usr/local/bin` and `/usr/bin`, matching the runtime command lookup. +- Set the Docker image `PATH` explicitly to include system binary directories before the app starts. + +## 0.49.1 - 2026-07-19 + +- Fixed Docker fallback tool discovery by exposing `anipy-cli` and `animdl` from fixed system paths and checking common system install locations at runtime. +- Changed queue jobs to fail immediately with a clear message when every selected download method is unavailable, instead of trying to launch an unavailable command. + +## 0.49.0 - 2026-07-19 + +- Added `animdl` as a third queue download backend and Docker-installed fallback tool. +- Replaced the single `anipy-cli` fallback toggle with selectable download methods for `ani-cli`, `anipy-cli`, and `animdl`; queue jobs try the checked methods in that order. +- Exposed `animdl` availability and version in the Config page runtime checks. + +## 0.48.3 - 2026-07-17 + +- Added Jellyfin handoff jobs to the Queue page so manual copy jobs appear alongside downloads with live progress, moved-file counts, and recent handoff activity. + +## 0.48.2 - 2026-07-09 + +- Added support for a checked-in `favicon.png`, serving it from the app and wiring it into the website pages so browsers show the custom favicon. + +## 0.48.1 - 2026-07-09 + +- Changed queue inserts so adding a download that exactly matches an existing failed job now reuses that failed entry and resets it to pending instead of creating another duplicate failed row. + +## 0.48.0 - 2026-07-09 + +- Added a `Clear failed` bulk action to the Queue page so failed jobs can be removed without touching pending, running, finished, or canceled downloads. + +## 0.47.1 - 2026-07-09 + +- Fixed `anipy-cli` fallback renaming for queued TV downloads so retried jobs now keep the requested episode numbers from the queue, avoiding cases like a retried `S02E11` being renamed as `S02E01`. + +## 0.47.0 - 2026-07-09 + +- Added an optional `anipy-cli` fallback toggle on the Config page so failed `ani-cli` queue jobs can automatically retry once with `anipy-cli`. +- Exposed `anipy-cli` availability and version in Config page runtime checks, and updated queue processing so fallback downloads still finalize into the same TV or movie library layout. + +## 0.46.0 - 2026-06-14 + +- Changed manual Jellyfin handoff to run as a persisted background job instead of a single blocking request, so the Config page can show live progress while files are being moved. +- Added a Jellyfin sync status API plus restart-safe SQLite-backed job tracking with per-entry and per-file progress details for current and recent handoff runs. +- Updated Jellyfin moves to transfer files individually into the target library, which keeps progress counts accurate while preserving the existing final folder layout. + +## 0.45.18 - 2026-06-13 + +- Added a `Source name` field to the watchlist auto-download editor so per-show jobs can send a more specific anime title to `ani-cli` when provider searches return multiple similarly named results. +- Migrated existing watchlist databases by adding an `auto_download_source_name` column and defaulting stored entries to the tracked title, preserving current behavior until you override it. +- Changed both watchlist `Download all` and automatic watchlist episode queueing to use the saved `Source name` for the `ani-cli` query while keeping the separate library `Name` field for folder and file naming. + +## 0.45.17 - 2026-06-07 + +- Added a lightweight `/api/watchlist/homepage` endpoint for Homepage's Custom API widget, returning simple watchlist category counts for `watchlist`, `planned`, `finished`, `dropped`, and `total`. + +## 0.45.16 - 2026-06-02 + +- Show the first English AniDB alternative title beneath each Search result when one exists, reusing the watchlist-backed alternative-title extraction path instead of duplicating title matching in the browser. +- Included alternative titles in the Search API payload so the result cards can render the subtitle without doing extra per-card network requests. + +## 0.45.15 - 2026-06-02 + +- Moved Search page results out of the sidebar and into a poster-style grid below the selection panel so hits now read like watchlist cards instead of a compact list. +- Added a dedicated search thumbnail route that reuses AnimeSchedule or AniDB artwork, letting search results show cover images without needing to add the title to the watchlist first. + +## 0.45.14 - 2026-05-26 + +- Added a full-width `Changelog` button under Config page Runtime info that opens a floating scrollable panel and loads the contents of the project's `CHANGELOG.md`. +- Added a new `/api/changelog` route for the Config page modal and kept `CHANGELOG.md` inside the Docker runtime image so the in-container changelog viewer works after deployment. + +## 0.45.13 - 2026-05-26 + +- Trimmed the Docker runtime image by removing repo-only files from `/app` after cloning, so the container no longer carries development docs, Compose/build files, or the test suite it does not use at runtime. +- Kept the checked-in `VERSION` file inside the image so Runtime info and startup logging still have the release metadata they now read at runtime. + +## 0.45.12 - 2026-05-26 + +- Changed `ani-cli-web` runtime version reporting to load directly from the project `VERSION` file instead of a duplicated hardcoded constant, which keeps Runtime info, API version responses, and startup logs in sync with release metadata. +- Added regression coverage to ensure the loaded runtime version matches the checked-in `VERSION` file. + +## 0.45.11 - 2026-05-26 + +- Fixed watchlist auto-download queueing so the first manual or scheduled refresh after adding a `Watching` series can pick up already-available missing episodes instead of waiting for a later refresh cycle. +- Kept add-time `initial` watchlist refreshes non-downloading, so tracking a show still seeds metadata first and only later refreshes are allowed to enqueue backlog or newly released episodes. + +## 0.45.10 - 2026-05-26 + +- Fixed watchlist-synced movie downloads so successful movie jobs move existing watchlist entries into `Finished` instead of leaving them stuck in `Watching` or `Planned`. +- Preserved queued job media type when creating missing watchlist entries from completed downloads, which prevents synced movies from being recreated as TV entries and keeps Jellyfin routing correct. + +## 0.45.9 - 2026-05-26 + +- Clarified queue cleanup wording so the UI and README now match the real `Clear finished` behavior, which removes both finished and canceled jobs. +- Fixed successful detached queue jobs to log that watchlist sync was skipped instead of incorrectly claiming that watchlist download state synced. + +## 0.45.8 - 2026-05-26 + +- Made watchlist removal sticky by recording removed show IDs, detaching related queue jobs from watchlist sync, and preventing later completed downloads from silently recreating deleted entries. +- Cleared watchlist queue bindings when a show is removed so old job history no longer suppresses future auto-download coverage if that anime is tracked again later. +- Updated manual watchlist `Download all` queueing to honor the per-anime auto-download quality first, then fall back to the global auto-download quality before the general queue default. + +## 0.45.7 - 2026-05-25 + +- Removed the remaining backend `ani-cli -S` dependency so queued downloads no longer trust caller-supplied search result ordinals, including older persisted jobs that still carry a legacy `result_index`. +- Simplified watchlist `Download all` and auto-download queueing to use the stored watchlist `show_id` plus direct episode lookups, avoiding unnecessary `search_anime()` match failures when provider search results drift. +- Restricted queue retries to failed or canceled jobs, which prevents finished downloads from being retried and accidentally redownloading episodes or resending completion side effects. + +## 0.45.6 - 2026-05-25 + +- Hardened Search page queueing so explicit downloads now submit the selected anime title directly instead of replaying a fragile web-result ordinal as `ani-cli -S`, which avoids cross-search mismatches between the API and `ani-cli`. +- Tightened watchlist download identity fallback so jobs without a stored result index now sync back only when the fallback search produces one unique confident title match, skipping ambiguous matches instead of silently picking result `1`. +- Tightened Jellyfin handoff recovery so a missing source folder with an existing target now counts as `already moved` only when the target library actually looks complete; incomplete targets are surfaced as failures for retry and notification handling. + +## 0.45.5 - 2026-05-25 + +- Reworked the watchlist card action layout into two rows so `Refresh` and `Remove` sit on the first line, with `Download all` and `Auto-download` underneath for a less cluttered button group. + +## 0.45.4 - 2026-05-25 + +- Fixed watchlist-triggered downloads to stop forcing `ani-cli` search result indexes from the web API result order; explicit search-page selections still keep their chosen result, but watchlist and auto-download jobs now avoid bad `-S` mismatches when `ani-cli` resolves the title differently. + +## 0.45.3 - 2026-05-25 + +- Improved queue failure handling for no-op `ani-cli` runs: when the command exits successfully but downloads no files, the job now fails with a clear message explaining that nothing was downloaded and the empty staging folder is cleaned up. + +## 0.45.2 - 2026-05-25 + +- Fixed watchlist completion so a successful partial dub or sub download no longer moves a TV series to `Finished`; the category now changes only when the downloaded mode has every expected episode both available and downloaded. +- Fixed Jellyfin TV handoff to use that same mode-aware completion rule, preventing partial dub libraries such as `8/12` from being moved early. + +## 0.45.1 - 2026-05-25 + +- Expanded the `download_completed` Discord webhook so it includes the full saved file list instead of only the first few paths, splitting the list across extra embeds when needed. +- Added `jellyfin_sync_success` and `jellyfin_sync_failed` Discord webhook events for finished-library handoffs, with success and actionable failure notifications emitted from Jellyfin moves. + +## 0.45.0 - 2026-05-25 + +- Added an optional per-anime auto-download episode offset for split-part seasons, so queued watchlist downloads can rename files as `S02E13`, `S02E14`, and onward while still downloading the underlying source episodes normally. +- Added a migration-safe `auto_download_offset` watchlist column, exposed it in the watchlist auto-download editor, and applied it to both manual watchlist `Download all` jobs and automatic watchlist episode downloads. + +## 0.44.1 - 2026-05-25 + +- Limited bulk watchlist refreshes to entries marked `Watching` or `Planned`, so manual `Refresh All` runs and scheduled refreshes skip `Finished` and `Dropped` shows. + +## 0.44.0 - 2026-05-25 + +- Added host filesystem browse buttons to the Config page path fields so default download and Jellyfin library folders can be selected from a built-in browser modal instead of typed manually. +- Added an authenticated `/api/fs/browse` endpoint for directory and file browsing to support the new host path picker workflow. + +## 0.43.0 - 2026-05-25 + +- Added a Jellyfin section on the Config page with enable or disable control, separate TV and movie destination folders, and a manual `Run now` handoff trigger. +- Added automatic Jellyfin handoff for completed watchlist entries, moving TV libraries only once the show is finished and fully downloaded in at least one language, while downloaded movies move into the configured Jellyfin movie library. + +## 0.42.0 - 2026-05-25 + +- Added a watchlist `TV` or `Movie` media tag with a migration-safe SQLite update for existing installs, plus Search and Watchlist controls to set or change that tag. +- Changed completed library layout so TV downloads now go under `.../tv//Season NN/...`, while movie-tagged entries go under `.../movie//.ext` without season-style folders or episode naming. + +## 0.41.0 - 2026-05-25 + +- Replaced the shared remote-access token flow with a classic single-user username/password login, backed by a persistent signed session cookie for browser sessions and HTTP Basic auth for API clients. +- Added `ANI_CLI_WEB_AUTH_USERNAME` and `ANI_CLI_WEB_AUTH_PASSWORD` support plus matching `config.json` fields, while keeping those private auth fields out of the public config API so the web UI does not leak or overwrite them. + +## 0.40.6 - 2026-05-25 + +- Fixed the Config page `Dependencies` panel so it stretches across the full settings width, centered its dependency badges, and added the installed `ani-cli` version to the runtime info block. + +## 0.40.5 - 2026-05-25 + +- Moved the Config page `Dependencies` panel to the top as a full-width card and changed the remaining settings panels to a two-column layout that collapses back to one column at `1080px` and below. + +## 0.40.4 - 2026-05-25 + +- Widened the watchlist auto-download modal layout so `Name source` and `Name` each fill half of the dialog row instead of staying in narrow grid cells. + +## 0.40.3 - 2026-05-25 + +- Passed the Search page `Season` value into new watchlist entries so the initial auto-download series setting matches what was chosen before tracking the show. + +## 0.40.2 - 2026-05-25 + +- Replaced the inline `Alternative titles` expansion with a centered overlay dialog so opening AniDB title variants no longer shifts the watchlist layout. + +## 0.40.1 - 2026-05-25 + +- Replaced the per-card auto-download expansion with a centered overlay dialog so opening settings no longer shifts the watchlist grid layout. + +## 0.40.0 - 2026-05-25 + +- Added per-watchlist storage for downloaded sub and dub episode lists so auto-download can compare actual downloaded history instead of only refresh count deltas. +- Changed auto-download to queue any available episodes that are still missing after a later manual or scheduled refresh, including backlog episodes that existed before tracking started. + +## 0.39.1 - 2026-05-25 + +- Replaced the auto-download name datalist with an explicit dropdown that lists the current title, AniDB alternative titles, and a `Custom` option. +- Added a per-anime `Series` field to auto-download settings so queued episodes can target a chosen series/season number instead of always using `1`. + +## 0.39.0 - 2026-05-25 + +- Added watchlist auto-download support with global Config controls plus per-anime mode, quality, and library-name settings hidden behind a card-level editor. +- Limited auto-download to `Watching` entries after later manual or scheduled refreshes, and only queue episodes that are newly discovered and not already pending or downloaded. + +## 0.38.0 - 2026-05-25 + +- Switched watchlist `Alternative titles` from AniList lookups to AniDB title-dump matching and now keep only English alternate titles. +- Reused the cached AniDB title metadata already present in the app, which makes alternate-title matching more predictable for watchlist refreshes. + +## 0.37.0 - 2026-05-25 + +- Added an `Alternative titles` toggle to every watchlist card so AniList title variants can be expanded inline. +- Cached AniList title matches during watchlist refreshes and stored Romaji, English, Native, and synonym titles in SQLite with a migration-safe schema update for existing installs. + +## 0.36.3 - 2026-05-24 + +- Changed the Queue page to stop re-rendering the full job list on every poll and refresh only visible non-terminal job cards in place. +- Added single-job queue reads so active job logs and status can update every 2.5 seconds while `done`, `failed`, and `canceled` jobs stop polling automatically. + +## 0.36.2 - 2026-05-23 + +- Replaced the watchlist `Download all` text prompt with an inline dropdown-style picker that lets you choose `Sub` or `Dub` directly on the card. + +## 0.36.1 - 2026-05-23 + +- Replaced the first-visit remote-access 403 dead end with a browser sign-in page and persistent auth cookie, so remote users no longer need to know the `?token=...` bootstrap URL format up front. + +## 0.36.0 - 2026-05-23 + +- Replaced the watchlist card `Open` action with `Download all`, which prompts for `sub` or `dub` and then queues all currently available episodes directly from the watchlist. + +## 0.35.2 - 2026-05-23 + +- Made queue job cards collapsible and defaulted finished jobs to collapsed so long queue histories take less space while running and pending jobs stay expanded. + +## 0.35.1 - 2026-05-23 + +- Changed the shared sidebar navigation to a vertical one-link-per-line stack so the menu stays balanced after adding the dedicated Queue page. + +## 0.35.0 - 2026-05-22 + +- Split the download queue out of the Search page into a dedicated `/queue` page so searching and queue management are separate workflows. +- Added a Queue navigation tab and moved queue polling, logs, paging, retry, cancel, and finished-job cleanup controls onto the new page. + +## 0.34.0 - 2026-05-21 + +- Added a Discord webhook notification event for completed downloads so successful queue jobs can report the resolved anime title, saved files, and cached thumbnail. +- Fixed watchlist download sync so successfully downloaded series are moved into the `Finished` category instead of staying in `Watching`, `Planned`, or another earlier category. + +## 0.33.1 - 2026-05-21 + +- Updated the Docker image build to install `yt-dlp` directly from the latest upstream GitHub release and verify the binary during the image build. + +## 0.33.0 - 2026-05-21 + +- Added Discord webhook notifications with Config page controls for the webhook URL, selectable notification events, and a one-click test action. +- Added refresh result notifications that report each anime with newly found episodes, including cached thumbnails plus current sub and dub counts. +- Added Discord notifications for refresh failures, interrupted refresh runs, and unexpected runtime errors so background problems are easier to notice without watching the web UI. + +## 0.32.5 - 2026-05-17 + +- Fixed the green ready-border highlight so it applies to any watchlist card with both subbed and dubbed episodes complete, instead of only cards filed under the `Finished` category. + +## 0.32.4 - 2026-05-17 + +- Highlighted fully ready finished watchlist entries with a thin green card border when both subbed and dubbed episodes are complete, making download-complete anime easier to scan quickly. + +## 0.32.3 - 2026-05-17 + +- Fixed watchlist download sync so a successful download now reuses the existing tracked entry when the resolved `show_id` changes, preserving its category and marking it downloaded instead of creating a duplicate finished-only row. + +## 0.32.2 - 2026-05-17 + +- Changed Search page queue log rendering to show the newest lines first instead of oldest-first. + +## 0.32.1 - 2026-05-17 + +- Suppressed stdout request logging for routine polling endpoints such as `/api/queue` and `/api/watchlist/refresh-status`, reducing noisy access-log spam during normal browser use. + +## 0.32.0 - 2026-05-17 + +- Changed bulk `Refresh All` processing to pace requests with a configurable delay between shows, reducing the chance of flooding upstream episode sources on large watchlists. +- Added a Config page setting for the per-show bulk refresh delay and expanded regression coverage for the new config field and pacing behavior. + +## 0.31.1 - 2026-05-17 + +- Added stdout lifecycle logging for scheduled watchlist refresh jobs so container logs now show when a scheduled run is queued, starts, finishes successfully, finishes with errors, gets interrupted, or fails. + +## 0.31.0 - 2026-05-17 + +- Added scheduled background watchlist refresh support so the app can periodically recheck episode counts for tracked anime. +- Added Config page controls for enabling or disabling the scheduled refresh and setting the refresh period in minutes. +- Reused the existing background `Refresh All` worker for scheduled runs and expanded regression coverage for the new config fields and scheduler timing behavior. + +## 0.30.4 - 2026-05-17 + +- Added browser-friendly remote auth bootstrap: remote users can open the app once with `?token=...`, the server stores the token in an HTTP-only cookie, and then strips the token back out of the URL with a redirect. +- Kept existing remote header auth support for API clients while extending regression coverage for cookie, query-token, and bootstrap redirect access. + +## 0.30.3 - 2026-05-17 + +- Fixed container startup under `docker-compose` and custom `USER_UID`/`USER_GID` mappings by launching the web app with `python3 /app/app.py` directly instead of relying on the `./ani-cli-web` launcher file being executable inside the container. + +## 0.30.2 - 2026-05-17 + +- Added container runtime support for `USER_UID` and `USER_GID`, letting `ani-cli-web` run under a chosen numeric host user/group so files created in mounted folders are not owned by `root`. +- Updated the Docker and Compose documentation to show how to pass host UID/GID values into the container. + +## 0.30.1 - 2026-05-17 + +- Added a `docker-compose.yaml` file that builds the bundled Docker image, mounts `./downloads` and `./.ani-cli-web`, and exposes the ani-cli git remotes, branch, and startup flags through Compose environment defaults. +- Documented the Compose workflow alongside the existing Docker build and `docker run` setup. + +## 0.30.0 - 2026-05-17 + +- Added a Docker image build that clones `ani-cli` and `ani-cli-web` from configurable git remotes, installs `ani-cli` system-wide, and starts `ani-cli-web` from `/app`. +- Added container startup support for `UPDATE_ON_START=true`, which runs `sudo ani-cli --update` before launching the web app. +- Documented the Docker build arguments, runtime mounts for `./downloads` and `./.ani-cli-web`, and the default container networking and storage layout. + +## 0.29.5 - 2026-05-16 + +- Fixed `/api/config` malformed-body handling so non-object JSON payloads now return `400 Bad Request` instead of silently resetting saved settings to defaults. +- Expanded regression coverage for invalid config payload shapes. + +## 0.29.4 - 2026-05-16 + +- Fixed `/api/watchlist/add` malformed-body handling so non-object JSON payloads now return `400 Bad Request` instead of surfacing as an internal server error. +- Expanded regression coverage for invalid watchlist add payload shapes. + +## 0.29.3 - 2026-05-16 + +- Fixed duplicate watchlist add responses so already-tracked shows now return `200 OK` with `created: false` instead of the misleading `201 Created`. +- Tightened `/api/watchlist/ensure-thumbnails` request validation so `show_ids` must be a JSON array and `force` is parsed as a real boolean value instead of relying on Python truthiness. +- Expanded regression coverage for duplicate watchlist add status handling and thumbnail warm-up payload validation. + +## 0.29.2 - 2026-05-16 + +- Hardened the download queue again so unexpected post-launch worker exceptions now fail the active job cleanly instead of killing the worker thread and leaving the job stuck in `running`. +- Fixed malformed JSON POST handling for watchlist action routes so missing required fields such as `show_id` and `data_url` now return `400 Bad Request` instead of surfacing as misleading `404 Not Found` errors. +- Expanded regression coverage for post-launch queue worker failures and required-field validation on watchlist POST endpoints. + +## 0.29.1 - 2026-05-16 + +- Hardened download queue startup so a failed `ani-cli` process launch now marks the job as failed instead of crashing the background queue worker thread. +- Cleaned up per-job staging directories after canceled and failed download runs, preventing partial artifacts from piling up under `.ani-cli-web/staging/`. +- Fixed thumbnail refresh fallback flow so stale stored AnimeSchedule routes and AniDB IDs now continue on to title-based lookups instead of aborting the whole cover refresh attempt early. +- Expanded regression coverage for queue startup failures, failed-download staging cleanup, and stale thumbnail source fallback behavior. + +## 0.29.0 - 2026-05-16 + +- Fixed watchlist thumbnail retry backoff so failed automatic cover lookups now record a retry timestamp and stop hammering AnimeSchedule or AniDB on every later warm-up pass. +- Hardened queued-download fallback matching to keep season and part identity intact while still accepting equivalent season notation such as `2nd Season` and `Season 2`. +- Made paged watchlist responses clamp out-of-range page requests before fetching rows, keeping the reported `page` value aligned with the returned `items`. +- Expanded regression coverage for thumbnail backoff persistence, season-aware fallback matching, and out-of-range watchlist pagination. + +## 0.28.0 - 2026-05-16 + +- Bounded watchlist refresh network calls with a shorter dedicated timeout and matched worker-shutdown grace period, making per-show and bulk watchlist teardown behavior more predictable when upstream services are slow. +- Improved completed-download watchlist sync failures by preserving the fallback resolution reason and surfacing it in logs and raised errors instead of collapsing everything into one generic unresolved `show_id` message. +- Continued the backend split by moving shared title-normalization and title-query helpers into a dedicated `title_matching.py` module. +- Tightened the shared browser polling helper so it uses serial `setTimeout` scheduling plus unload cleanup. +- Expanded regression coverage for bounded watchlist refresh timeouts, clearer fallback-match failures, and the updated poll-helper cleanup behavior. + +## 0.27.0 - 2026-05-15 + +- Made runtime reset stricter by refusing to clear the global runtime state when background workers do not stop cleanly, preventing stale workers from outliving the runtime instance they came from. +- Taught per-show watchlist refreshes to stop earlier during shutdown by checking the refresh stop flag before committing later update stages. +- Marked bulk watchlist refresh jobs as `interrupted` when shutdown interrupts them instead of letting them look like a normal successful completion. +- Hardened the completed-download watchlist sync fallback so it only trusts a fallback search result when the queued title still matches confidently, failing safe instead of risking the wrong anime being marked downloaded. +- Reworked the shared browser polling helper to use serial `setTimeout` polling with unload cleanup instead of a bare repeating interval. +- Continued the backend split by moving queued-download watchlist identity resolution into a dedicated `watchlist_identity.py` helper module. +- Expanded regression coverage for interrupted resets, interrupted bulk watchlist refresh jobs, safer watchlist identity fallback behavior, and browser poll helper cleanup. + +## 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. +- Persisted watchlist refresh history in SQLite, restore interrupted `pending` and `running` refresh jobs as `interrupted` after restart, and block duplicate queued refresh submissions. +- Hardened thumbnail in-flight event cleanup so unexpected errors during cache updates do not leave later requests waiting on a stuck per-show event. +- Return `400 Bad Request` for non-UTF-8 JSON payloads instead of surfacing them as internal server errors. +- Expanded regression coverage for deferred bootstrap, duplicate refresh protection, interrupted refresh recovery, UTF-8 body validation, and thumbnail event cleanup. + +## 0.11.0 - 2026-05-13 + +- Moved the large inline page templates and shared page-shell helpers into `web_templates.py`, reducing `app.py` to server and runtime logic. +- Changed the shared SQLite file from `queue.sqlite3` to `state.sqlite3` and added automatic migration from the previous project-local database filename. +- Made watchlist poster image routes cache-only so opening the watchlist no longer fans out synchronous remote cover lookups from the browser image tags. +- Moved `Refresh All` onto a background watchlist-refresh worker with status polling so large watchlists no longer block a request thread until every show finishes updating. +- Added an HTTP JSON body-size guard to reject oversized uploads before the server reads unbounded request bodies into memory. +- Reworked the test harness to run against an isolated temporary state root and added handler-level coverage for thumbnail serving, background refresh status, and oversized POST rejection. + +## 0.10.0 - 2026-05-13 + +- Fixed running-job cancellation so the worker now consistently finishes canceled downloads as `canceled` instead of drifting into failed or completed final states. +- Fixed watchlist `Refresh All` to iterate every tracked show instead of silently stopping at the paged list limit. +- Added loopback-only request protection by default, with `ANI_CLI_WEB_ALLOW_REMOTE=1` available when remote/LAN access is intentionally desired. +- Extracted shared sidebar branding and page-link helpers to reduce repeated inline page chrome across Search, Config, and Watchlist. +- Added focused regression tests for cancellation propagation, full watchlist refresh coverage, local-access helpers, and shared template helpers. + +## 0.9.0 - 2026-05-13 + +- Reworked Search, Config, and Watchlist with a Tsuki-inspired visual refresh using violet accents, glassy surfaces, softer gradients, and calmer card styling. +- Kept the existing ani-cli-web behavior and routing intact while unifying the refreshed design language across the whole web UI. + +## 0.8.16 - 2026-05-13 + +- Unified the left sidebar layout across Search, Config, and Watchlist so the page chrome now uses the same panel width, spacing, and structure everywhere. +- Kept the Config page compact by constraining its main settings content while restoring the shared sidebar pattern. + +## 0.8.15 - 2026-05-13 + +- Removed the dependency status panel from the Search page so the main route stays focused on search, episode selection, and queue management. +- Reworked the Config page into a centered compact settings layout instead of the earlier stretched split-screen view. + +## 0.8.14 - 2026-05-13 + +- Added a standalone `/config` page for saved defaults. +- Moved the Search page defaults section onto the new Config page and added Config navigation across Search and Watchlist. +- Kept dependency status visible while moving saved folder, mode, and quality management into the dedicated settings view. + +## 0.8.13 - 2026-05-12 + +- Added a manual thumbnail upload button for watchlist cards that still have no poster, saving the selected image into the local thumbnail cache for that anime. + +## 0.8.12 - 2026-05-12 + +- Improved thumbnail lookup for season-labeled titles by retrying AnimeSchedule searches with simplified base-title queries before falling back to AniDB. +- Stopped failed thumbnail fetch attempts from refreshing the retry window, so broken lookups do not get stuck in an immediate 404 state after one bad fetch. +- Added extra request headers to AniDB page lookups to improve compatibility with cover-page fetches that were returning `403 Forbidden`. + +## 0.8.11 - 2026-05-12 + +- Added a small per-anime thumbnail reload button on watchlist cards that forces a redownload of that show's cached cover image. + +## 0.8.10 - 2026-05-12 + +- Fixed watchlist source links to use AllManga's `bangumi/` page format instead of the earlier incorrect route pattern. + +## 0.8.9 - 2026-05-12 + +- Changed watchlist anime titles into direct links to their AllManga source pages, using the tracked show ID plus a normalized title slug. + +## 0.8.8 - 2026-05-11 + +- Moved `config.json` into the project-local `.ani-cli-web/` folder so app settings now live alongside the queue database, watchlist data, staging files, and thumbnail cache. +- Added automatic migration from the legacy XDG config location so existing web app settings are preserved on first startup after the move. + +## 0.8.7 - 2026-05-11 + +- Moved all project runtime state that previously lived under `~/.local/state/ani-cli-web/` into the project-local `.ani-cli-web/` folder. +- Added one-time migration for the legacy queue database, queue JSON, watchlist JSON, AniDB title dump, staging directory, and thumbnails so existing local data is carried into the project folder automatically. + +## 0.8.6 - 2026-05-11 + +- Changed watchlist cards to append into the DOM before assigning poster image URLs, avoiding detached-image and lazy-load edge cases during poster rendering. +- Added a per-page thumbnail nonce to the local poster URL so a fresh watchlist load always bypasses stale browser-cached misses for `/api/watchlist/thumb/...`. + +## 0.8.5 - 2026-05-11 + +- Fixed watchlist poster rendering for browser-cached images by attaching thumbnail load and error handlers before setting `img.src`, then immediately showing already-complete images. +- This resolves the case where posters existed on disk and the watchlist route was healthy, but cached images stayed hidden because the browser had already finished loading them before the old listener was attached. + +## 0.8.4 - 2026-05-11 + +- Fixed the watchlist thumbnail renderer to always request the local `/api/watchlist/thumb/...` image route instead of trusting stale `thumbnail_ready` state from older cached entries. +- This lets already-downloaded project-local thumbnails render on the page even when the cached file exists but the watchlist row has not yet been refreshed to mark it ready. + +## 0.8.3 - 2026-05-11 + +- Migrated any legacy watchlist thumbnails from the old state-directory cache into the new project-local `.ani-cli-web/thumbnails/` folder on access. +- Fixed thumbnail retry gating so the app no longer skips refetch attempts when a thumbnail filename exists in the database but the actual cached file is missing on disk. +- Expanded thumbnail debug logging to show whether the expected cached file actually exists before the retry-window decision is made. + +## 0.8.2 - 2026-05-11 + +- Moved watchlist thumbnail storage into the project-local `.ani-cli-web/thumbnails/` folder so cached images can be inspected directly from the repository tree. +- Added debug logs for the local `/api/watchlist/thumb/...` route and file-serving path so thumbnail cache hits, misses, and served files are visible in `--debug` output. + +## 0.8.1 - 2026-05-11 + +- Added a `--debug` runtime flag for `ani-cli-web` and `./ani-cli-web` that logs thumbnail-source request flow, match decisions, responses, cache writes, and failures while fetching watchlist images. +- Added support for the typo-tolerant `---debug` form as well, so debugging still works if that form is used by mistake. + +## 0.8.0 - 2026-05-11 + +- Switched watchlist thumbnails to prefer AnimeSchedule metadata, which `ani-cli` already uses upstream for title-based metadata lookups. +- Stored the resolved AnimeSchedule route and title on watchlist entries so future poster fetches can reuse that stable route instead of repeating a title search every time. +- Restored only a one-shot watchlist thumbnail warm-up request for the visible page, avoiding the old repeated background refresh loop. + +## 0.7.5 - 2026-05-11 + +- Removed automatic watchlist thumbnail warm-up requests after page render so the watchlist no longer keeps making background refresh-style requests while the thumbnail issue is shelved. + +## 0.7.4 - 2026-05-11 + +- Added watchlist removal end-to-end with a new `Remove` button beside `Refresh` and `Open` on each watchlist card. +- Added a watchlist remove API action and cleaned up a card's cached poster file when its watchlist entry is deleted. + +## 0.7.3 - 2026-05-11 + +- Handled browser/client disconnects gracefully in the shared HTTP response writer so abandoned watchlist requests no longer cascade into `BrokenPipeError` tracebacks. +- Ignored disconnect exceptions in the request exception path to avoid attempting a second error response on an already closed socket. + +## 0.7.2 - 2026-05-11 + +- Fixed the AniDB poster extractor to handle relative `/images/main/...` image links in addition to fully qualified CDN URLs. +- Normalized AniDB poster URLs through `urljoin` before downloading, which should unblock entries where AniDB serves the image link off the anime page first and redirects afterward. +- Added an AniDB referer header to image downloads for better compatibility with direct cover fetches. + +## 0.7.1 - 2026-05-11 + +- Stored the matched AniDB `aid` and AniDB title on each watchlist entry so future thumbnail fetches reuse a stable AniDB identity instead of re-searching by name every time. +- Added lightweight AniDB debug hints to watchlist cards via hover text so matched AniDB IDs and titles can be inspected without changing the compact card layout. +- Allowed forced thumbnail retries to rematch by title when a previously stored AniDB ID fails to produce a cover. + +## 0.7.0 - 2026-05-11 + +- Switched watchlist thumbnails from AniList to AniDB. +- Added AniDB title-dump caching for local title-to-anime resolution and AniDB page poster extraction for one-at-a-time cover fetching. +- Kept local thumbnail caching so each watchlist entry only needs its AniDB poster resolved once while it remains tracked. + +## 0.6.2 - 2026-05-11 + +- Changed watchlist thumbnail warm-up to force small sequential retry batches for the currently visible page so previously failed covers can recover immediately. +- Removed the client-side `CSS.escape` dependency from thumbnail repaint logic for broader browser compatibility. +- Kept thumbnail batching capped to avoid reintroducing external source rate-limit spikes. + +## 0.6.1 - 2026-05-11 + +- Changed watchlist thumbnail loading to a controlled batched warm-up flow so missing covers are not requested all at once. +- Added per-show thumbnail request deduplication to avoid duplicate cover fetches while the same image is already being resolved. +- Updated the watchlist client to repaint cards when newly cached thumbnails become available. + +## 0.6.0 - 2026-05-11 + +- Switched the watchlist to paged loading with 30 cards per page so the desktop view can target a 5 by 6 layout without leaving large blank areas. +- Tightened the sidebar again so the main watchlist pane has more room for cards on wide screens. +- Improved thumbnail loading by prefetching missing covers in the background and retrying failed card image requests with a forced refresh. + +## 0.5.0 - 2026-05-11 + +- Reworked the watchlist into a tighter multi-column poster grid that prioritizes title, sub and dub counts, last checked time, and refresh/open actions. +- Added cached external cover lookups for watchlist entries under the app state directory. +- Reduced the watchlist sidebar width so wider screens can fit roughly 4 to 5 anime cards per row. + +## 0.4.0 - 2026-05-11 + +- Reworked the standalone watchlist page to use the same split-panel layout and navigation language as the search page. +- Added watchlist summary cards and richer tracked-show cards so status, episode totals, and search handoff all live in the same visual system. +- Updated the search page navigation styling so both pages now feel like one consistent GUI. + +## 0.3.1 - 2026-05-11 + +- Split the watchlist out to its own standalone `/watchlist` page. +- Kept the main `/` page focused on search, queue management, and downloads. +- Added a watchlist-to-search handoff that pre-fills the search page query. + +## 0.3.0 - 2026-05-11 + +- Moved the watchlist into SQLite with automatic migration from the legacy `watchlist.json` file. +- Changed the watchlist model to track one row per show with separate sub and dub episode counts. +- Added manual refresh actions for one show or the entire watchlist. +- Split the sidebar into switchable Search and Watchlist pages. +- Added direct watchlist tracking from search results. + +## 0.2.2 - 2026-05-11 + +- Fixed a broken watchlist merge that caused `app.py` to fail during startup with an `IndentationError`. +- Restored the web server entrypoint so `./ani-cli-web` starts the local HTTP server again. +- Repaired watchlist POST routes, storage lookups, and embedded page script placement so the watchlist UI can load and update cleanly. + +## 0.2.1 - 2026-05-11 + +- Padded season folder numbers to two digits, such as `Season 01` and `Season 02`. + +## 0.2.0 - 2026-05-11 + +- Replaced JSON queue storage with SQLite-backed queue persistence. +- Added automatic migration from the previous `queue.json` queue file. +- Limited the queue view to 10 jobs per page with previous and next controls. +- Added bulk queue actions to remove finished jobs and retry all failed jobs. + +## 0.1.2 - 2026-05-11 + +- Updated finalized download layout to save media under `ANI_CLI_DOWNLOAD_DIR/anime_name/Season /`. + +## 0.1.1 - 2026-05-11 + +- Expanded `.gitignore` to ignore `__pycache__` directories at every project depth. + +## 0.1.0 - 2026-05-11 + +- Split `ani-cli-web` into a standalone project that uses system-wide `ani-cli`. +- Added project hygiene files: `.gitignore`, `VERSION`, and this changelog. +- Documented runtime state handling and standalone project layout. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b4836ea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM python:3.12-slim-bookworm + +ENV DEBIAN_FRONTEND=noninteractive \ + PATH=/usr/local/bin:/usr/bin:/bin \ + ANI_CLI_DOWNLOAD_DIR=/downloads \ + ANI_CLI_WEB_HOST=0.0.0.0 \ + ANI_CLI_WEB_PORT=8421 \ + ANI_CLI_WEB_JOB_STDOUT=true \ + UPDATE_ON_START=false \ + USER_UID= \ + USER_GID= + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + aria2 \ + bash \ + botan \ + ca-certificates \ + curl \ + ffmpeg \ + nodejs \ + npm \ + sudo \ + util-linux \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /app + +COPY package.json /app/ +RUN cd /app && npm install --omit=dev + +COPY app.py app_support.py config_page.py http_handler.py provider_bridge.py provider_downloader.py queue_jobs.py queue_page.py search_page.py template_helpers.py title_matching.py watchlist_identity.py watchlist_page.py web_templates.py VERSION CHANGELOG.md favicon.png /app/ +COPY providers /app/providers + +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh \ + && mkdir -p /downloads /app/.kaizoku + +WORKDIR /app + +EXPOSE 8421 + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 774b9d8..ea8417a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,78 @@ -# kaizoku +# Kaizoku +Kaizoku is a local web app for searching, tracking, and downloading anime from Anikoto, AniNeko, and AnimePahe. It keeps the previous ani-cli-web style and management flows, but the downloader no longer uses `ani-cli`, `anipy-cli`, or AllManga/AllAnime. + +## Features + +- Search anime through the configured provider: `anikoto`, `anineko`, or `pahe`. +- Queue single episodes or batches in subbed or dubbed mode. +- Save files with Jellyfin-friendly layout: `TV/Series Name/Season 01/Series Name - S01E01.mp4`. +- Save English external subtitles when the provider exposes usable subtitle tracks. +- Manage watchlists for `Watching`, `Planned`, `Finished`, and `Dropped`. +- Periodically refresh selected watchlists and auto-download newly available episodes. +- Sync watchlist download flags from the filesystem. +- Hand completed libraries to Jellyfin TV or movie folders. +- Export/import watchlist backups. +- Send unified Discord notifications for downloads, refreshes, Jellyfin handoff, and runtime errors. +- Store configuration, queue state, and watchlist data in SQLite under `.kaizoku/`. + +## Requirements + +- Python 3.12 or newer. +- Node.js and npm for the provider bridge. +- `ffmpeg` available in `PATH`. + +Install Node dependencies once: + +```sh +npm install +``` + +Run locally: + +```sh +./kaizoku +``` + +Then open: + +```text +http://127.0.0.1:8421/ +``` + +Useful environment variables: + +- `ANI_CLI_WEB_HOST=0.0.0.0` +- `ANI_CLI_WEB_PORT=8421` +- `ANI_CLI_WEB_ALLOW_REMOTE=1` +- `ANI_CLI_WEB_AUTH_USERNAME=admin` +- `ANI_CLI_WEB_AUTH_PASSWORD=choose-a-long-random-password` +- `ANI_CLI_WEB_JOB_STDOUT=true` +- `ANI_CLI_WEB_STATE_ROOT=/path/to/state` + +The `ANI_CLI_WEB_*` names are kept as compatibility aliases while the app transitions from the previous project. + +## Docker + +```sh +docker compose up -d --build +``` + +The compose file mounts: + +- `./downloads` to `/downloads` +- `./.kaizoku` to `/app/.kaizoku` + +The Docker image installs Python, Node.js, npm, and `ffmpeg`, then runs `npm install --omit=dev` for the provider bridge. + +## Download Flow + +Kaizoku stores provider-backed show IDs as `provider:id`, for example `anikoto:some-show-slug`. Queue jobs resolve the episode source through `providers/bridge.js`, then `provider_downloader.py` downloads the media with `ffmpeg` into a staging directory. Existing finalization code moves staged files into the configured library layout, preserving data already present in production download folders. + +## Data Safety + +Kaizoku uses additive SQLite migrations for queue and watchlist schema changes. Upgrades do not delete configuration, watchlists, queue history, or downloaded files. Jellyfin handoff moves only completed library folders that pass the existing readiness checks. + +## Provider Source + +The Anikoto, AniNeko, and AnimePahe parser modules in `providers/extensions/Anime/` are adapted from TheYogMehta/extensions and retain their GPL/license headers. Kaizoku acts as a local client-side parser/downloader wrapper and does not host media. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..c5d4cee --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.51.0 diff --git a/app.py b/app.py new file mode 100644 index 0000000..9c0276a --- /dev/null +++ b/app.py @@ -0,0 +1,3313 @@ +#!/usr/bin/env python3 +import base64 +import hashlib +import gzip +import json +import mimetypes +import os +import re +import shutil +import sqlite3 +import threading +import time +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET +from binascii import Error as BinasciiError +from datetime import datetime, timezone +from http.server import ThreadingHTTPServer +from pathlib import Path +import sys +from urllib.parse import quote, urljoin, urlparse + +import app_support +import provider_bridge +from app_support import ( + AGENT, + ALLANIME_API, + ALLANIME_REFERER, + ALLMANGA_SITE_BASE, + ANIDB_ANIME_PAGE, + ANIDB_TITLES_CACHE, + ANIDB_TITLES_PATH, + ANIDB_TITLES_URL, + ANIMESCHEDULE_API, + ANIMESCHEDULE_IMAGE_BASE, + APP_NAME, + CONFIG_PATH, + DEFAULT_CONFIG, + MAX_JSON_BODY_BYTES, + MODE_CHOICES, + QUALITY_CHOICES, + send_discord_webhook_event, + STATE_DB_PATH, + THUMBNAIL_RETRY_SECONDS, + THUMBNAIL_ROOT, + VERSION, + WATCHLIST_CATEGORY_CHOICES, + WATCHLIST_CATEGORY_LABELS, + WATCHLIST_JSON_PATH, + WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS, + WORKER_SHUTDOWN_TIMEOUT_SECONDS, + background_workers_enabled, + client_address_is_local, + clear_remote_access_sessions, + debug_log, + load_json, + migrate_legacy_state_storage, + normalize_season, + normalize_config, + normalize_episode_offset, + normalize_media_type, + normalize_result_index, + now_iso, + remote_access_session_fingerprint, + sanitize_path_component, + thumbnail_file_path, + validate_discord_webhook_url, + write_json, +) +from http_handler import HandlerContext, build_handler_class, server_host, server_port +from queue_jobs import DownloadQueue, JellyfinSyncJobs, WatchlistAutoRefreshScheduler, WatchlistRefreshJobs +from title_matching import ( + base_title_variants, + normalize_title_key, + normalize_identity_title_key, + title_lookup_queries, + title_match_variants, +) +from watchlist_identity import ( + resolve_job_watchlist_identity as resolve_job_watchlist_identity_impl, + titles_confidently_match, +) +from web_templates import CONFIG_HTML, INDEX_HTML, PAGE_LINKS, QUEUE_HTML, WATCHLIST_HTML, render_page_links, render_sidebar_brand + +RUNTIME_LOCK = threading.RLock() + + +class HttpError(Exception): + def __init__(self, status, message): + super().__init__(message) + self.status = status + self.message = message + + +CONFIG = None +WATCHLIST = None +DOWNLOAD_QUEUE = None +WATCHLIST_REFRESH = None +WATCHLIST_AUTO_REFRESH = None +WATCHLIST_JELLYFIN_SYNC = None +WATCHLIST_BACKUP_FORMAT_VERSION = 1 + + +def graph_request(query, variables, timeout=25): + payload = json.dumps({"variables": variables, "query": query}).encode("utf-8") + request = urllib.request.Request( + f"{ALLANIME_API}/api", + data=payload, + headers={ + "Content-Type": "application/json", + "Referer": ALLANIME_REFERER, + "User-Agent": AGENT, + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8") + except urllib.error.URLError as exc: + raise RuntimeError(f"Could not reach AllAnime: {exc}") from exc + except TimeoutError as exc: + raise RuntimeError("AllAnime request timed out") from exc + + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError("AllAnime returned an unreadable response") from exc + + if data.get("errors"): + message = data["errors"][0].get("message", "AllAnime returned an error") + raise RuntimeError(message) + return data.get("data", {}) + + +def parse_iso_timestamp(value): + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + + +def thumbnail_retry_due(checked_at): + timestamp = parse_iso_timestamp(checked_at) + if not timestamp: + return True + age = datetime.now(timezone.utc) - timestamp.astimezone(timezone.utc) + return age.total_seconds() >= THUMBNAIL_RETRY_SECONDS + + +def cover_route(show_id): + return f"/api/watchlist/thumb/{quote(str(show_id).strip(), safe='')}" + + +def search_thumbnail_cache_key(title): + normalized = normalize_title_key(title) or str(title or "").strip().casefold() + digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12] + return f"search-{digest}" + + +def anime_source_url(show_id, title): + normalized_show_id = str(show_id or "").strip() + if not normalized_show_id: + return f"{ALLMANGA_SITE_BASE}/search-anime?tr=sub&cty=ALL&query={quote(str(title or '').strip(), safe='')}" + return f"{ALLMANGA_SITE_BASE}/bangumi/{quote(normalized_show_id, safe='')}" + + +def infer_image_extension(url, content_type): + normalized_type = str(content_type or "").split(";", 1)[0].strip().lower() + extension = mimetypes.guess_extension(normalized_type, strict=False) + if extension == ".jpe": + extension = ".jpg" + if extension: + return extension + suffix = Path(urlparse(str(url or "")).path).suffix.lower() + if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}: + return suffix + return ".jpg" + + +def save_thumbnail_bytes(show_id, source_name, content_type, content): + if not content: + raise RuntimeError("Thumbnail content was empty") + THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True) + stem = sanitize_path_component(show_id, "anime") + extension = infer_image_extension(source_name, content_type) + final_path = THUMBNAIL_ROOT / f"{stem}{extension}" + tmp_path = final_path.with_suffix(final_path.suffix + ".tmp") + with tmp_path.open("wb") as handle: + handle.write(content) + tmp_path.replace(final_path) + for existing in THUMBNAIL_ROOT.glob(f"{stem}.*"): + if existing != final_path and existing.is_file(): + existing.unlink(missing_ok=True) + debug_log("thumbnail.cache.write", show_id=show_id, path=final_path, bytes=len(content), content_type=content_type) + return final_path.name + + +def decode_image_data_url(data_url): + text = str(data_url or "").strip() + match = re.match(r"^data:(image/[-a-zA-Z0-9.+]+);base64,(.+)$", text, re.DOTALL) + if not match: + raise ValueError("Thumbnail upload must be a base64 image data URL.") + content_type = match.group(1).strip().lower() + try: + content = base64.b64decode(match.group(2), validate=True) + except (ValueError, BinasciiError) as exc: + raise ValueError("Thumbnail upload data was not valid base64.") from exc + if len(content) > 8 * 1024 * 1024: + raise ValueError("Thumbnail upload is too large. Keep it under 8 MB.") + return content_type, content + + +def animeschedule_request(path, params=None, timeout=25): + params = params or {} + query = "&".join(f"{quote(str(key), safe='')}={quote(str(value), safe='')}" for key, value in params.items()) + url = f"{ANIMESCHEDULE_API}{path}" + if query: + url = f"{url}?{query}" + debug_log("animeschedule.request", url=url, params=params) + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": AGENT, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8") + debug_log( + "animeschedule.response", + url=url, + status=getattr(response, "status", "unknown"), + bytes=len(raw.encode("utf-8")), + snippet=raw[:200], + ) + except urllib.error.URLError as exc: + debug_log("animeschedule.error", url=url, error=exc) + raise RuntimeError(f"Could not reach AnimeSchedule: {exc}") from exc + except TimeoutError as exc: + debug_log("animeschedule.timeout", url=url) + raise RuntimeError("AnimeSchedule request timed out") from exc + + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError("AnimeSchedule returned unreadable JSON") from exc + + +def encode_alternative_titles(alternative_titles): + normalized = [] + for item in alternative_titles or []: + if not isinstance(item, dict): + continue + label = str(item.get("label") or "").strip() + value = str(item.get("value") or "").strip() + if label and value: + normalized.append({"label": label, "value": value}) + return json.dumps(normalized, ensure_ascii=True) + + +def decode_alternative_titles(payload): + if not payload: + return [] + try: + raw = json.loads(payload) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + normalized = [] + seen = set() + for item in raw if isinstance(raw, list) else []: + if not isinstance(item, dict): + continue + label = str(item.get("label") or "").strip() + value = str(item.get("value") or "").strip() + normalized_key = (label.lower(), normalize_title_key(value) or value.casefold()) + if not label or not value or not normalized_key[1] or normalized_key in seen: + continue + seen.add(normalized_key) + normalized.append({"label": label, "value": value}) + return normalized + + +def extract_anidb_alternative_titles(aid, primary_title=""): + normalized_aid = str(aid or "").strip() + if not normalized_aid: + return [] + primary_key = normalize_title_key(primary_title) or str(primary_title or "").strip().casefold() + results = [] + seen = set() + type_labels = { + "official": "English", + "main": "English", + "short": "English", + "syn": "English", + } + for entry in get_cached_anidb_titles(): + if str(entry.get("aid") or "").strip() != normalized_aid: + continue + if str(entry.get("lang") or "").strip().lower() != "en": + continue + text = str(entry.get("title") or "").strip() + normalized = normalize_title_key(text) or text.casefold() + if not normalized or normalized == primary_key or normalized in seen: + continue + seen.add(normalized) + label = type_labels.get(str(entry.get("type") or "").strip().lower(), "English") + results.append({"label": label, "value": text}) + return results + + +def parse_nonnegative_int(value, default=0): + try: + number = int(value) + except (TypeError, ValueError): + return default + return number if number >= 0 else default + + +def normalize_animeschedule_status(value): + text = str(value or "").strip() + return text.title() if text else "" + + +def normalize_watchlist_category(value, default="watching"): + text = str(value or "").strip().lower() + aliases = { + "watching": "watching", + "planned": "planned", + "planed": "planned", + "finished": "finished", + "dropped": "dropped", + } + return aliases.get(text, default) + + +def watchlist_category_label(value): + return WATCHLIST_CATEGORY_LABELS[normalize_watchlist_category(value)] + + +def media_type_label(value): + return "Movie" if normalize_media_type(value) == "movie" else "TV" + + +def normalize_animeschedule_entry(node): + if not isinstance(node, dict): + return None + route = str(node.get("route") or "").strip() + image_route = str(node.get("imageVersionRoute") or "").strip() + title = str(node.get("title") or "").strip() + names = node.get("names") if isinstance(node.get("names"), dict) else {} + if not route or not (title or names): + return None + return { + "route": route, + "title": title, + "imageVersionRoute": image_route, + "names": names, + "status": normalize_animeschedule_status(node.get("status")), + "episodes": parse_nonnegative_int(node.get("episodes")), + "subPremier": str(node.get("subPremier") or "").strip() or None, + "dubPremier": str(node.get("dubPremier") or "").strip() or None, + } + + +def collect_animeschedule_entries(payload): + entries = [] + + def walk(node): + if isinstance(node, dict): + entry = normalize_animeschedule_entry(node) + if entry: + entries.append(entry) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(payload) + return entries + + +def animeschedule_title_candidates(entry): + values = [] + if entry.get("title"): + values.append(entry["title"]) + names = entry.get("names") if isinstance(entry.get("names"), dict) else {} + for key in ("english", "romaji", "native", "abbreviation"): + text = str(names.get(key) or "").strip() + if text: + values.append(text) + synonyms = names.get("synonyms") if isinstance(names.get("synonyms"), list) else [] + for value in synonyms: + text = str(value or "").strip() + if text: + values.append(text) + deduped = [] + seen = set() + for value in values: + normalized = normalize_title_key(value) + if normalized and normalized not in seen: + seen.add(normalized) + deduped.append(value) + return deduped + + +def score_animeschedule_match(query_variants, entry): + best = None + for candidate in animeschedule_title_candidates(entry): + normalized = normalize_title_key(candidate) + if not normalized: + continue + score = None + if normalized in query_variants: + score = 110 + else: + for variant in query_variants: + if variant and (normalized in variant or variant in normalized): + score = max(score or 0, 80 - abs(len(normalized) - len(variant))) + if score is not None: + best = max(best or score, score) + return best + + +def fetch_animeschedule_cover_by_route(route, fallback_title=""): + debug_log("thumbnail.source.route_lookup", source="AnimeSchedule", route=route, fallback_title=fallback_title) + entry = fetch_animeschedule_anime_by_route(route, fallback_title=fallback_title) + image_route = str(entry.get("imageVersionRoute") or "").strip() + if not image_route: + raise RuntimeError("AnimeSchedule anime did not provide an image route") + debug_log( + "thumbnail.source.route_match", + source="AnimeSchedule", + route=entry["route"], + image_route=image_route, + title=entry["title"], + ) + return { + "route": entry["route"], + "title": entry["title"], + "url": urljoin(ANIMESCHEDULE_IMAGE_BASE, image_route), + } + + +def find_animeschedule_match(title): + query_variants = title_match_variants(title) + if not query_variants: + raise RuntimeError("Missing title for AnimeSchedule lookup") + queries = title_lookup_queries(title) + debug_log("thumbnail.source.title_lookup", source="AnimeSchedule", title=title, variants=sorted(query_variants), queries=queries) + best = None + candidate_count = 0 + last_error = None + for query in queries: + try: + payload = animeschedule_request("/anime", {"q": query, "st": "alphabetic"}) + except Exception as exc: + last_error = exc + debug_log("thumbnail.source.query_failed", source="AnimeSchedule", title=title, query=query, error=exc) + continue + for entry in collect_animeschedule_entries(payload): + candidate_count += 1 + score = score_animeschedule_match(query_variants, entry) + if score is None: + continue + if best is None or score > best["score"]: + best = { + "route": entry["route"], + "title": next(iter(animeschedule_title_candidates(entry)), entry.get("title") or str(title)), + "imageVersionRoute": str(entry.get("imageVersionRoute") or "").strip(), + "score": score, + } + if best is not None: + break + if not best: + debug_log("thumbnail.source.no_match", source="AnimeSchedule", title=title, candidates=candidate_count, queries=queries) + if last_error is not None: + raise last_error + raise RuntimeError("AnimeSchedule lookup did not find a matching anime") + debug_log( + "thumbnail.source.best_match", + source="AnimeSchedule", + title=title, + route=best["route"], + matched_title=best["title"], + image_route=best["imageVersionRoute"], + score=best["score"], + candidates=candidate_count, + ) + return best + + +def fetch_animeschedule_anime_by_route(route, fallback_title="", timeout=25): + payload = animeschedule_request(f"/anime/{quote(str(route).strip(), safe='')}", timeout=timeout) + entry = normalize_animeschedule_entry(payload) + if not entry: + raise RuntimeError("AnimeSchedule anime response was missing required fields") + if fallback_title and not entry.get("title"): + entry["title"] = str(fallback_title).strip() + return entry + + +def resolve_animeschedule_anime(title, route="", timeout=25): + stored_route = str(route or "").strip() + if stored_route: + try: + return fetch_animeschedule_anime_by_route(stored_route, fallback_title=title, timeout=timeout) + except Exception: + debug_log("animeschedule.route_refresh_failed", route=stored_route, title=title) + + best = find_animeschedule_match(title) + return fetch_animeschedule_anime_by_route(best["route"], fallback_title=best["title"], timeout=timeout) + + +def fetch_animeschedule_cover(title): + best = find_animeschedule_match(title) + if best["imageVersionRoute"]: + return { + "route": best["route"], + "title": str(best["title"]).strip(), + "url": urljoin(ANIMESCHEDULE_IMAGE_BASE, best["imageVersionRoute"]), + } + return fetch_animeschedule_cover_by_route(best["route"], fallback_title=best["title"]) + + +def anidb_titles_cache_due(): + if not ANIDB_TITLES_PATH.exists(): + return True + age = time.time() - ANIDB_TITLES_PATH.stat().st_mtime + return age >= 24 * 60 * 60 + + +def ensure_anidb_titles_dump(force=False): + if not force and not anidb_titles_cache_due(): + return ANIDB_TITLES_PATH + ANIDB_TITLES_PATH.parent.mkdir(parents=True, exist_ok=True) + debug_log("anidb.titles.request", url=ANIDB_TITLES_URL, force=force) + request = urllib.request.Request( + ANIDB_TITLES_URL, + headers={ + "Accept": "application/gzip, application/octet-stream;q=0.9, */*;q=0.5", + "User-Agent": AGENT, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = response.read() + debug_log( + "anidb.titles.response", + status=getattr(response, "status", "unknown"), + bytes=len(payload), + content_type=response.headers.get("Content-Type", ""), + ) + except urllib.error.URLError as exc: + debug_log("anidb.titles.error", error=exc) + if ANIDB_TITLES_PATH.exists(): + return ANIDB_TITLES_PATH + raise RuntimeError(f"Could not download AniDB titles dump: {exc}") from exc + except TimeoutError as exc: + if ANIDB_TITLES_PATH.exists(): + return ANIDB_TITLES_PATH + raise RuntimeError("AniDB titles dump download timed out") from exc + + if not payload: + if ANIDB_TITLES_PATH.exists(): + return ANIDB_TITLES_PATH + raise RuntimeError("AniDB titles dump was empty") + + tmp_path = ANIDB_TITLES_PATH.with_suffix(".xml.gz.tmp") + tmp_path.write_bytes(payload) + tmp_path.replace(ANIDB_TITLES_PATH) + return ANIDB_TITLES_PATH + + +def iter_anidb_titles(): + path = ensure_anidb_titles_dump() + mtime = path.stat().st_mtime if path.exists() else None + if ANIDB_TITLES_CACHE["entries"] is not None and ANIDB_TITLES_CACHE["mtime"] == mtime: + for entry in ANIDB_TITLES_CACHE["entries"]: + yield entry + return + + entries = [] + title_index = {} + token_index = {} + with gzip.open(path, "rb") as handle: + context = ET.iterparse(handle, events=("end",)) + for _, elem in context: + if elem.tag != "anime": + continue + aid = str(elem.attrib.get("aid") or "").strip() + if not aid: + elem.clear() + continue + for title_elem in elem.findall("title"): + title = str(title_elem.text or "").strip() + if not title: + continue + normalized = normalize_title_key(title) + entry = { + "aid": aid, + "title": title, + "type": str(title_elem.attrib.get("type") or "").strip().lower(), + "lang": str(title_elem.attrib.get("{http://www.w3.org/XML/1998/namespace}lang") or "").strip().lower(), + "normalized_title": normalized, + } + index = len(entries) + entries.append(entry) + if normalized: + title_index.setdefault(normalized, []).append(index) + for token in set(normalized.split()): + if len(token) >= 3: + token_index.setdefault(token, set()).add(index) + elem.clear() + ANIDB_TITLES_CACHE["mtime"] = mtime + ANIDB_TITLES_CACHE["entries"] = entries + ANIDB_TITLES_CACHE["title_index"] = title_index + ANIDB_TITLES_CACHE["token_index"] = {token: sorted(indices) for token, indices in token_index.items()} + ANIDB_TITLES_CACHE["match_cache"] = {} + for entry in entries: + yield entry + + +def get_cached_anidb_titles(): + path = ensure_anidb_titles_dump() + mtime = path.stat().st_mtime if path.exists() else None + if ANIDB_TITLES_CACHE["entries"] is None or ANIDB_TITLES_CACHE["mtime"] != mtime: + for _entry in iter_anidb_titles(): + pass + return ANIDB_TITLES_CACHE["entries"] or [] + + +def score_anidb_title_match(query_variants, candidate): + normalized = candidate.get("normalized_title") or normalize_title_key(candidate["title"]) + if not normalized: + return None + score = None + if normalized in query_variants: + score = 100 + else: + for variant in query_variants: + if variant and (normalized in variant or variant in normalized): + score = max(score or 0, 72 - abs(len(normalized) - len(variant))) + if score is None: + return None + + type_bonus = { + "official": 12, + "main": 10, + "short": 6, + "syn": 4, + }.get(candidate["type"], 0) + lang_bonus = { + "en": 5, + "x-jat": 4, + "ja": 2, + }.get(candidate["lang"], 0) + return score + type_bonus + lang_bonus + + +def resolve_anidb_aid(title): + query_variants = title_match_variants(title) + if not query_variants: + raise RuntimeError("Missing title for AniDB lookup") + + cache_key = tuple(sorted(query_variants)) + cached = ANIDB_TITLES_CACHE["match_cache"].get(cache_key) + if cached is not None: + if cached: + return dict(cached) + raise RuntimeError("AniDB title lookup did not find a matching anime") + + entries = get_cached_anidb_titles() + title_index = ANIDB_TITLES_CACHE.get("title_index") or {} + token_index = ANIDB_TITLES_CACHE.get("token_index") or {} + candidate_indices = set() + for variant in query_variants: + candidate_indices.update(title_index.get(variant, [])) + for token in variant.split(): + if len(token) >= 3: + candidate_indices.update(token_index.get(token, [])) + candidates = [entries[index] for index in sorted(candidate_indices)] if candidate_indices else entries + + def pick_best(pool, current_best=None): + best_match = current_best + for candidate in pool: + score = score_anidb_title_match(query_variants, candidate) + if score is None: + continue + if best_match is None or score > best_match["score"]: + best_match = { + "aid": candidate["aid"], + "title": candidate["title"], + "score": score, + } + return best_match + + best = pick_best(candidates) + candidate_count = len(candidates) + if best is None and candidate_indices: + best = pick_best(entries) + candidate_count = len(entries) + if not best: + debug_log("thumbnail.source.no_match", source="AniDB", title=title, candidates=candidate_count) + ANIDB_TITLES_CACHE["match_cache"][cache_key] = None + raise RuntimeError("AniDB title lookup did not find a matching anime") + debug_log( + "thumbnail.source.best_match", + source="AniDB", + title=title, + aid=best["aid"], + matched_title=best["title"], + score=best["score"], + candidates=candidate_count, + ) + ANIDB_TITLES_CACHE["match_cache"][cache_key] = dict(best) + return best + + +def fetch_anidb_cover(title): + match = resolve_anidb_aid(title) + return fetch_anidb_cover_by_aid(match["aid"], fallback_title=match["title"]) + + +def extract_anidb_alternative_titles_for_title(title, primary_title=""): + clean_title = str(title or "").strip() + if not clean_title: + return [] + try: + match = resolve_anidb_aid(clean_title) + except Exception as exc: + debug_log("anidb.alt_titles.resolve_failed", title=clean_title, error=exc) + return [] + try: + return extract_anidb_alternative_titles(match["aid"], primary_title=primary_title or clean_title) + except Exception as exc: + debug_log("anidb.alt_titles.extract_failed", title=clean_title, aid=match.get("aid"), error=exc) + return [] + + +def fetch_anidb_cover_by_aid(aid, fallback_title=""): + page_url = ANIDB_ANIME_PAGE.format(aid=aid) + debug_log("thumbnail.source.route_lookup", source="AniDB", aid=aid, fallback_title=fallback_title, page_url=page_url) + request = urllib.request.Request( + page_url, + headers={ + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Referer": "https://anidb.net/", + "User-Agent": AGENT, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=25) as response: + html = response.read().decode("utf-8", errors="replace") + debug_log( + "anidb.page.response", + aid=aid, + status=getattr(response, "status", "unknown"), + bytes=len(html.encode("utf-8")), + snippet=html[:200], + ) + except urllib.error.URLError as exc: + debug_log("anidb.page.error", aid=aid, error=exc) + raise RuntimeError(f"Could not reach AniDB anime page: {exc}") from exc + except TimeoutError as exc: + raise RuntimeError("AniDB anime page request timed out") from exc + + candidates = [ + r'((?:https?:)?//cdn-[^/"\']+\.anidb\.net/images/main/[^"\'\s>]+)', + r'((?:https?:)?//anidb\.net/images/main/[^"\'\s>]+)', + r'(["\'])(/images/main/[^"\']+)\1', + r'(["\'])(/[^"\']*images/main/[^"\']+)\1', + ] + image_url = None + for pattern in candidates: + match = re.search(pattern, html, re.IGNORECASE) + if not match: + continue + image_url = match.group(1) if match.lastindex == 1 else match.group(2) + break + + if not image_url: + debug_log("anidb.page.no_image", aid=aid) + raise RuntimeError("AniDB page did not expose a cover image") + if image_url.startswith("//"): + image_url = f"https:{image_url}" + elif image_url.startswith("/"): + image_url = urljoin(page_url, image_url) + debug_log("anidb.page.image", aid=aid, image_url=image_url) + return { + "aid": str(aid).strip(), + "title": str(fallback_title or "").strip(), + "url": image_url, + } + + +def resolve_search_thumbnail(title): + clean_title = str(title or "").strip() + if not clean_title: + raise ValueError("Missing title for thumbnail lookup") + cache_key = search_thumbnail_cache_key(clean_title) + THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True) + for existing in THUMBNAIL_ROOT.glob(f"{cache_key}.*"): + if existing.is_file(): + return existing + cover = None + last_error = None + for resolver in (fetch_animeschedule_cover, fetch_anidb_cover): + try: + cover = resolver(clean_title) + break + except Exception as exc: + last_error = exc + if not cover: + raise RuntimeError("Could not find a thumbnail for this search result") from last_error + cached_name = cache_thumbnail_image(cache_key, cover["url"]) + path = thumbnail_file_path(cached_name) + if not path or not path.exists(): + raise RuntimeError("Could not cache the search thumbnail") + return path + + +def cache_thumbnail_image(show_id, image_url): + THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True) + debug_log("thumbnail.download.request", show_id=show_id, image_url=image_url) + request = urllib.request.Request( + image_url, + headers={ + "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + "Referer": "https://anidb.net/", + "User-Agent": AGENT, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=25) as response: + content = response.read() + content_type = response.headers.get("Content-Type", "") + debug_log( + "thumbnail.download.response", + show_id=show_id, + status=getattr(response, "status", "unknown"), + bytes=len(content), + content_type=content_type, + final_url=getattr(response, "url", image_url), + ) + except urllib.error.URLError as exc: + debug_log("thumbnail.download.error", show_id=show_id, image_url=image_url, error=exc) + raise RuntimeError(f"Could not download cover image: {exc}") from exc + except TimeoutError as exc: + debug_log("thumbnail.download.timeout", show_id=show_id, image_url=image_url) + raise RuntimeError("Cover image download timed out") from exc + + if not content: + raise RuntimeError("Cover image response was empty") + + stem = sanitize_path_component(show_id, "anime") + extension = infer_image_extension(image_url, content_type) + final_path = THUMBNAIL_ROOT / f"{stem}{extension}" + tmp_path = final_path.with_suffix(final_path.suffix + ".tmp") + with tmp_path.open("wb") as handle: + handle.write(content) + tmp_path.replace(final_path) + + for existing in THUMBNAIL_ROOT.glob(f"{stem}.*"): + if existing != final_path and existing.is_file(): + existing.unlink(missing_ok=True) + debug_log("thumbnail.cache.write", show_id=show_id, path=final_path, bytes=len(content), content_type=content_type) + return final_path.name + + +def search_anime(query, mode): + runtime = ensure_runtime() + provider = str((runtime.get("config") or {}).get("provider") or "anikoto").strip().lower() + data = provider_bridge.search(query, provider=provider, page=1) + shows = data.get("results") or [] + results = [] + for show in shows: + title = str(show.get("title") or "").replace('\\"', '"').strip() + show_id = str(show.get("id") or "").strip() + if not show_id or not title: + continue + alternative_titles = extract_anidb_alternative_titles_for_title(title, primary_title=title) + index = len(results) + 1 + results.append( + { + "id": show_id, + "title": title, + "episodes": "", + "index": index, + "query": query, + "mode": mode, + "provider": show.get("provider") or provider, + "alternative_titles": alternative_titles, + } + ) + return results + + +def numeric_episode_key(value): + parts = re.findall(r"\d+|\D+", str(value)) + key = [] + for part in parts: + key.append((0, int(part)) if part.isdigit() else (1, part)) + return key + + +def fetch_show_episode_snapshot(show_id, timeout=25): + runtime = ensure_runtime() + provider = str((runtime.get("config") or {}).get("provider") or "anikoto").strip().lower() + info = provider_bridge.info(show_id, provider=provider) + rows = provider_bridge.episodes(show_id, provider=provider) + episodes = {choice: [] for choice in MODE_CHOICES} + for row in rows: + value = str(row.get("number") or "").strip() + if not value: + continue + langs = {str(lang or "").strip().lower() for lang in row.get("langs") or []} + if not langs: + langs.add("sub") + if "hsub" in langs: + langs.add("sub") + for choice in MODE_CHOICES: + if choice in langs and value not in episodes[choice]: + episodes[choice].append(value) + for choice in MODE_CHOICES: + episodes[choice] = sorted(episodes[choice], key=numeric_episode_key) + return { + "show_id": str(info.get("id") or show_id), + "title": str(info.get("title") or "").replace('\\"', '"').strip(), + "episodes": episodes, + } + + +def episode_list(show_id, mode): + normalized_mode = str(mode or "").strip().lower() + if normalized_mode not in MODE_CHOICES: + raise ValueError("Mode must be sub or dub") + return fetch_show_episode_snapshot(show_id)["episodes"][normalized_mode] + + +def format_episode_progress(available_count, expected_count): + available = parse_nonnegative_int(available_count) + expected = parse_nonnegative_int(expected_count) + if expected > 0: + return f"{available}/{expected}" + return str(available) + + +def mode_is_complete(available_count, expected_count, airing_status): + expected = parse_nonnegative_int(expected_count) + available = parse_nonnegative_int(available_count) + return expected > 0 and normalize_animeschedule_status(airing_status) == "Finished" and available >= expected + + +def watchlist_status_message(sub_count, dub_count, expected_count=0, airing_status=""): + parts = [ + f"Sub: {format_episode_progress(sub_count, expected_count)} available", + f"Dub: {format_episode_progress(dub_count, expected_count)} available", + ] + status = normalize_animeschedule_status(airing_status) + if status: + parts.append(f"AnimeSchedule: {status}") + if mode_is_complete(sub_count, expected_count, status): + parts.append("Sub ready") + if mode_is_complete(dub_count, expected_count, status): + parts.append("Dub ready") + return ". ".join(parts) + "." + + +def watchlist_download_library_name(item): + return sanitize_path_component( + item.get("auto_download_name") or item.get("title") or item.get("show_id") or "Anime", + sanitize_path_component(item.get("title") or item.get("show_id") or "Anime", "Anime"), + ) + + +def watchlist_source_library_dir(item, config): + media_type = normalize_media_type(item.get("media_type")) + return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item) + + +def watchlist_source_season_dir(item, config): + source_root = watchlist_source_library_dir(item, config) + if normalize_media_type(item.get("media_type")) == "movie": + return source_root + season = normalize_season((item or {}).get("auto_download_series") or "1") + return source_root / f"Season {int(season):02d}" + + +def jellyfin_target_library_dir(item, config): + media_type = normalize_media_type(item.get("media_type")) + key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir" + root = str((config or {}).get(key) or "").strip() + if not root: + return None + return Path(root).expanduser() / watchlist_download_library_name(item) + + +def watchlist_item_ready_for_jellyfin(item): + if not isinstance(item, dict): + return False, "invalid_item" + if not bool(item.get("downloaded")): + return False, "not_downloaded" + if normalize_watchlist_category(item.get("category")) != "finished": + return False, "category" + if normalize_media_type(item.get("media_type")) == "movie": + return True, "ready" + preferred_mode = watchlist_preferred_completion_mode(item) + return watchlist_item_download_complete(item, mode=preferred_mode) + + +def watchlist_preferred_completion_mode(item, fallback_mode=""): + fallback = str(fallback_mode or "").strip().lower() + if fallback in MODE_CHOICES: + return fallback + configured = str((item or {}).get("auto_download_mode") or "").strip().lower() + if configured in MODE_CHOICES: + return configured + downloaded_dub = len((item or {}).get("downloaded_dub_episodes") or []) + downloaded_sub = len((item or {}).get("downloaded_sub_episodes") or []) + if downloaded_dub and not downloaded_sub: + return "dub" + if downloaded_sub and not downloaded_dub: + return "sub" + return "dub" + + +def watchlist_item_download_complete(item, mode=""): + if not isinstance(item, dict): + return False, "invalid_item" + normalized_mode = watchlist_preferred_completion_mode(item, fallback_mode=mode) + expected_count = int(item.get("expected_count") or 0) + if expected_count < 1: + return False, "expected_count" + if normalize_animeschedule_status(item.get("airing_status")) != "Finished": + return False, "airing_status" + available_count = int(item.get(f"{normalized_mode}_count") or 0) + if available_count < expected_count: + return False, "episodes_unavailable" + downloaded_count = len(item.get(f"downloaded_{normalized_mode}_episodes") or []) + if downloaded_count < expected_count: + return False, "episodes_missing" + return True, "ready" + + +def normalize_downloaded_episode_value(value): + text = str(value or "").strip() + if not text: + return "" + match = re.match(r"^0*([0-9]+)(.*)$", text) + if not match: + return text + number = int(match.group(1) or "0", 10) + suffix = str(match.group(2) or "") + return f"{number}{suffix}" if number > 0 else text + + +def reverse_episode_offset(value, offset): + normalized_offset = normalize_episode_offset(offset) + text = str(value or "").strip() + if not normalized_offset or not text: + return normalize_downloaded_episode_value(text) + match = re.match(r"^0*([0-9]+)(.*)$", text) + if not match: + return normalize_downloaded_episode_value(text) + number = int(match.group(1) or "0", 10) + if number < 1: + return normalize_downloaded_episode_value(text) + logical_number = number - int(normalized_offset, 10) + 1 + if logical_number < 1: + return "" + suffix = str(match.group(2) or "") + return f"{logical_number}{suffix}" + + +def watchlist_downloaded_episode_values_from_filesystem(item, config, mode): + normalized_mode = str(mode or "").strip().lower() + if normalized_mode not in MODE_CHOICES: + return [] + season_dir = watchlist_source_season_dir(item, config) + if not season_dir.exists() or not season_dir.is_dir(): + return [] + season = normalize_season((item or {}).get("auto_download_series") or "1") + pattern = re.compile(rf"\bS{int(season):02d}E([0-9][0-9A-Za-z.\-]*)\b", re.IGNORECASE) + values = [] + seen = set() + offset = (item or {}).get("auto_download_offset") + for path in sorted(season_dir.rglob("*")): + if not path.is_file(): + continue + match = pattern.search(path.stem) + if not match: + continue + logical_value = reverse_episode_offset(match.group(1), offset) + normalized_value = normalize_downloaded_episode_value(logical_value) + if normalized_value and normalized_value not in seen: + seen.add(normalized_value) + values.append(normalized_value) + return values + + +def _move_tree_contents(source_dir, target_dir, progress_fn=None): + moved = [] + file_paths = [ + source_path + for source_path in sorted(source_dir.rglob("*"), key=lambda path: (len(path.parts), str(path))) + if source_path.is_file() + ] + total_files = len(file_paths) + for index, source_path in enumerate(file_paths, start=1): + relative = source_path.relative_to(source_dir) + destination = target_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + final_destination = app_support.unique_destination(destination) + if progress_fn is not None: + progress_fn( + { + "file_index": index, + "file_total": total_files, + "source": str(source_path), + "current_file": str(final_destination), + } + ) + shutil.move(str(source_path), str(final_destination)) + moved.append(str(final_destination)) + for path in sorted(source_dir.rglob("*"), key=lambda path: len(path.parts), reverse=True): + if path.is_dir(): + try: + path.rmdir() + except OSError: + pass + try: + source_dir.rmdir() + except OSError: + pass + return moved + + +def jellyfin_media_files(target_dir): + video_suffixes = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".m4v", ".ts"} + return [ + path + for path in target_dir.rglob("*") + if path.is_file() and path.suffix.lower() in video_suffixes + ] + + +def jellyfin_target_looks_complete(item, target_dir): + files = jellyfin_media_files(target_dir) + if normalize_media_type(item.get("media_type")) == "movie": + return bool(files) + expected_count = int(item.get("expected_count") or 0) + if expected_count < 1: + return False + return len(files) >= expected_count + + +def move_watchlist_item_to_jellyfin(item, config, progress_fn=None): + source_dir = watchlist_source_library_dir(item, config) + target_dir = jellyfin_target_library_dir(item, config) + if target_dir is None: + return {"moved": False, "reason": "target_missing", "item": item} + try: + if source_dir.resolve() == target_dir.resolve(): + return {"moved": False, "reason": "same_path", "item": item, "source": str(source_dir), "target": str(target_dir)} + except OSError: + pass + if not source_dir.exists(): + if target_dir.exists(): + reason = "already_moved" if jellyfin_target_looks_complete(item, target_dir) else "target_incomplete" + return {"moved": False, "reason": reason, "item": item, "source": str(source_dir), "target": str(target_dir)} + return {"moved": False, "reason": "source_missing", "item": item, "source": str(source_dir), "target": str(target_dir)} + target_dir.parent.mkdir(parents=True, exist_ok=True) + target_dir.mkdir(parents=True, exist_ok=True) + moved_files = _move_tree_contents(source_dir, target_dir, progress_fn=progress_fn) + return { + "moved": True, + "reason": "moved", + "item": item, + "source": str(source_dir), + "target": str(target_dir), + "moved_files": moved_files, + } + + +def notify_jellyfin_sync_result(item, result, config, automatic=False): + reason = str(result.get("reason") or "").strip() + if result.get("moved"): + event_name = "jellyfin_sync_success" + elif reason in {"already_moved", "same_path", "disabled", "not_downloaded", "category", "expected_count", "airing_status", "episodes_missing", "ready"}: + return {"sent": False, "reason": "skip"} + else: + event_name = "jellyfin_sync_failed" + payload = { + "show_id": item.get("show_id"), + "title": item.get("title"), + "media_type": normalize_media_type(item.get("media_type")), + "source": "automatic" if automatic else "manual", + "reason": reason or ("moved" if result.get("moved") else "unknown"), + "target": result.get("target"), + "source_path": result.get("source"), + "moved_files": result.get("moved_files") if isinstance(result.get("moved_files"), list) else [], + "error": result.get("error"), + } + send_discord_webhook_event(config or get_config_snapshot(), event_name, payload) + return {"sent": True, "event": event_name} + + +def episode_specs_for_values(values, available_episodes): + ordered = [str(value).strip() for value in available_episodes or [] if str(value).strip()] + targets = {str(value).strip() for value in values or [] if str(value).strip()} + if not ordered or not targets: + return [] + indices = [index for index, value in enumerate(ordered) if value in targets] + if not indices: + return [] + ranges = [] + start = indices[0] + end = indices[0] + for index in indices[1:]: + if index == end + 1: + end = index + continue + ranges.append((start, end)) + start = end = index + ranges.append((start, end)) + specs = [] + for start, end in ranges: + specs.append(ordered[start] if start == end else f"{ordered[start]}-{ordered[end]}") + return specs + + +def encode_episode_values(values): + normalized = [] + seen = set() + for value in values or []: + text = str(value or "").strip() + if text and text not in seen: + seen.add(text) + normalized.append(text) + return json.dumps(normalized, ensure_ascii=True) + + +def decode_episode_values(payload): + if not payload: + return [] + try: + raw = json.loads(payload) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + normalized = [] + seen = set() + for value in raw if isinstance(raw, list) else []: + text = str(value or "").strip() + if text and text not in seen: + seen.add(text) + normalized.append(text) + return normalized + + +def expand_episode_spec_against_available(episode_spec, available_episodes): + specs = [part for part in re.split(r"\s+", str(episode_spec or "").strip()) if part] + values = [str(value).strip() for value in available_episodes or [] if str(value).strip()] + if not specs or not values: + return [] + index_map = {value: index for index, value in enumerate(values)} + expanded = [] + seen = set() + for spec in specs: + if "-" in spec: + start, end = [piece.strip() for piece in spec.split("-", 1)] + if start in index_map and end in index_map: + start_index = index_map[start] + end_index = index_map[end] + if start_index > end_index: + start_index, end_index = end_index, start_index + for value in values[start_index : end_index + 1]: + if value not in seen: + seen.add(value) + expanded.append(value) + continue + if spec in index_map and spec not in seen: + seen.add(spec) + expanded.append(spec) + return expanded + + +class WatchlistStore: + def __init__(self, config_getter=None, start_worker=True): + self.lock = threading.RLock() + self.config_getter = config_getter or (lambda: dict(DEFAULT_CONFIG)) + self.auto_download_fn = None + self.jellyfin_sync_fn = None + self.thumbnail_events = {} + self.refresh_pending = [] + self.refresh_pending_set = set() + self.refresh_inflight_set = set() + self.refresh_wakeup = threading.Event() + self.refresh_stop_event = threading.Event() + self.refresh_worker = None + self.worker_enabled = bool(start_worker) + self._init_db() + self._migrate_legacy_json() + self._restore_queued_refreshes() + if start_worker: + self.ensure_worker() + + def ensure_worker(self): + if not self.worker_enabled: + return + with self.lock: + if self.refresh_worker is not None and self.refresh_worker.is_alive(): + return + self.refresh_stop_event.clear() + self.refresh_worker = threading.Thread(target=self._refresh_loop, daemon=True) + self.refresh_worker.start() + + def shutdown(self, wait=False): + self.refresh_stop_event.set() + self.refresh_wakeup.set() + worker = self.refresh_worker + if wait and worker is not None and worker.is_alive(): + worker.join(timeout=WORKER_SHUTDOWN_TIMEOUT_SECONDS) + return worker is None or not worker.is_alive() + + def _refresh_request_timeout(self): + return WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS + + def _next_pending_refresh(self): + with self.lock: + if not self.refresh_pending: + return None, None + show_id, source = self.refresh_pending.pop(0) + self.refresh_pending_set.discard(show_id) + self.refresh_inflight_set.add(show_id) + return show_id, source + + def _enqueue_refresh_locked(self, show_id, source="manual"): + normalized_show_id = str(show_id or "").strip() + if ( + not normalized_show_id + or normalized_show_id in self.refresh_pending_set + or normalized_show_id in self.refresh_inflight_set + ): + return False + self.refresh_pending.append((normalized_show_id, str(source or "manual").strip().lower() or "manual")) + self.refresh_pending_set.add(normalized_show_id) + return True + + def _drop_refresh_locked(self, show_id): + normalized_show_id = str(show_id or "").strip() + if not normalized_show_id: + return False + removed = False + if normalized_show_id in self.refresh_pending_set: + self.refresh_pending = [ + value for value in self.refresh_pending if str((value[0] if isinstance(value, tuple) else value) or "").strip() != normalized_show_id + ] + self.refresh_pending_set.discard(normalized_show_id) + removed = True + if normalized_show_id in self.refresh_inflight_set: + self.refresh_inflight_set.discard(normalized_show_id) + removed = True + return removed + + def _restore_queued_refreshes(self): + with self.lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT show_id + FROM watchlist + WHERE status = 'queued' + ORDER BY updated_at ASC, created_at ASC, title COLLATE NOCASE ASC + """ + ).fetchall() + for row in rows: + self._enqueue_refresh_locked(str(row["show_id"]).strip(), source="manual") + if self.refresh_pending: + self.refresh_wakeup.set() + + def _refresh_loop(self): + while not self.refresh_stop_event.is_set(): + show_id, refresh_source = self._next_pending_refresh() + if not show_id: + self.refresh_wakeup.wait(2) + self.refresh_wakeup.clear() + continue + try: + self.refresh(show_id, refresh_source=refresh_source or "manual") + except Exception as exc: + debug_log("watchlist.refresh_worker.error", show_id=show_id, error=exc) + finally: + with self.lock: + self.refresh_inflight_set.discard(show_id) + + def schedule_refresh(self, show_id, source="manual"): + normalized_show_id = str(show_id or "").strip() + if not normalized_show_id: + raise ValueError("Missing show_id.") + self.ensure_worker() + with self.lock: + self._enqueue_refresh_locked(normalized_show_id, source=source) + self.refresh_wakeup.set() + return self.get(normalized_show_id) + + def _auto_download_defaults(self, title): + config = self.config_getter() or {} + normalized_title = str(title or "").strip() or "Anime" + return { + "auto_download_mode": str(config.get("auto_download_mode") or "dub").strip().lower() or "dub", + "auto_download_quality": str(config.get("auto_download_quality") or "best").strip().lower() or "best", + "auto_download_name": sanitize_path_component(normalized_title, "Anime"), + "auto_download_source_name": normalized_title, + "auto_download_series": "1", + "auto_download_offset": None, + "media_type": "tv", + } + + def _connect(self): + conn = sqlite3.connect(STATE_DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + return conn + + def _table_columns_conn(self, conn, table_name): + return [str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall()] + + def _exists_conn(self, conn, show_id): + row = conn.execute( + "SELECT 1 FROM watchlist WHERE show_id = ? LIMIT 1", + (str(show_id or "").strip(),), + ).fetchone() + return bool(row) + + def has_show(self, show_id): + normalized_show_id = str(show_id or "").strip() + if not normalized_show_id: + return False + with self.lock, self._connect() as conn: + return self._exists_conn(conn, normalized_show_id) + + def _find_download_title_match_conn(self, conn, title, exclude_show_id=""): + normalized_title_key = normalize_identity_title_key(title) + excluded_show_id = str(exclude_show_id or "").strip() + if not normalized_title_key: + return None + rows = conn.execute( + """ + SELECT * + FROM watchlist + ORDER BY updated_at DESC, created_at DESC, title COLLATE NOCASE ASC + """ + ).fetchall() + matches = [] + for row in rows: + row_show_id = str(row["show_id"] or "").strip() + if not row_show_id or row_show_id == excluded_show_id: + continue + if normalize_identity_title_key(row["title"]) == normalized_title_key: + matches.append(row) + if len(matches) > 1: + return None + return matches[0] if matches else None + + def _init_db(self): + STATE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS watchlist ( + show_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'watching', + downloaded INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + status_message TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_checked TEXT, + sub_count INTEGER NOT NULL DEFAULT 0, + dub_count INTEGER NOT NULL DEFAULT 0, + expected_count INTEGER NOT NULL DEFAULT 0, + airing_status TEXT, + sub_latest_episode TEXT, + dub_latest_episode TEXT, + animeschedule_route TEXT, + animeschedule_title TEXT, + anilist_id INTEGER, + anilist_title TEXT, + anilist_site_url TEXT, + alternative_titles_json TEXT, + anidb_aid TEXT, + anidb_title TEXT, + media_type TEXT NOT NULL DEFAULT 'tv', + auto_download_mode TEXT, + auto_download_quality TEXT, + auto_download_name TEXT, + auto_download_source_name TEXT, + auto_download_series TEXT, + auto_download_offset TEXT, + downloaded_sub_episodes_json TEXT, + downloaded_dub_episodes_json TEXT, + thumbnail_path TEXT, + thumbnail_checked_at TEXT + ) + """ + ) + columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()} + if "category" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN category TEXT NOT NULL DEFAULT 'watching'") + if "downloaded" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded INTEGER NOT NULL DEFAULT 0") + if "thumbnail_path" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN thumbnail_path TEXT") + if "thumbnail_checked_at" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN thumbnail_checked_at TEXT") + if "expected_count" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN expected_count INTEGER NOT NULL DEFAULT 0") + if "airing_status" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN airing_status TEXT") + if "anidb_aid" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_aid TEXT") + if "anidb_title" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_title TEXT") + if "media_type" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN media_type TEXT NOT NULL DEFAULT 'tv'") + if "animeschedule_route" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT") + if "animeschedule_title" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_title TEXT") + if "anilist_id" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_id INTEGER") + if "anilist_title" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_title TEXT") + if "anilist_site_url" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_site_url TEXT") + if "alternative_titles_json" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN alternative_titles_json TEXT") + if "auto_download_mode" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_mode TEXT") + if "auto_download_quality" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_quality TEXT") + if "auto_download_name" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_name TEXT") + if "auto_download_source_name" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_source_name TEXT") + if "auto_download_series" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_series TEXT") + if "auto_download_offset" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_offset TEXT") + if "downloaded_sub_episodes_json" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_sub_episodes_json TEXT") + if "downloaded_dub_episodes_json" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_dub_episodes_json TEXT") + conn.execute( + "UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''" + ) + conn.execute( + "UPDATE watchlist SET media_type = 'tv' WHERE media_type IS NULL OR TRIM(media_type) = ''" + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_title ON watchlist(title)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_category ON watchlist(category)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_updated_at ON watchlist(updated_at DESC)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS watchlist_removals ( + show_id TEXT PRIMARY KEY, + title TEXT, + removed_at TEXT NOT NULL + ) + """ + ) + + def _remember_removal_conn(self, conn, show_id, title): + conn.execute( + """ + INSERT INTO watchlist_removals (show_id, title, removed_at) + VALUES (?, ?, ?) + ON CONFLICT(show_id) DO UPDATE SET + title = excluded.title, + removed_at = excluded.removed_at + """, + (str(show_id or "").strip(), str(title or "").strip(), now_iso()), + ) + + def _clear_removal_conn(self, conn, show_id): + conn.execute("DELETE FROM watchlist_removals WHERE show_id = ?", (str(show_id or "").strip(),)) + + def _was_removed_conn(self, conn, show_id): + row = conn.execute( + "SELECT 1 FROM watchlist_removals WHERE show_id = ? LIMIT 1", + (str(show_id or "").strip(),), + ).fetchone() + return row is not None + + def _row_to_item(self, row): + item = dict(row) + defaults = self._auto_download_defaults(item.get("title")) + item["category"] = normalize_watchlist_category(item.get("category")) + item["category_label"] = watchlist_category_label(item["category"]) + item["media_type"] = normalize_media_type(item.get("media_type")) + item["media_type_label"] = media_type_label(item["media_type"]) + item["downloaded"] = bool(int(item.get("downloaded") or 0)) + item["sub_count"] = int(item.get("sub_count") or 0) + item["dub_count"] = int(item.get("dub_count") or 0) + item["expected_count"] = int(item.get("expected_count") or 0) + item["airing_status"] = normalize_animeschedule_status(item.get("airing_status")) + item["total_count"] = item["sub_count"] + item["dub_count"] + item["sub_progress"] = format_episode_progress(item["sub_count"], item["expected_count"]) + item["dub_progress"] = format_episode_progress(item["dub_count"], item["expected_count"]) + item["sub_complete"] = mode_is_complete(item["sub_count"], item["expected_count"], item["airing_status"]) + item["dub_complete"] = mode_is_complete(item["dub_count"], item["expected_count"], item["airing_status"]) + item["finished_badge"] = ( + "downloaded" if item["category"] == "finished" and item["downloaded"] else "manual" if item["category"] == "finished" else "" + ) + item["source_url"] = anime_source_url(item.get("show_id"), item.get("title")) + thumb_path = thumbnail_file_path(item.get("thumbnail_path")) + item["thumbnail_ready"] = bool(thumb_path and thumb_path.exists()) + item["thumbnail_url"] = cover_route(item.get("show_id")) + item["alternative_titles"] = decode_alternative_titles(item.get("alternative_titles_json")) + item["has_alternative_titles"] = bool(item["alternative_titles"]) + mode = str(item.get("auto_download_mode") or defaults["auto_download_mode"]).strip().lower() + quality = str(item.get("auto_download_quality") or defaults["auto_download_quality"]).strip().lower() + item["auto_download_mode"] = mode if mode in MODE_CHOICES else defaults["auto_download_mode"] + item["auto_download_quality"] = quality if quality in QUALITY_CHOICES else defaults["auto_download_quality"] + item["auto_download_name"] = sanitize_path_component(item.get("auto_download_name") or defaults["auto_download_name"], defaults["auto_download_name"]) + item["auto_download_source_name"] = str(item.get("auto_download_source_name") or defaults["auto_download_source_name"]).strip() or defaults["auto_download_source_name"] + item["auto_download_series"] = normalize_season(item.get("auto_download_series") or defaults["auto_download_series"]) + item["auto_download_offset"] = normalize_episode_offset(item.get("auto_download_offset")) + item["downloaded_sub_episodes"] = decode_episode_values(item.get("downloaded_sub_episodes_json")) + item["downloaded_dub_episodes"] = decode_episode_values(item.get("downloaded_dub_episodes_json")) + if item.get("animeschedule_route"): + title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title" + item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}" + elif item.get("anidb_aid"): + title = str(item.get("anidb_title") or item.get("title") or "").strip() or "unknown title" + item["thumbnail_debug"] = f"AniDB: {item['anidb_aid']} - {title}" + elif item["thumbnail_ready"]: + item["thumbnail_debug"] = "Thumbnail source: manual upload" + else: + item["thumbnail_debug"] = "Thumbnail source: unresolved" + return item + + def _upsert_conn(self, conn, item): + conn.execute( + """ + INSERT INTO watchlist ( + show_id, title, category, downloaded, status, status_message, created_at, updated_at, + last_checked, sub_count, dub_count, expected_count, airing_status, + sub_latest_episode, dub_latest_episode, + animeschedule_route, animeschedule_title, + anilist_id, anilist_title, anilist_site_url, alternative_titles_json, + anidb_aid, anidb_title, media_type, + auto_download_mode, auto_download_quality, auto_download_name, auto_download_source_name, auto_download_series, auto_download_offset, + downloaded_sub_episodes_json, downloaded_dub_episodes_json, + thumbnail_path, thumbnail_checked_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(show_id) DO UPDATE SET + title = excluded.title, + category = excluded.category, + downloaded = excluded.downloaded, + status = excluded.status, + status_message = excluded.status_message, + updated_at = excluded.updated_at, + last_checked = excluded.last_checked, + sub_count = excluded.sub_count, + dub_count = excluded.dub_count, + expected_count = excluded.expected_count, + airing_status = excluded.airing_status, + sub_latest_episode = excluded.sub_latest_episode, + dub_latest_episode = excluded.dub_latest_episode, + animeschedule_route = excluded.animeschedule_route, + animeschedule_title = excluded.animeschedule_title, + anilist_id = excluded.anilist_id, + anilist_title = excluded.anilist_title, + anilist_site_url = excluded.anilist_site_url, + alternative_titles_json = excluded.alternative_titles_json, + anidb_aid = excluded.anidb_aid, + anidb_title = excluded.anidb_title, + media_type = excluded.media_type, + auto_download_mode = excluded.auto_download_mode, + auto_download_quality = excluded.auto_download_quality, + auto_download_name = excluded.auto_download_name, + auto_download_source_name = excluded.auto_download_source_name, + auto_download_series = excluded.auto_download_series, + auto_download_offset = excluded.auto_download_offset, + downloaded_sub_episodes_json = excluded.downloaded_sub_episodes_json, + downloaded_dub_episodes_json = excluded.downloaded_dub_episodes_json, + thumbnail_path = excluded.thumbnail_path, + thumbnail_checked_at = excluded.thumbnail_checked_at + """, + ( + item["show_id"], + item["title"], + normalize_watchlist_category(item.get("category")), + 1 if item.get("downloaded") else 0, + item["status"], + item["status_message"], + item["created_at"], + item["updated_at"], + item["last_checked"], + int(item.get("sub_count") or 0), + int(item.get("dub_count") or 0), + int(item.get("expected_count") or 0), + item.get("airing_status"), + item.get("sub_latest_episode"), + item.get("dub_latest_episode"), + item.get("animeschedule_route"), + item.get("animeschedule_title"), + item.get("anilist_id"), + item.get("anilist_title"), + item.get("anilist_site_url"), + item.get("alternative_titles_json"), + item.get("anidb_aid"), + item.get("anidb_title"), + normalize_media_type(item.get("media_type")), + item.get("auto_download_mode"), + item.get("auto_download_quality"), + item.get("auto_download_name"), + str(item.get("auto_download_source_name") or "").strip() or str(item["title"]).strip(), + item.get("auto_download_series"), + normalize_episode_offset(item.get("auto_download_offset")), + item.get("downloaded_sub_episodes_json"), + item.get("downloaded_dub_episodes_json"), + item.get("thumbnail_path"), + item.get("thumbnail_checked_at"), + ), + ) + + def _migrate_legacy_json(self): + if not WATCHLIST_JSON_PATH.exists(): + return + legacy = load_json(WATCHLIST_JSON_PATH, {}) + if not legacy: + WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated")) + return + + aggregated = {} + for value in legacy.values(): + if not isinstance(value, dict): + continue + show_id = str(value.get("show_id") or "").strip() + if not show_id: + continue + mode = str(value.get("mode") or "").strip().lower() + if mode not in MODE_CHOICES: + continue + item = aggregated.setdefault( + show_id, + { + "show_id": show_id, + "title": str(value.get("title") or "Unknown Anime").strip() or "Unknown Anime", + "category": "watching", + "downloaded": False, + "status": str(value.get("status") or "tracked"), + "status_message": str(value.get("status_message") or "Migrated from legacy watchlist."), + "created_at": str(value.get("added_at") or now_iso()), + "updated_at": now_iso(), + "last_checked": value.get("last_checked"), + "sub_count": 0, + "dub_count": 0, + "expected_count": 0, + "airing_status": None, + "sub_latest_episode": None, + "dub_latest_episode": None, + "animeschedule_route": None, + "animeschedule_title": None, + "anilist_id": None, + "anilist_title": None, + "anilist_site_url": None, + "alternative_titles_json": None, + "anidb_aid": None, + "anidb_title": None, + "media_type": "tv", + "auto_download_mode": None, + "auto_download_quality": None, + "auto_download_name": None, + "auto_download_source_name": None, + "auto_download_series": None, + "auto_download_offset": None, + "downloaded_sub_episodes_json": None, + "downloaded_dub_episodes_json": None, + "thumbnail_path": None, + "thumbnail_checked_at": None, + }, + ) + episodes = value.get("episodes_available") if isinstance(value.get("episodes_available"), list) else [] + count = len(episodes) + latest = episodes[-1] if episodes else None + if mode == "sub": + item["sub_count"] = max(item["sub_count"], count) + item["sub_latest_episode"] = latest or item["sub_latest_episode"] + else: + item["dub_count"] = max(item["dub_count"], count) + item["dub_latest_episode"] = latest or item["dub_latest_episode"] + if item["title"] == "Unknown Anime" and str(value.get("title") or "").strip(): + item["title"] = str(value.get("title")).strip() + + with self.lock, self._connect() as conn: + for item in aggregated.values(): + self._upsert_conn(conn, item) + WATCHLIST_JSON_PATH.rename(WATCHLIST_JSON_PATH.with_suffix(".json.migrated")) + + def list(self, page=1, per_page=30, category=None): + page = max(1, int(page or 1)) + per_page = min(60, max(1, int(per_page or 30))) + active_category = normalize_watchlist_category(category) if category else None + with self.lock, self._connect() as conn: + overall_total = conn.execute("SELECT COUNT(*) AS count FROM watchlist").fetchone()["count"] + if active_category: + total = conn.execute( + "SELECT COUNT(*) AS count FROM watchlist WHERE category = ?", + (active_category,), + ).fetchone()["count"] + else: + total = conn.execute("SELECT COUNT(*) AS count FROM watchlist").fetchone()["count"] + totals = conn.execute( + """ + SELECT + COALESCE(SUM(sub_count), 0) AS sub_total, + COALESCE(SUM(dub_count), 0) AS dub_total + FROM watchlist + """ + ).fetchone() + category_rows = conn.execute( + "SELECT category, COUNT(*) AS count FROM watchlist GROUP BY category" + ).fetchall() + queued_total = conn.execute("SELECT COUNT(*) AS count FROM watchlist WHERE status = 'queued'").fetchone()["count"] + pages = max(1, (total + per_page - 1) // per_page) + page = min(page, pages) + offset = (page - 1) * per_page + if active_category: + rows = conn.execute( + "SELECT * FROM watchlist WHERE category = ? ORDER BY title COLLATE NOCASE ASC LIMIT ? OFFSET ?", + (active_category, per_page, offset), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + items = [self._row_to_item(row) for row in rows] + category_counts = {key: 0 for key in WATCHLIST_CATEGORY_CHOICES} + for row in category_rows: + category_counts[normalize_watchlist_category(row["category"])] = int(row["count"] or 0) + return { + "items": items, + "total": total, + "page": page, + "pages": pages, + "per_page": per_page, + "active_category": active_category, + "active_category_label": watchlist_category_label(active_category) if active_category else "", + "categories": category_counts, + "queued_count": int(queued_total or 0), + "summary": { + "shows": int(overall_total or 0), + "sub": int(totals["sub_total"] or 0), + "dub": int(totals["dub_total"] or 0), + }, + } + + def homepage_summary(self): + with self.lock, self._connect() as conn: + rows = conn.execute( + "SELECT category, COUNT(*) AS count FROM watchlist GROUP BY category" + ).fetchall() + counts = {key: 0 for key in WATCHLIST_CATEGORY_CHOICES} + for row in rows: + counts[normalize_watchlist_category(row["category"])] = int(row["count"] or 0) + total = sum(counts.values()) + return { + "watchlist": counts["watching"], + "planned": counts["planned"], + "finished": counts["finished"], + "dropped": counts["dropped"], + "total": total, + } + + def get(self, show_id): + with self.lock, self._connect() as conn: + row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (str(show_id).strip(),)).fetchone() + if not row: + raise KeyError("Anime not found in watchlist.") + return self._row_to_item(row) + + def all_show_ids(self, categories=None): + normalized_categories = [] + for category in categories or []: + normalized = normalize_watchlist_category(category) + if normalized not in normalized_categories: + normalized_categories.append(normalized) + with self.lock, self._connect() as conn: + if normalized_categories: + placeholders = ", ".join("?" for _ in normalized_categories) + rows = conn.execute( + f"SELECT show_id FROM watchlist WHERE category IN ({placeholders}) ORDER BY title COLLATE NOCASE ASC", + tuple(normalized_categories), + ).fetchall() + else: + rows = conn.execute("SELECT show_id FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall() + return [str(row["show_id"]).strip() for row in rows if str(row["show_id"]).strip()] + + def add(self, payload): + show_id = str(payload.get("show_id") or "").strip() + title = str(payload.get("title") or "Unknown Anime").strip() or "Unknown Anime" + if not show_id: + raise ValueError("Missing show_id.") + + with self.lock, self._connect() as conn: + row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (show_id,)).fetchone() + if row: + item = self._row_to_item(row) + return {"message": f"Already tracking {item['title']}.", "item": item, "created": False} + + now = now_iso() + item = { + **self._auto_download_defaults(title), + "show_id": show_id, + "title": title, + "category": normalize_watchlist_category(payload.get("category")), + "downloaded": False, + "status": "queued", + "status_message": "Queued initial watchlist refresh.", + "created_at": now, + "updated_at": now, + "last_checked": None, + "sub_count": 0, + "dub_count": 0, + "expected_count": 0, + "airing_status": None, + "sub_latest_episode": None, + "dub_latest_episode": None, + "animeschedule_route": None, + "animeschedule_title": None, + "anilist_id": None, + "anilist_title": None, + "anilist_site_url": None, + "alternative_titles_json": None, + "anidb_aid": None, + "anidb_title": None, + "media_type": normalize_media_type(payload.get("media_type")), + "thumbnail_path": None, + "thumbnail_checked_at": None, + "downloaded_sub_episodes_json": None, + "downloaded_dub_episodes_json": None, + } + item["auto_download_series"] = normalize_season(payload.get("auto_download_series") or item.get("auto_download_series") or "1") + item["auto_download_offset"] = normalize_episode_offset(payload.get("auto_download_offset")) + item["auto_download_source_name"] = ( + str(payload.get("auto_download_source_name") or item.get("auto_download_source_name") or title).strip() or title + ) + self._upsert_conn(conn, item) + self._clear_removal_conn(conn, show_id) + + queued = self.schedule_refresh(show_id, source="initial") + return {"message": f"Added {queued['title']} to the watchlist. Refresh queued.", "item": queued, "created": True} + + def refresh(self, show_id, include_thumbnail=True, refresh_source="manual"): + existing = self.get(show_id) + normalized_show_id = existing["show_id"] + previous_sub_count = int(existing.get("sub_count") or 0) + previous_dub_count = int(existing.get("dub_count") or 0) + previous_last_checked = existing.get("last_checked") + now = now_iso() + request_timeout = self._refresh_request_timeout() + try: + snapshot = fetch_show_episode_snapshot(normalized_show_id, timeout=request_timeout) + if self.refresh_stop_event.is_set(): + return None + if not self.has_show(normalized_show_id): + return None + sub_episodes = snapshot["episodes"]["sub"] + dub_episodes = snapshot["episodes"]["dub"] + schedule = None + try: + schedule = resolve_animeschedule_anime( + snapshot["title"] or existing["title"], + route=existing.get("animeschedule_route"), + timeout=request_timeout, + ) + except Exception: + schedule = None + anidb_match = None + try: + if existing.get("anidb_aid"): + anidb_match = { + "aid": str(existing.get("anidb_aid")).strip(), + "title": str(existing.get("anidb_title") or snapshot["title"] or existing["title"]).strip(), + } + else: + anidb_match = resolve_anidb_aid(snapshot["title"] or existing["title"]) + except Exception: + anidb_match = None + if self.refresh_stop_event.is_set(): + return None + if not self.has_show(normalized_show_id): + return None + expected_count = parse_nonnegative_int((schedule or {}).get("episodes")) or int(existing.get("expected_count") or 0) + airing_status = normalize_animeschedule_status((schedule or {}).get("status") or existing.get("airing_status")) + alternative_titles = extract_anidb_alternative_titles( + (anidb_match or {}).get("aid"), + primary_title=snapshot["title"] or existing["title"], + ) + payload = ( + snapshot["title"] or existing["title"], + "updated", + watchlist_status_message( + len(sub_episodes), + len(dub_episodes), + expected_count=expected_count, + airing_status=airing_status, + ), + now, + now, + len(sub_episodes), + len(dub_episodes), + expected_count, + airing_status, + sub_episodes[-1] if sub_episodes else None, + dub_episodes[-1] if dub_episodes else None, + (schedule or {}).get("route") or existing.get("animeschedule_route"), + (schedule or {}).get("title") or existing.get("animeschedule_title"), + existing.get("anilist_id"), + existing.get("anilist_title"), + existing.get("anilist_site_url"), + encode_alternative_titles(alternative_titles) if anidb_match is not None else existing.get("alternative_titles_json"), + (anidb_match or {}).get("aid") or existing.get("anidb_aid"), + (anidb_match or {}).get("title") or existing.get("anidb_title"), + normalized_show_id, + ) + except Exception as exc: + if self.refresh_stop_event.is_set(): + return None + if not self.has_show(normalized_show_id): + return None + payload = ( + existing["title"], + "error", + f"Could not refresh episodes: {exc}", + now, + now, + existing["sub_count"], + existing["dub_count"], + existing.get("expected_count"), + existing.get("airing_status"), + existing.get("sub_latest_episode"), + existing.get("dub_latest_episode"), + existing.get("animeschedule_route"), + existing.get("animeschedule_title"), + existing.get("anilist_id"), + existing.get("anilist_title"), + existing.get("anilist_site_url"), + existing.get("alternative_titles_json"), + existing.get("anidb_aid"), + existing.get("anidb_title"), + normalized_show_id, + ) + + with self.lock, self._connect() as conn: + if self.refresh_stop_event.is_set(): + return None + cursor = conn.execute( + """ + UPDATE watchlist + SET + title = ?, + status = ?, + status_message = ?, + updated_at = ?, + last_checked = ?, + sub_count = ?, + dub_count = ?, + expected_count = ?, + airing_status = ?, + sub_latest_episode = ?, + dub_latest_episode = ?, + animeschedule_route = ?, + animeschedule_title = ?, + anilist_id = ?, + anilist_title = ?, + anilist_site_url = ?, + alternative_titles_json = ?, + anidb_aid = ?, + anidb_title = ? + WHERE show_id = ? + """, + payload, + ) + if cursor.rowcount < 1: + return None + if self.refresh_stop_event.is_set(): + return None + if include_thumbnail and self.has_show(normalized_show_id): + try: + self.ensure_thumbnail(normalized_show_id) + except Exception: + pass + item = self.get(normalized_show_id) + item["previous_sub_count"] = previous_sub_count + item["previous_dub_count"] = previous_dub_count + item["previous_last_checked"] = previous_last_checked + item["sub_delta"] = max(0, int(item.get("sub_count") or 0) - previous_sub_count) + item["dub_delta"] = max(0, int(item.get("dub_count") or 0) - previous_dub_count) + item["had_new_episodes"] = bool(item["sub_delta"] or item["dub_delta"]) + item["sub_episode_values"] = sub_episodes if 'sub_episodes' in locals() else [] + item["dub_episode_values"] = dub_episodes if 'dub_episodes' in locals() else [] + if self.auto_download_fn is not None: + try: + item["auto_download_result"] = self.auto_download_fn(item, refresh_source=refresh_source) + except Exception as exc: + debug_log("watchlist.auto_download.error", show_id=normalized_show_id, source=refresh_source, error=exc) + if self.jellyfin_sync_fn is not None: + try: + item["jellyfin_sync_result"] = self.jellyfin_sync_fn(item, automatic=True) + except Exception as exc: + debug_log("watchlist.jellyfin_sync.error", show_id=normalized_show_id, source=refresh_source, error=exc) + return item + + def refresh_all(self): + refreshed = [] + for show_id in self.all_show_ids(categories=("watching", "planned")): + item = self.refresh(show_id, include_thumbnail=False) + if item is not None: + refreshed.append(item) + return {"count": len(refreshed), "items": refreshed, "message": f"Refreshed {len(refreshed)} watchlist entries."} + + def update_category(self, show_id, category): + existing = self.get(show_id) + now = now_iso() + with self.lock, self._connect() as conn: + conn.execute( + "UPDATE watchlist SET category = ?, updated_at = ? WHERE show_id = ?", + (normalize_watchlist_category(category), now, existing["show_id"]), + ) + return self.get(show_id) + + def update_media_type(self, show_id, media_type): + existing = self.get(show_id) + normalized_media_type = normalize_media_type(media_type) + with self.lock, self._connect() as conn: + conn.execute( + "UPDATE watchlist SET media_type = ?, updated_at = ? WHERE show_id = ?", + (normalized_media_type, now_iso(), existing["show_id"]), + ) + return self.get(show_id) + + def queue_refresh(self, show_id, status_message="Queued watchlist refresh.", source="manual"): + existing = self.get(show_id) + now = now_iso() + with self.lock, self._connect() as conn: + conn.execute( + "UPDATE watchlist SET status = 'queued', status_message = ?, updated_at = ? WHERE show_id = ?", + (str(status_message or "Queued watchlist refresh.").strip(), now, existing["show_id"]), + ) + return self.schedule_refresh(existing["show_id"], source=source) + + def reconcile_downloaded_filesystem(self, config=None): + active_config = normalize_config(config or self.config_getter() or {}) + items = [] + changed = 0 + with self.lock, self._connect() as conn: + rows = conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall() + for row in rows: + existing_item = self._row_to_item(row) + media_type = normalize_media_type(existing_item.get("media_type")) + if media_type == "movie": + source_dir = watchlist_source_library_dir(existing_item, active_config) + has_files = source_dir.exists() and any(path.is_file() for path in source_dir.rglob("*")) + new_sub_values = [] + new_dub_values = [] + downloaded = has_files + else: + filesystem_values = watchlist_downloaded_episode_values_from_filesystem(existing_item, active_config, "dub") + if filesystem_values: + preferred_mode = watchlist_preferred_completion_mode(existing_item) + new_sub_values = list(existing_item.get("downloaded_sub_episodes") or []) + new_dub_values = list(existing_item.get("downloaded_dub_episodes") or []) + if preferred_mode == "sub": + new_sub_values = filesystem_values + else: + new_dub_values = filesystem_values + downloaded = bool(new_sub_values or new_dub_values) + else: + new_sub_values = [] + new_dub_values = [] + downloaded = False + old_sub_values = existing_item.get("downloaded_sub_episodes") or [] + old_dub_values = existing_item.get("downloaded_dub_episodes") or [] + sub_added = [value for value in new_sub_values if value not in old_sub_values] + sub_removed = [value for value in old_sub_values if value not in new_sub_values] + dub_added = [value for value in new_dub_values if value not in old_dub_values] + dub_removed = [value for value in old_dub_values if value not in new_dub_values] + downloaded_changed = bool(existing_item.get("downloaded")) != downloaded + row_changed = bool(sub_added or sub_removed or dub_added or dub_removed or downloaded_changed) + if row_changed: + conn.execute( + """ + UPDATE watchlist + SET downloaded = ?, downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ?, updated_at = ? + WHERE show_id = ? + """, + ( + 1 if downloaded else 0, + encode_episode_values(new_sub_values) if new_sub_values else None, + encode_episode_values(new_dub_values) if new_dub_values else None, + now_iso(), + existing_item["show_id"], + ), + ) + changed += 1 + items.append( + { + "show_id": existing_item.get("show_id"), + "title": existing_item.get("title"), + "media_type": media_type, + "changed": row_changed, + "downloaded": downloaded, + "sub_added": sub_added, + "sub_removed": sub_removed, + "dub_added": dub_added, + "dub_removed": dub_removed, + } + ) + return { + "message": f"Reconciled downloaded watchlist files for {len(items)} entr{'y' if len(items) == 1 else 'ies'}." + + (f" Updated {changed}." if changed else " No changes were needed."), + "total": len(items), + "changed": changed, + "items": items, + } + + def export_backup(self): + with self.lock, self._connect() as conn: + watchlist_columns = self._table_columns_conn(conn, "watchlist") + removal_columns = self._table_columns_conn(conn, "watchlist_removals") + watchlist_rows = [ + {column: row[column] for column in watchlist_columns} + for row in conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall() + ] + removal_rows = [ + {column: row[column] for column in removal_columns} + for row in conn.execute("SELECT * FROM watchlist_removals ORDER BY removed_at DESC").fetchall() + ] + return { + "format": "kaizoku-watchlist-backup", + "format_version": WATCHLIST_BACKUP_FORMAT_VERSION, + "app_version": VERSION, + "exported_at": now_iso(), + "tables": { + "watchlist": { + "columns": watchlist_columns, + "rows": watchlist_rows, + }, + "watchlist_removals": { + "columns": removal_columns, + "rows": removal_rows, + }, + }, + } + + def import_backup(self, backup): + if not isinstance(backup, dict): + raise ValueError("Watchlist backup must be a JSON object.") + if backup.get("format") not in {"kaizoku-watchlist-backup", "ani-cli-web-watchlist-backup"}: + raise ValueError("Backup format is not recognized.") + try: + format_version = int(backup.get("format_version") or 0) + except (TypeError, ValueError) as exc: + raise ValueError("Backup format version is invalid.") from exc + if format_version < 1 or format_version > WATCHLIST_BACKUP_FORMAT_VERSION: + raise ValueError("Backup format version is not supported by this app version.") + tables = backup.get("tables") + if not isinstance(tables, dict): + raise ValueError("Backup is missing table data.") + watchlist_rows = ((tables.get("watchlist") or {}).get("rows") if isinstance(tables.get("watchlist"), dict) else None) + removal_rows = ( + (tables.get("watchlist_removals") or {}).get("rows") + if isinstance(tables.get("watchlist_removals"), dict) + else [] + ) + if not isinstance(watchlist_rows, list): + raise ValueError("Backup is missing watchlist rows.") + if not isinstance(removal_rows, list): + raise ValueError("Backup removal rows must be an array.") + now = now_iso() + with self.lock, self._connect() as conn: + watchlist_columns = self._table_columns_conn(conn, "watchlist") + removal_columns = self._table_columns_conn(conn, "watchlist_removals") + conn.execute("DELETE FROM watchlist") + conn.execute("DELETE FROM watchlist_removals") + imported_watchlist = 0 + for row in watchlist_rows: + if not isinstance(row, dict): + raise ValueError("Backup watchlist rows must be JSON objects.") + show_id = str(row.get("show_id") or "").strip() + title = str(row.get("title") or "").strip() + if not show_id or not title: + raise ValueError("Backup watchlist rows must include show_id and title.") + values = {column: row.get(column) for column in watchlist_columns} + values["show_id"] = show_id + values["title"] = title + values["category"] = normalize_watchlist_category(values.get("category")) + values["downloaded"] = 1 if values.get("downloaded") else 0 + values["status"] = str(values.get("status") or "tracked") + values["status_message"] = str(values.get("status_message") or "Imported from backup.") + values["created_at"] = str(values.get("created_at") or now) + values["updated_at"] = str(values.get("updated_at") or now) + values["sub_count"] = int(values.get("sub_count") or 0) + values["dub_count"] = int(values.get("dub_count") or 0) + values["expected_count"] = int(values.get("expected_count") or 0) + values["media_type"] = normalize_media_type(values.get("media_type")) + placeholders = ", ".join("?" for _column in watchlist_columns) + column_sql = ", ".join(watchlist_columns) + conn.execute( + f"INSERT INTO watchlist ({column_sql}) VALUES ({placeholders})", + [values.get(column) for column in watchlist_columns], + ) + imported_watchlist += 1 + imported_removals = 0 + for row in removal_rows: + if not isinstance(row, dict): + raise ValueError("Backup removal rows must be JSON objects.") + show_id = str(row.get("show_id") or "").strip() + if not show_id: + continue + values = {column: row.get(column) for column in removal_columns} + values["show_id"] = show_id + values["title"] = str(values.get("title") or "").strip() + values["removed_at"] = str(values.get("removed_at") or now) + placeholders = ", ".join("?" for _column in removal_columns) + column_sql = ", ".join(removal_columns) + conn.execute( + f"INSERT INTO watchlist_removals ({column_sql}) VALUES ({placeholders})", + [values.get(column) for column in removal_columns], + ) + imported_removals += 1 + return { + "message": f"Imported {imported_watchlist} watchlist entr{'y' if imported_watchlist == 1 else 'ies'} from backup.", + "imported": imported_watchlist, + "removals": imported_removals, + } + + def mark_downloaded(self, show_id, title="", mode="", episodes="", media_type=""): + normalized_show_id = str(show_id or "").strip() + if not normalized_show_id: + raise ValueError("Missing show_id for watchlist download sync.") + + normalized_title = str(title or "").strip() + normalized_mode = str(mode or "").strip().lower() + normalized_media_type = normalize_media_type(media_type or "tv") + episode_spec = str(episodes or "").strip() + now = now_iso() + downloaded_episode_values = [] + if normalized_mode in MODE_CHOICES and episode_spec: + try: + available_episodes = episode_list(normalized_show_id, normalized_mode) + except Exception: + available_episodes = [] + downloaded_episode_values = expand_episode_spec_against_available(episode_spec, available_episodes) + with self.lock, self._connect() as conn: + row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (normalized_show_id,)).fetchone() + if row: + existing_item = self._row_to_item(row) + sub_payload = existing_item.get("downloaded_sub_episodes_json") + dub_payload = existing_item.get("downloaded_dub_episodes_json") + if normalized_mode == "sub" and downloaded_episode_values: + sub_payload = encode_episode_values(existing_item.get("downloaded_sub_episodes", []) + downloaded_episode_values) + elif normalized_mode == "dub" and downloaded_episode_values: + dub_payload = encode_episode_values(existing_item.get("downloaded_dub_episodes", []) + downloaded_episode_values) + completed_item = dict(existing_item) + completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload) + completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload) + if normalize_media_type(existing_item.get("media_type")) == "movie": + is_complete = True + else: + is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode) + target_category = "finished" if is_complete else existing_item["category"] + conn.execute( + """ + UPDATE watchlist + SET category = ?, downloaded = 1, status = 'queued', status_message = ?, updated_at = ?, + downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ? + WHERE show_id = ? + """, + (target_category, "Queued refresh after successful download.", now, sub_payload, dub_payload, normalized_show_id), + ) + else: + if self._was_removed_conn(conn, normalized_show_id): + return None + matched_row = self._find_download_title_match_conn(conn, normalized_title, exclude_show_id=normalized_show_id) + if matched_row is not None: + matched_item = self._row_to_item(matched_row) + previous_show_id = str(matched_row["show_id"]).strip() + resolved_title = normalized_title or str(matched_row["title"] or "").strip() or "Unknown Anime" + sub_payload = matched_item.get("downloaded_sub_episodes_json") + dub_payload = matched_item.get("downloaded_dub_episodes_json") + if normalized_mode == "sub" and downloaded_episode_values: + sub_payload = encode_episode_values(matched_item.get("downloaded_sub_episodes", []) + downloaded_episode_values) + elif normalized_mode == "dub" and downloaded_episode_values: + dub_payload = encode_episode_values(matched_item.get("downloaded_dub_episodes", []) + downloaded_episode_values) + completed_item = dict(matched_item) + completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload) + completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload) + if normalize_media_type(matched_item.get("media_type")) == "movie": + is_complete = True + else: + is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode) + target_category = "finished" if is_complete else matched_item["category"] + conn.execute( + """ + UPDATE watchlist + SET + show_id = ?, + title = ?, + category = ?, + downloaded = 1, + status = 'queued', + status_message = ?, + updated_at = ?, + downloaded_sub_episodes_json = ?, + downloaded_dub_episodes_json = ? + WHERE show_id = ? + """, + ( + normalized_show_id, + resolved_title, + target_category, + "Queued refresh after successful download.", + now, + sub_payload, + dub_payload, + previous_show_id, + ), + ) + if previous_show_id != normalized_show_id: + event = self.thumbnail_events.pop(previous_show_id, None) + if event is not None: + self.thumbnail_events[normalized_show_id] = event + self._drop_refresh_locked(previous_show_id) + else: + existing = { + **self._auto_download_defaults(normalized_title or "Unknown Anime"), + "show_id": normalized_show_id, + "title": normalized_title or "Unknown Anime", + "category": "finished", + "downloaded": True, + "status": "queued", + "status_message": "Queued refresh after successful download.", + "created_at": now, + "updated_at": now, + "last_checked": None, + "sub_count": 0, + "dub_count": 0, + "expected_count": 0, + "airing_status": None, + "sub_latest_episode": None, + "dub_latest_episode": None, + "animeschedule_route": None, + "animeschedule_title": None, + "anilist_id": None, + "anilist_title": None, + "anilist_site_url": None, + "alternative_titles_json": None, + "anidb_aid": None, + "anidb_title": None, + "media_type": normalized_media_type, + "auto_download_source_name": None, + "auto_download_series": None, + "downloaded_sub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "sub" else None, + "downloaded_dub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "dub" else None, + "thumbnail_path": None, + "thumbnail_checked_at": None, + } + self._upsert_conn(conn, existing) + self.schedule_refresh(normalized_show_id, source="sync") + return self.get(normalized_show_id) + + def update_auto_download_settings(self, show_id, mode=None, quality=None, name=None, source_name=None, series=None, episode_offset=None): + existing = self.get(show_id) + defaults = self._auto_download_defaults(existing["title"]) + normalized_mode = str(mode or defaults["auto_download_mode"]).strip().lower() + normalized_quality = str(quality or defaults["auto_download_quality"]).strip().lower() + if normalized_mode not in MODE_CHOICES: + raise ValueError("Auto-download mode must be sub or dub.") + if normalized_quality not in QUALITY_CHOICES: + raise ValueError("Unknown auto-download quality.") + normalized_name = sanitize_path_component(name or defaults["auto_download_name"], defaults["auto_download_name"]) + normalized_source_name = str(source_name or existing.get("auto_download_source_name") or defaults["auto_download_source_name"]).strip() + if not normalized_source_name: + normalized_source_name = defaults["auto_download_source_name"] + normalized_series = normalize_season(series or existing.get("auto_download_series") or defaults["auto_download_series"]) + normalized_offset = ( + normalize_episode_offset(existing.get("auto_download_offset")) + if episode_offset is None + else normalize_episode_offset(episode_offset) + ) + with self.lock, self._connect() as conn: + conn.execute( + """ + UPDATE watchlist + SET auto_download_mode = ?, auto_download_quality = ?, auto_download_name = ?, auto_download_source_name = ?, auto_download_series = ?, auto_download_offset = ?, updated_at = ? + WHERE show_id = ? + """, + ( + normalized_mode, + normalized_quality, + normalized_name, + normalized_source_name, + normalized_series, + normalized_offset, + now_iso(), + existing["show_id"], + ), + ) + return self.get(show_id) + + def remove(self, show_id): + existing = self.get(show_id) + thumb_path = thumbnail_file_path(existing.get("thumbnail_path")) + with self.lock, self._connect() as conn: + cursor = conn.execute("DELETE FROM watchlist WHERE show_id = ?", (str(show_id).strip(),)) + if cursor.rowcount < 1: + raise KeyError("Anime not found in watchlist.") + self._remember_removal_conn(conn, existing["show_id"], existing["title"]) + event = self.thumbnail_events.pop(str(show_id).strip(), None) + if event is not None: + event.set() + self._drop_refresh_locked(show_id) + if thumb_path and thumb_path.exists(): + thumb_path.unlink(missing_ok=True) + return {"ok": True, "message": f"Removed {existing['title']} from the watchlist.", "item": existing} + + def set_manual_thumbnail(self, show_id, filename, data_url): + existing = self.get(show_id) + content_type, content = decode_image_data_url(data_url) + cached_name = save_thumbnail_bytes(existing["show_id"], filename or existing["title"], content_type, content) + checked_at = now_iso() + with self.lock, self._connect() as conn: + conn.execute( + """ + UPDATE watchlist + SET thumbnail_path = ?, thumbnail_checked_at = ?, updated_at = ? + WHERE show_id = ? + """, + (cached_name, checked_at, checked_at, existing["show_id"]), + ) + item = self.get(show_id) + return {"message": f"Uploaded thumbnail for {item['title']}.", "item": item} + + def ensure_thumbnail(self, show_id, force=False): + item = self.get(show_id) + current_path = thumbnail_file_path(item.get("thumbnail_path")) + current_exists = bool(current_path and current_path.exists()) + debug_log( + "thumbnail.ensure.start", + show_id=show_id, + force=force, + title=item.get("title"), + cached_path=current_path, + cached_exists=current_exists, + thumbnail_ready=item.get("thumbnail_ready"), + route=item.get("animeschedule_route"), + aid=item.get("anidb_aid"), + ) + if current_exists and not force: + debug_log("thumbnail.ensure.cached", show_id=show_id, path=current_path) + return current_path + if not force and current_path is None and not thumbnail_retry_due(item.get("thumbnail_checked_at")): + debug_log("thumbnail.ensure.skipped_retry_window", show_id=show_id, checked_at=item.get("thumbnail_checked_at")) + return None + if current_path and not current_exists: + debug_log("thumbnail.ensure.missing_cached_file", show_id=show_id, expected_path=current_path) + + with self.lock: + event = self.thumbnail_events.get(show_id) + if event: + wait_event = event + owns_event = False + else: + wait_event = threading.Event() + self.thumbnail_events[show_id] = wait_event + owns_event = True + + if event: + wait_event.wait(timeout=20) + if not self.has_show(show_id): + debug_log("thumbnail.ensure.waited_removed", show_id=show_id) + return None + refreshed = self.get(show_id) + refreshed_path = thumbnail_file_path(refreshed.get("thumbnail_path")) + debug_log("thumbnail.ensure.waited_for_inflight", show_id=show_id, resolved_path=refreshed_path) + return refreshed_path if refreshed_path and refreshed_path.exists() else None + + cached_name = None + cover = None + checked_at = now_iso() + try: + try: + stored_route = str(item.get("animeschedule_route") or "").strip() + stored_schedule_title = str(item.get("animeschedule_title") or item.get("title") or "").strip() + if stored_route: + try: + debug_log("thumbnail.ensure.try_route", source="AnimeSchedule", show_id=show_id, route=stored_route) + cover = fetch_animeschedule_cover_by_route(stored_route, fallback_title=stored_schedule_title) + except Exception: + debug_log("thumbnail.ensure.route_failed", source="AnimeSchedule", show_id=show_id, route=stored_route) + if cover is None: + try: + debug_log("thumbnail.ensure.try_title", source="AnimeSchedule", show_id=show_id, title=item["title"]) + cover = fetch_animeschedule_cover(item["title"]) + except Exception: + debug_log("thumbnail.ensure.title_failed", source="AnimeSchedule", show_id=show_id, title=item["title"]) + cover = None + stored_aid = str(item.get("anidb_aid") or "").strip() + stored_title = str(item.get("anidb_title") or item.get("title") or "").strip() + if cover is None and stored_aid: + try: + debug_log("thumbnail.ensure.try_route", source="AniDB", show_id=show_id, aid=stored_aid) + cover = fetch_anidb_cover_by_aid(stored_aid, fallback_title=stored_title) + except Exception: + debug_log("thumbnail.ensure.route_failed", source="AniDB", show_id=show_id, aid=stored_aid) + if cover is None: + debug_log("thumbnail.ensure.try_title", source="AniDB", show_id=show_id, title=item["title"]) + cover = fetch_anidb_cover(item["title"]) + debug_log( + "thumbnail.ensure.cover_selected", + show_id=show_id, + source="AnimeSchedule" if cover.get("route") else "AniDB", + cover=cover, + ) + cached_name = cache_thumbnail_image(item["show_id"], cover["url"]) + except Exception as exc: + debug_log("thumbnail.ensure.failed", show_id=show_id, error=exc) + cached_name = None + + with self.lock, self._connect() as conn: + conn.execute( + """ + UPDATE watchlist + SET thumbnail_path = ?, thumbnail_checked_at = ?, + animeschedule_route = ?, animeschedule_title = ?, + anidb_aid = ?, anidb_title = ? + WHERE show_id = ? + """, + ( + cached_name, + checked_at, + (cover or {}).get("route"), + (cover or {}).get("title") if (cover or {}).get("route") else item.get("animeschedule_title"), + (cover or {}).get("aid"), + (cover or {}).get("title") if (cover or {}).get("aid") else item.get("anidb_title"), + item["show_id"], + ), + ) + debug_log( + "thumbnail.ensure.finish", + show_id=show_id, + cached_name=cached_name, + route=(cover or {}).get("route"), + aid=(cover or {}).get("aid"), + checked_at=checked_at, + ) + return thumbnail_file_path(cached_name) + finally: + if owns_event: + wait_event.set() + with self.lock: + inflight = self.thumbnail_events.get(show_id) + if inflight is wait_event: + self.thumbnail_events.pop(show_id, None) + + def ensure_thumbnails(self, show_ids, limit=6, force=False): + normalized = [] + for show_id in show_ids: + text = str(show_id or "").strip() + if text and text not in normalized: + normalized.append(text) + updated = [] + for show_id in normalized[: max(1, min(int(limit or 6), 10))]: + try: + item = self.get(show_id) + except KeyError: + continue + if not force and item.get("thumbnail_ready"): + updated.append(item) + continue + try: + self.ensure_thumbnail(show_id, force=force) + except Exception: + pass + try: + updated.append(self.get(show_id)) + except KeyError: + continue + return {"items": updated} + + +def add_to_watchlist(payload): + ensure_runtime() + return WATCHLIST.add(payload) + + +def get_watchlist(page=1, per_page=30, category=None): + ensure_runtime() + return WATCHLIST.list(page=page, per_page=per_page, category=category) + + +def get_watchlist_homepage_summary(): + ensure_runtime() + return WATCHLIST.homepage_summary() + + +def update_watchlist_status(show_id, _mode=None): + ensure_runtime() + item = WATCHLIST.queue_refresh(show_id, source="manual") + return {"message": f"Queued refresh for {item['title']}.", "item": item} + + +def download_watchlist_item(show_id, mode): + runtime = ensure_runtime() + item = runtime["watchlist"].get(show_id) + normalized_mode = str(mode or "").strip().lower() + if normalized_mode not in MODE_CHOICES: + raise ValueError("Mode must be sub or dub") + + query = str(item.get("auto_download_source_name") or item.get("title") or item.get("show_id") or "").strip() + if not query: + raise ValueError("Watchlist item is missing a searchable title.") + + episodes = episode_list(item["show_id"], normalized_mode) + if not episodes: + raise ValueError(f"No {normalized_mode} episodes are currently available for {item['title']}.") + episode_spec = episodes[0] if len(episodes) == 1 else f"{episodes[0]}-{episodes[-1]}" + + payload = { + "show_id": item["show_id"], + "provider": provider_bridge.parse_provider_id(item["show_id"], runtime["config"].get("provider"))[0], + "query": query, + "title": item["title"], + "anime_name": item.get("auto_download_name") or item["title"], + "media_type": item.get("media_type") or "tv", + "season": item.get("auto_download_series") or "1", + "episode_offset": item.get("auto_download_offset"), + "mode": normalized_mode, + "quality": item.get("auto_download_quality") + or runtime["config"].get("auto_download_quality") + or runtime["config"]["quality"], + "episodes": episode_spec, + "download_dir": runtime["config"]["download_dir"], + } + job = runtime["download_queue"].add(payload) + return { + "message": f"Queued {item['title']} for {normalized_mode} download.", + "job": job, + "item": item, + } + + +def queue_watchlist_auto_download(item, refresh_source="manual"): + source = str(refresh_source or "manual").strip().lower() or "manual" + if source not in {"manual", "scheduled"}: + return {"queued": False, "reason": f"source:{source}"} + runtime = ensure_runtime() + config = runtime["config"] + if not bool(config.get("auto_download_enabled")): + return {"queued": False, "reason": "config_disabled"} + if normalize_watchlist_category(item.get("category")) != "watching": + return {"queued": False, "reason": "category"} + + mode = str(item.get("auto_download_mode") or config.get("auto_download_mode") or "dub").strip().lower() + quality = str(item.get("auto_download_quality") or config.get("auto_download_quality") or "best").strip().lower() + if mode not in MODE_CHOICES: + mode = "dub" + if quality not in QUALITY_CHOICES: + quality = "best" + available_episodes = list(item.get(f"{mode}_episode_values") or []) + downloaded_episodes = set(item.get("downloaded_dub_episodes", []) if mode == "dub" else item.get("downloaded_sub_episodes", [])) + if not available_episodes: + return {"queued": False, "reason": "mode_has_no_available_episodes"} + + covered = runtime["download_queue"].covered_episodes(item.get("show_id"), mode, available_episodes) + missing = [episode for episode in available_episodes if episode not in downloaded_episodes and episode not in covered] + specs = episode_specs_for_values(missing, available_episodes) + if not specs: + return {"queued": False, "reason": "nothing_missing"} + + query = str(item.get("auto_download_source_name") or item.get("title") or item.get("show_id") or "").strip() + if not query: + return {"queued": False, "reason": "missing_title"} + + anime_name = sanitize_path_component( + item.get("auto_download_name") or item.get("title") or "Anime", + sanitize_path_component(item.get("title") or "Anime", "Anime"), + ) + series = normalize_season(item.get("auto_download_series") or "1") + episode_offset = normalize_episode_offset(item.get("auto_download_offset")) + jobs = [] + for episode_spec in specs: + job = runtime["download_queue"].add( + { + "show_id": item["show_id"], + "provider": provider_bridge.parse_provider_id(item["show_id"], config.get("provider"))[0], + "query": query, + "title": item["title"], + "anime_name": anime_name, + "media_type": item.get("media_type") or "tv", + "season": series, + "episode_offset": episode_offset, + "mode": mode, + "quality": quality, + "episodes": episode_spec, + "download_dir": config["download_dir"], + } + ) + jobs.append(job) + return {"queued": True, "mode": mode, "quality": quality, "episodes": specs, "jobs": jobs} + + +def trigger_jellyfin_sync_for_item(item, automatic=False, config=None, progress_fn=None): + active_config = normalize_config(config or get_config_snapshot()) + if automatic and not bool(active_config.get("jellyfin_sync_enabled")): + return {"moved": False, "reason": "disabled", "item": item} + ready, reason = watchlist_item_ready_for_jellyfin(item) + if not ready: + return {"moved": False, "reason": reason, "item": item} + media_type = normalize_media_type(item.get("media_type")) + target_key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir" + if not str(active_config.get(target_key) or "").strip(): + result = {"moved": False, "reason": "target_missing", "item": item} + try: + notify_jellyfin_sync_result(item, result, active_config, automatic=automatic) + except Exception as exc: + debug_log("discord_webhook.jellyfin_sync_failed", show_id=item.get("show_id"), reason=result.get("reason"), error=exc) + return result + try: + result = move_watchlist_item_to_jellyfin(item, active_config, progress_fn=progress_fn) + except Exception as exc: + result = { + "moved": False, + "reason": "error", + "item": item, + "source": str(watchlist_source_library_dir(item, active_config)), + "target": str(jellyfin_target_library_dir(item, active_config)), + "error": str(exc), + } + try: + notify_jellyfin_sync_result(item, result, active_config, automatic=automatic) + except Exception as exc: + debug_log("discord_webhook.jellyfin_sync_failed", show_id=item.get("show_id"), reason=result.get("reason"), error=exc) + if result.get("moved"): + debug_log( + "jellyfin.sync.moved", + show_id=item.get("show_id"), + media_type=media_type, + source=result.get("source"), + target=result.get("target"), + automatic=automatic, + ) + return result + + +def run_jellyfin_sync(config=None): + runtime = ensure_runtime() + active_config = normalize_config(config or runtime["config"] or {}) + items = [] + moved = 0 + for show_id in runtime["watchlist"].all_show_ids(): + try: + item = runtime["watchlist"].get(show_id) + except KeyError: + continue + result = trigger_jellyfin_sync_for_item(item, automatic=False, config=active_config) + items.append( + { + "show_id": item.get("show_id"), + "title": item.get("title"), + "media_type": item.get("media_type"), + "moved": bool(result.get("moved")), + "reason": result.get("reason"), + "target": result.get("target"), + } + ) + if result.get("moved"): + moved += 1 + return { + "message": f"Moved {moved} watchlist entr{'y' if moved == 1 else 'ies'} to Jellyfin libraries.", + "moved": moved, + "total": len(items), + "items": items, + } + + +def start_jellyfin_sync(config=None): + ensure_runtime() + return WATCHLIST_JELLYFIN_SYNC.start(config=config) + + +def get_jellyfin_sync_status(): + ensure_runtime() + return WATCHLIST_JELLYFIN_SYNC.status() + + +def export_watchlist_backup(): + runtime = ensure_runtime() + return runtime["watchlist"].export_backup() + + +def import_watchlist_backup(payload): + runtime = ensure_runtime() + return runtime["watchlist"].import_backup(payload) + + +def reconcile_watchlist_downloaded_files(config=None): + runtime = ensure_runtime() + active_config = normalize_config(config or runtime["config"] or {}) + return runtime["watchlist"].reconcile_downloaded_filesystem(active_config) + + +def update_watchlist_category(show_id, category): + ensure_runtime() + item = WATCHLIST.update_category(show_id, category) + return {"message": f"Moved {item['title']} to {item['category_label']}.", "item": item} + + +def update_watchlist_media_type(show_id, media_type): + ensure_runtime() + item = WATCHLIST.update_media_type(show_id, media_type) + return {"message": f"Saved {item['title']} as {item['media_type_label']}.", "item": item} + + +def update_watchlist_auto_download(show_id, mode=None, quality=None, name=None, source_name=None, series=None, episode_offset=None): + ensure_runtime() + item = WATCHLIST.update_auto_download_settings( + show_id, + mode=mode, + quality=quality, + name=name, + source_name=source_name, + series=series, + episode_offset=episode_offset, + ) + return {"message": f"Saved auto-download settings for {item['title']}.", "item": item} + + +def update_all_watchlist_statuses(): + ensure_runtime() + return WATCHLIST_REFRESH.start() + + +def get_watchlist_refresh_status(): + ensure_runtime() + return WATCHLIST_REFRESH.status() + + +def remove_from_watchlist(show_id): + runtime = ensure_runtime() + result = WATCHLIST.remove(show_id) + runtime["download_queue"].detach_show(show_id) + return result + + +def upload_watchlist_thumbnail(payload): + ensure_runtime() + return WATCHLIST.set_manual_thumbnail(payload["show_id"], payload.get("filename"), payload.get("data_url")) + + +def resolve_job_watchlist_identity(job, config=None): + active_config = config or CONFIG or DEFAULT_CONFIG + return resolve_job_watchlist_identity_impl( + job, + search_fn=search_anime, + normalize_title_key_fn=normalize_identity_title_key, + base_title_variants_fn=base_title_variants, + default_mode=active_config["mode"], + ) + + +def sync_downloaded_job_to_watchlist(job): + ensure_runtime() + if job.get("skip_watchlist_sync"): + return None + show_id, title, reason = resolve_job_watchlist_identity_impl( + job, + search_fn=search_anime, + normalize_title_key_fn=normalize_identity_title_key, + base_title_variants_fn=base_title_variants, + default_mode=(CONFIG or DEFAULT_CONFIG)["mode"], + return_reason=True, + ) + if not show_id: + debug_log("watchlist.sync.resolve_failed", job=job, reason=reason) + raise ValueError(f"Could not resolve a watchlist show_id for the completed download: {reason}.") + return WATCHLIST.mark_downloaded( + show_id, + title=title, + mode=job.get("mode"), + episodes=job.get("episodes"), + media_type=job.get("media_type"), + ) + +def prepare_download_job(job): + config = ensure_runtime()["config"] + if normalize_result_index(job.get("result_index")) is None: + result_index = infer_download_job_result_index(job, config) + if result_index is not None: + job["result_index"] = result_index + show_id, title, reason = resolve_job_watchlist_identity_impl( + job, + search_fn=search_anime, + normalize_title_key_fn=normalize_identity_title_key, + base_title_variants_fn=base_title_variants, + default_mode=config["mode"], + return_reason=True, + ) + if show_id: + job["show_id"] = show_id + if title: + job["title"] = title + if not show_id and reason: + debug_log("watchlist.prepare_job.resolve_skipped", job=job, reason=reason) + return job + + +def infer_download_job_result_index(job, config): + query = str(job.get("query") or job.get("title") or job.get("anime_name") or "").strip() + mode = str(job.get("mode") or config.get("mode") or "sub").strip().lower() + if not query or mode not in MODE_CHOICES: + return None + try: + results = search_anime(query, mode) + except Exception as exc: + debug_log("watchlist.prepare_job.result_index_search_failed", job=job, error=exc) + return None + + show_id = str(job.get("show_id") or "").strip() + if show_id: + for position, result in enumerate(results, start=1): + if str(result.get("id") or "").strip() == show_id: + return position + + title = str(job.get("title") or job.get("anime_name") or query).strip() + matches = [] + for position, result in enumerate(results, start=1): + candidate_title = str(result.get("title") or "").strip() + if titles_confidently_match(title, candidate_title, normalize_identity_title_key, base_title_variants): + matches.append(position) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + debug_log("watchlist.prepare_job.result_index_ambiguous", job=job, matches=matches) + return None + + +def get_config_snapshot(fallback=None): + with RUNTIME_LOCK: + source = CONFIG if CONFIG is not None else fallback + if source is not None: + return dict(source) + return normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG)) + + +def save_runtime_config(config): + current = get_config_snapshot(fallback=DEFAULT_CONFIG) + merged = dict(current) + if isinstance(config, dict): + if "anipy_cli_fallback_enabled" in config and "download_methods" not in config: + merged.pop("download_methods", None) + merged.update(config) + normalized = normalize_config(merged) + previous_fingerprint = remote_access_session_fingerprint(current) + next_fingerprint = remote_access_session_fingerprint(normalized) + scheduler = None + with RUNTIME_LOCK: + global CONFIG + CONFIG = dict(normalized) + write_json(CONFIG_PATH, CONFIG) + scheduler = WATCHLIST_AUTO_REFRESH + if previous_fingerprint and previous_fingerprint != next_fingerprint: + clear_remote_access_sessions({"fingerprint": previous_fingerprint}) + if scheduler is not None: + scheduler.notify_config_changed() + return dict(CONFIG) + + +def test_discord_webhook_config(config): + normalized = normalize_config(config) + if not normalized.get("discord_webhook_url"): + raise ValueError("Discord webhook URL is required for testing.") + validate_discord_webhook_url(normalized["discord_webhook_url"]) + send_discord_webhook_event(normalized, "test", force=True) + return {"message": "Discord webhook test notification sent."} + + +def runtime_state(): + return { + "config": CONFIG, + "watchlist": WATCHLIST, + "download_queue": DOWNLOAD_QUEUE, + "watchlist_refresh": WATCHLIST_REFRESH, + "watchlist_auto_refresh": WATCHLIST_AUTO_REFRESH, + "watchlist_jellyfin_sync": WATCHLIST_JELLYFIN_SYNC, + } + + +def build_runtime(start_workers=None): + migrate_legacy_state_storage() + worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) and background_workers_enabled() + config = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG)) + write_json(CONFIG_PATH, config) + watchlist = WatchlistStore(config_getter=lambda: get_config_snapshot(config), start_worker=worker_state) + download_queue = DownloadQueue( + config_getter=lambda: get_config_snapshot(config), + watchlist_sync_fn=sync_downloaded_job_to_watchlist, + job_prepare_fn=prepare_download_job, + start_worker=worker_state, + ) + watchlist.auto_download_fn = queue_watchlist_auto_download + watchlist.jellyfin_sync_fn = trigger_jellyfin_sync_for_item + watchlist_refresh = WatchlistRefreshJobs( + watchlist, + config_getter=lambda: get_config_snapshot(config), + start_worker=worker_state, + ) + watchlist_jellyfin_sync = JellyfinSyncJobs( + watchlist, + sync_fn=trigger_jellyfin_sync_for_item, + config_getter=lambda: get_config_snapshot(config), + start_worker=worker_state, + ) + watchlist_auto_refresh = WatchlistAutoRefreshScheduler( + config_getter=lambda: get_config_snapshot(config), + refresh_start_fn=watchlist_refresh.start, + start_worker=worker_state, + ) + return { + "config": config, + "watchlist": watchlist, + "download_queue": download_queue, + "watchlist_refresh": watchlist_refresh, + "watchlist_auto_refresh": watchlist_auto_refresh, + "watchlist_jellyfin_sync": watchlist_jellyfin_sync, + } + + +def initialize_runtime(start_workers=None): + global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH, WATCHLIST_JELLYFIN_SYNC + with RUNTIME_LOCK: + workers_allowed = background_workers_enabled() + if ( + CONFIG is not None + and WATCHLIST is not None + and DOWNLOAD_QUEUE is not None + and WATCHLIST_REFRESH is not None + and WATCHLIST_AUTO_REFRESH is not None + and WATCHLIST_JELLYFIN_SYNC is not None + ): + if start_workers and workers_allowed: + WATCHLIST.worker_enabled = True + WATCHLIST.ensure_worker() + DOWNLOAD_QUEUE.ensure_worker() + WATCHLIST_REFRESH.ensure_worker() + WATCHLIST_AUTO_REFRESH.ensure_worker() + WATCHLIST_JELLYFIN_SYNC.ensure_worker() + return runtime_state() + runtime = build_runtime(start_workers=start_workers) + CONFIG = runtime["config"] + WATCHLIST = runtime["watchlist"] + DOWNLOAD_QUEUE = runtime["download_queue"] + WATCHLIST_REFRESH = runtime["watchlist_refresh"] + WATCHLIST_AUTO_REFRESH = runtime["watchlist_auto_refresh"] + WATCHLIST_JELLYFIN_SYNC = runtime["watchlist_jellyfin_sync"] + return runtime + + +def ensure_runtime(start_workers=False): + if ( + CONFIG is None + or WATCHLIST is None + or DOWNLOAD_QUEUE is None + or WATCHLIST_REFRESH is None + or WATCHLIST_AUTO_REFRESH is None + or WATCHLIST_JELLYFIN_SYNC is None + ): + return initialize_runtime(start_workers=start_workers) + if start_workers: + return initialize_runtime(start_workers=True) + return runtime_state() + + +def shutdown_runtime(wait=False, cancel_active_downloads=None): + services = None + with RUNTIME_LOCK: + if ( + CONFIG is None + and WATCHLIST is None + and DOWNLOAD_QUEUE is None + and WATCHLIST_REFRESH is None + and WATCHLIST_AUTO_REFRESH is None + and WATCHLIST_JELLYFIN_SYNC is None + ): + return False + services = (WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH, WATCHLIST_JELLYFIN_SYNC) + watchlist, download_queue, watchlist_refresh, watchlist_auto_refresh, watchlist_jellyfin_sync = services + should_cancel_downloads = bool(wait) if cancel_active_downloads is None else bool(cancel_active_downloads) + stopped = True + if watchlist is not None: + stopped = watchlist.shutdown(wait=wait) and stopped + if download_queue is not None: + stopped = download_queue.shutdown(wait=wait, cancel_active=should_cancel_downloads) and stopped + if watchlist_refresh is not None: + stopped = watchlist_refresh.shutdown(wait=wait) and stopped + if watchlist_auto_refresh is not None: + stopped = watchlist_auto_refresh.shutdown(wait=wait) and stopped + if watchlist_jellyfin_sync is not None: + stopped = watchlist_jellyfin_sync.shutdown(wait=wait) and stopped + return stopped + + +def reset_runtime(wait=False, cancel_active_downloads=None): + global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH, WATCHLIST_JELLYFIN_SYNC + stopped = shutdown_runtime(wait=wait, cancel_active_downloads=cancel_active_downloads) + if not stopped: + if wait: + raise RuntimeError("Runtime shutdown did not complete cleanly.") + return False + with RUNTIME_LOCK: + CONFIG = None + WATCHLIST = None + DOWNLOAD_QUEUE = None + WATCHLIST_REFRESH = None + WATCHLIST_AUTO_REFRESH = None + WATCHLIST_JELLYFIN_SYNC = None + return True + + +Handler = build_handler_class( + HandlerContext( + add_to_watchlist=add_to_watchlist, + config_html=CONFIG_HTML, + download_watchlist_item=download_watchlist_item, + ensure_runtime=ensure_runtime, + episode_list=episode_list, + export_watchlist_backup=export_watchlist_backup, + get_config_snapshot=get_config_snapshot, + get_watchlist_homepage_summary=get_watchlist_homepage_summary, + get_jellyfin_sync_status=get_jellyfin_sync_status, + get_watchlist=get_watchlist, + get_watchlist_refresh_status=get_watchlist_refresh_status, + http_error=HttpError, + index_html=INDEX_HTML, + import_watchlist_backup=import_watchlist_backup, + queue_html=QUEUE_HTML, + reconcile_watchlist_downloaded_files=reconcile_watchlist_downloaded_files, + remove_from_watchlist=remove_from_watchlist, + run_jellyfin_sync=run_jellyfin_sync, + runtime_state=runtime_state, + save_runtime_config=save_runtime_config, + search_anime=search_anime, + resolve_search_thumbnail=resolve_search_thumbnail, + test_discord_webhook_config=test_discord_webhook_config, + thumbnail_file_path=thumbnail_file_path, + start_jellyfin_sync=start_jellyfin_sync, + update_all_watchlist_statuses=update_all_watchlist_statuses, + update_watchlist_auto_download=update_watchlist_auto_download, + update_watchlist_category=update_watchlist_category, + update_watchlist_media_type=update_watchlist_media_type, + update_watchlist_status=update_watchlist_status, + upload_watchlist_thumbnail=upload_watchlist_thumbnail, + watchlist_html=WATCHLIST_HTML, + ) +) + + +def parse_runtime_flags(argv): + remaining = [] + for arg in argv: + if arg in {"--debug", "---debug"}: + app_support.DEBUG_MODE = True + continue + remaining.append(arg) + return remaining + + +def main(): + parse_runtime_flags(sys.argv[1:]) + host = server_host() + port = server_port() + server = ThreadingHTTPServer((host, port), Handler) + debug_log("server.start", version=VERSION, host=host, port=port, argv=sys.argv[1:]) + print(f"Serving {APP_NAME} {VERSION} on http://{host}:{port}/") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down server.") + finally: + if not shutdown_runtime(wait=True, cancel_active_downloads=True): + print("Warning: background workers did not stop cleanly.") + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/app_support.py b/app_support.py new file mode 100644 index 0000000..20f743c --- /dev/null +++ b/app_support.py @@ -0,0 +1,1289 @@ +#!/usr/bin/env python3 + +"""Shared constants and helper utilities for Kaizoku.""" + +import hashlib +import hmac +import ipaddress +import json +import mimetypes +import os +import re +import secrets +import shutil +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +import urllib.error +import urllib.request +from urllib.parse import urlparse +from uuid import uuid4 + + +PROJECT_ROOT = Path(__file__).resolve().parent + + +def configured_cli_command(env_name, executable_name): + configured = str(os.environ.get(env_name) or "").strip() + if configured: + return configured + resolved = shutil.which(executable_name) + if resolved: + return resolved + for candidate in (Path("/usr/local/bin") / executable_name, Path("/usr/bin") / executable_name): + if candidate.exists() and os.access(candidate, os.X_OK): + return str(candidate) + return executable_name + + +KAIZOKU_DOWNLOADER = configured_cli_command("KAIZOKU_DOWNLOADER_BIN", "provider_downloader.py") +ANI_CLI = configured_cli_command("ANI_CLI_BIN", "ani-cli") +ANIPY_CLI = configured_cli_command("ANIPY_CLI_BIN", "anipy-cli") +ANIMDL = configured_cli_command("ANIMDL_BIN", "animdl") +APP_NAME = "Kaizoku" +VERSION_FILE = PROJECT_ROOT / "VERSION" +CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md" +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"} +PROVIDER_CHOICES = ("anikoto", "anineko", "pahe") +DOWNLOAD_METHOD_CHOICES = ("kaizoku",) +MEDIA_TYPE_CHOICES = {"tv", "movie"} +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 +WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS = 8 +WORKER_SHUTDOWN_TIMEOUT_SECONDS = 10 +WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES = 60 +WATCHLIST_AUTO_REFRESH_MIN_MINUTES = 1 +WATCHLIST_AUTO_REFRESH_MAX_MINUTES = 7 * 24 * 60 +WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS = 5 +WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0 +WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300 +DISCORD_WEBHOOK_EVENT_CHOICES = ( + "download_completed", + "jellyfin_sync_success", + "jellyfin_sync_failed", + "watchlist_refresh_new_episodes", + "watchlist_refresh_failed", + "watchlist_refresh_interrupted", + "runtime_error", +) +DISCORD_WEBHOOK_EVENT_LABELS = { + "download_completed": "Download completed", + "jellyfin_sync_success": "Jellyfin move succeeded", + "jellyfin_sync_failed": "Jellyfin move failed", + "watchlist_refresh_new_episodes": "Refresh finished with new episodes found", + "watchlist_refresh_failed": "Refresh failed or finished with refresh errors", + "watchlist_refresh_interrupted": "Refresh interrupted by shutdown or restart", + "runtime_error": "Unexpected runtime errors", +} +REMOTE_ACCESS_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60 +REMOTE_SESSION_STORE_LOCK = threading.RLock() + + +def load_app_version(default="0.0.0"): + try: + version = VERSION_FILE.read_text(encoding="utf-8").strip() + except OSError: + return default + return version or default + + +def load_project_text_file(path, default=""): + try: + return Path(path).read_text(encoding="utf-8") + except OSError: + return default + + +VERSION = load_app_version() + + +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_username(config=None): + configured = str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip() + if configured: + return configured + if isinstance(config, dict): + return str(config.get("auth_username") or "").strip() + return "" + + +def remote_access_password(config=None): + configured = str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip() + if configured: + return configured + if isinstance(config, dict): + return str(config.get("auth_password") or "").strip() + return "" + + +def remote_access_credentials_configured(config=None): + return bool(remote_access_username(config) and remote_access_password(config)) + + +def remote_access_credentials_match(username, password, config=None): + expected_username = remote_access_username(config) + expected_password = remote_access_password(config) + provided_username = str(username or "").strip() + provided_password = str(password or "").strip() + return bool( + expected_username + and expected_password + and hmac.compare_digest(provided_username, expected_username) + and hmac.compare_digest(provided_password, expected_password) + ) + + +def remote_access_session_value(config=None): + return create_remote_access_session(config) + + +def remote_access_session_fingerprint(config=None): + username = remote_access_username(config) + password = remote_access_password(config) + if not username or not password: + return "" + return hashlib.sha256(f"{username}\0{password}".encode("utf-8")).hexdigest() + + +def _remote_access_session_token_digest(value): + return hashlib.sha256(str(value or "").strip().encode("utf-8")).hexdigest() + + +def _load_remote_access_sessions_locked(): + data = load_json(REMOTE_SESSIONS_PATH, {}) + if not isinstance(data, dict): + return {} + return data + + +def _write_remote_access_sessions_locked(data): + write_json(REMOTE_SESSIONS_PATH, data) + + +def _prune_remote_access_sessions_locked(data, now=None): + timestamp = int(now if now is not None else time.time()) + changed = False + for token_digest, payload in list(data.items()): + try: + expires_at = int(payload.get("expires_at") or 0) + except (AttributeError, TypeError, ValueError): + expires_at = 0 + if expires_at <= timestamp: + data.pop(token_digest, None) + changed = True + return changed + + +def create_remote_access_session(config=None, ttl_seconds=REMOTE_ACCESS_SESSION_TTL_SECONDS): + username = remote_access_username(config) + fingerprint = remote_access_session_fingerprint(config) + if not username or not fingerprint: + return "" + now = int(time.time()) + token = secrets.token_urlsafe(32) + payload = { + "username": username, + "fingerprint": fingerprint, + "created_at": now_iso(), + "expires_at": now + max(60, int(ttl_seconds or REMOTE_ACCESS_SESSION_TTL_SECONDS)), + } + with REMOTE_SESSION_STORE_LOCK: + sessions = _load_remote_access_sessions_locked() + _prune_remote_access_sessions_locked(sessions, now=now) + sessions[_remote_access_session_token_digest(token)] = payload + _write_remote_access_sessions_locked(sessions) + return token + + +def remote_access_session_matches(value, config=None): + provided = str(value or "").strip() + fingerprint = remote_access_session_fingerprint(config) + username = remote_access_username(config) + if not provided or not fingerprint or not username: + return False + token_digest = _remote_access_session_token_digest(provided) + with REMOTE_SESSION_STORE_LOCK: + sessions = _load_remote_access_sessions_locked() + changed = _prune_remote_access_sessions_locked(sessions) + payload = sessions.get(token_digest) + if changed: + _write_remote_access_sessions_locked(sessions) + if not isinstance(payload, dict): + return False + return bool( + hmac.compare_digest(str(payload.get("fingerprint") or ""), fingerprint) + and hmac.compare_digest(str(payload.get("username") or ""), username) + ) + + +def clear_remote_access_sessions(config=None): + expected_fingerprint = str(config.get("fingerprint") or "") if isinstance(config, dict) else "" + with REMOTE_SESSION_STORE_LOCK: + sessions = _load_remote_access_sessions_locked() + if expected_fingerprint: + filtered = { + token_digest: payload + for token_digest, payload in sessions.items() + if str((payload or {}).get("fingerprint") or "") != expected_fingerprint + } + else: + filtered = {} + if filtered == sessions: + return False + _write_remote_access_sessions_locked(filtered) + return True + + +def background_workers_enabled(): + return not env_flag("ANI_CLI_WEB_DISABLE_WORKER") + + +def job_stdout_enabled(): + return env_flag("ANI_CLI_WEB_JOB_STDOUT") + + +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"), + "anipy_cli_fallback_enabled": False, + "download_methods": ["kaizoku"], + "provider": "anikoto", + "watchlist_auto_refresh_enabled": False, + "watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES, + "watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS, + "auto_download_enabled": False, + "auto_download_mode": "dub", + "auto_download_quality": "best", + "jellyfin_sync_enabled": False, + "jellyfin_tv_dir": "", + "jellyfin_movie_dir": "", + "discord_webhook_url": "", + "discord_webhook_events": [], + "auth_username": str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip(), + "auth_password": str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip(), +} +PRIVATE_CONFIG_KEYS = {"auth_username", "auth_password"} + + +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 / ".kaizoku" + + +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") +REMOTE_SESSIONS_PATH = project_state_path("remote-sessions.json") +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): + source_has_download_methods = isinstance(data, dict) and "download_methods" in 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"]) + anipy_cli_fallback_enabled = config.get("anipy_cli_fallback_enabled") + download_methods = config.get("download_methods") + auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled") + auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes") + refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds") + auto_download_enabled = config.get("auto_download_enabled") + auto_download_mode = str(config.get("auto_download_mode", "dub")).lower() + auto_download_quality = str(config.get("auto_download_quality", "best")).lower() + if auto_download_quality.endswith("p") and auto_download_quality[:-1].isdigit(): + auto_download_quality = auto_download_quality[:-1] + jellyfin_sync_enabled = config.get("jellyfin_sync_enabled") + jellyfin_tv_dir = str(config.get("jellyfin_tv_dir") or "").strip() + jellyfin_movie_dir = str(config.get("jellyfin_movie_dir") or "").strip() + webhook_url = str(config.get("discord_webhook_url") or "").strip() + webhook_events = config.get("discord_webhook_events") + auth_username = str(config.get("auth_username") or "").strip() + auth_password = str(config.get("auth_password") or "").strip() + + if isinstance(anipy_cli_fallback_enabled, str): + anipy_cli_fallback_enabled = anipy_cli_fallback_enabled.strip().lower() in {"1", "true", "yes", "on"} + else: + anipy_cli_fallback_enabled = bool(anipy_cli_fallback_enabled) + if isinstance(download_methods, str): + method_values = [part.strip().lower() for part in re.split(r"[\s,]+", download_methods) if part.strip()] + elif isinstance(download_methods, list): + method_values = [str(part or "").strip().lower() for part in download_methods] + else: + method_values = [] + normalized_methods = [] + for method in method_values: + if method in DOWNLOAD_METHOD_CHOICES and method not in normalized_methods: + normalized_methods.append(method) + if not normalized_methods or not source_has_download_methods: + normalized_methods = ["kaizoku"] + if isinstance(auto_refresh_enabled, str): + auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"} + else: + auto_refresh_enabled = bool(auto_refresh_enabled) + if isinstance(auto_download_enabled, str): + auto_download_enabled = auto_download_enabled.strip().lower() in {"1", "true", "yes", "on"} + else: + auto_download_enabled = bool(auto_download_enabled) + if isinstance(jellyfin_sync_enabled, str): + jellyfin_sync_enabled = jellyfin_sync_enabled.strip().lower() in {"1", "true", "yes", "on"} + else: + jellyfin_sync_enabled = bool(jellyfin_sync_enabled) + + try: + auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES) + except (TypeError, ValueError): + auto_refresh_minutes = WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES + auto_refresh_minutes = max( + WATCHLIST_AUTO_REFRESH_MIN_MINUTES, + min(WATCHLIST_AUTO_REFRESH_MAX_MINUTES, auto_refresh_minutes), + ) + try: + refresh_delay_seconds = int(str(refresh_delay_seconds).strip() or WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS) + except (TypeError, ValueError): + refresh_delay_seconds = WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS + refresh_delay_seconds = max( + WATCHLIST_REFRESH_DELAY_MIN_SECONDS, + min(WATCHLIST_REFRESH_DELAY_MAX_SECONDS, refresh_delay_seconds), + ) + + 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()) + config["download_methods"] = ["kaizoku"] + config["anipy_cli_fallback_enabled"] = False + provider = str(config.get("provider") or DEFAULT_CONFIG.get("provider") or "anikoto").strip().lower() + config["provider"] = provider if provider in PROVIDER_CHOICES else "anikoto" + config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled + config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes + config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds + config["auto_download_enabled"] = auto_download_enabled + config["auto_download_mode"] = auto_download_mode if auto_download_mode in MODE_CHOICES else "dub" + config["auto_download_quality"] = auto_download_quality if auto_download_quality in QUALITY_CHOICES else "best" + config["jellyfin_sync_enabled"] = jellyfin_sync_enabled + config["jellyfin_tv_dir"] = str(Path(jellyfin_tv_dir).expanduser()) if jellyfin_tv_dir else "" + config["jellyfin_movie_dir"] = str(Path(jellyfin_movie_dir).expanduser()) if jellyfin_movie_dir else "" + if isinstance(webhook_events, str): + webhook_events = [part.strip() for part in webhook_events.split(",")] + elif not isinstance(webhook_events, list): + webhook_events = [] + normalized_events = [] + seen_events = set() + for event_name in webhook_events: + normalized_name = str(event_name or "").strip() + if normalized_name in DISCORD_WEBHOOK_EVENT_CHOICES and normalized_name not in seen_events: + seen_events.add(normalized_name) + normalized_events.append(normalized_name) + config["discord_webhook_url"] = webhook_url + config["discord_webhook_events"] = normalized_events + config["auth_username"] = auth_username + config["auth_password"] = auth_password + return config + + +def sanitize_public_config(config): + source = normalize_config(config or {}) + return {key: value for key, value in source.items() if key not in PRIVATE_CONFIG_KEYS} + + +def _env_remote_path_roots(): + raw = str(os.environ.get("ANI_CLI_WEB_REMOTE_PATH_ROOTS") or "").strip() + if not raw: + return [] + roots = [] + for part in raw.split(os.pathsep): + text = str(part or "").strip() + if not text: + continue + try: + resolved = Path(text).expanduser().resolve() + except OSError: + continue + if resolved not in roots: + roots.append(resolved) + return roots + + +def configured_remote_path_roots(config=None): + normalized = normalize_config(config or {}) + roots = list(_env_remote_path_roots()) + for raw_path in ( + normalized.get("download_dir"), + normalized.get("jellyfin_tv_dir"), + normalized.get("jellyfin_movie_dir"), + ): + text = str(raw_path or "").strip() + if not text: + continue + try: + resolved = Path(text).expanduser().resolve() + except OSError: + continue + if resolved not in roots: + roots.append(resolved) + return roots + + +def path_is_within_roots(path, roots): + try: + candidate = Path(path).expanduser().resolve() + except OSError: + return False + for root in roots or []: + try: + root_path = Path(root).expanduser().resolve() + except OSError: + continue + if candidate == root_path or root_path in candidate.parents: + return True + return False + + +def validate_discord_webhook_url(url): + text = str(url or "").strip() + if not text: + return "" + parsed = urlparse(text) + if parsed.scheme != "https": + raise ValueError("Discord webhook URL must use https.") + host = str(parsed.netloc or "").split("@", 1)[-1].split(":", 1)[0].strip().lower() + if host not in {"discord.com", "ptb.discord.com", "canary.discord.com", "discordapp.com"}: + raise ValueError("Discord webhook URL must point to an official Discord webhook host.") + if not str(parsed.path or "").startswith("/api/webhooks/"): + raise ValueError("Discord webhook URL must use the Discord /api/webhooks/ path.") + return text + + +def discord_webhook_enabled(config, event_name, force=False): + if not isinstance(config, dict): + return False + webhook_url = str(config.get("discord_webhook_url") or "").strip() + if not webhook_url: + return False + if force: + return True + return str(event_name or "").strip() in { + str(name).strip() for name in config.get("discord_webhook_events") or [] + } + + +def thumbnail_file_path(name): + filename = Path(str(name or "")).name + if not filename: + return None + project_path = THUMBNAIL_ROOT / filename + if project_path.exists(): + return project_path + legacy_path = LEGACY_THUMBNAIL_ROOT / filename + if legacy_path.exists(): + THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True) + try: + shutil.move(str(legacy_path), str(project_path)) + debug_log("thumbnail.cache.migrated", from_path=legacy_path, to_path=project_path) + return project_path + except OSError as exc: + debug_log("thumbnail.cache.migrate_failed", from_path=legacy_path, to_path=project_path, error=exc) + return legacy_path + return project_path + + +def _discord_file_part(boundary, field_name, attachment_name, content_type, payload): + safe_name = str(attachment_name or "attachment.bin").replace('"', "") + header = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{field_name}"; filename="{safe_name}"\r\n' + f"Content-Type: {content_type}\r\n\r\n" + ).encode("utf-8") + return header + payload + b"\r\n" + + +def _discord_text_part(boundary, field_name, value): + return ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{field_name}"\r\n\r\n' + f"{value}\r\n" + ).encode("utf-8") + + +def _post_discord_webhook(url, payload, attachments=None, timeout=10): + attachments = attachments or [] + headers = {"User-Agent": AGENT} + if attachments: + boundary = f"kaizoku-{uuid4().hex}" + body = bytearray() + body.extend(_discord_text_part(boundary, "payload_json", json.dumps(payload))) + for index, attachment in enumerate(attachments): + path = attachment.get("path") + if path is None or not Path(path).exists(): + continue + data = Path(path).read_bytes() + if not data: + continue + filename = str(attachment.get("filename") or Path(path).name or f"attachment-{index}") + content_type = str(attachment.get("content_type") or mimetypes.guess_type(filename)[0] or "application/octet-stream") + body.extend(_discord_file_part(boundary, f"files[{index}]", filename, content_type, data)) + body.extend(f"--{boundary}--\r\n".encode("utf-8")) + headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" + request_data = bytes(body) + else: + headers["Content-Type"] = "application/json" + request_data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=request_data, headers=headers, method="POST") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + response.read() + return getattr(response, "status", 204) + except urllib.error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace").strip() + if details: + raise RuntimeError(f"Discord webhook rejected the request: HTTP {exc.code} {details}") from exc + raise RuntimeError(f"Discord webhook rejected the request: HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Could not reach Discord webhook: {exc}") from exc + except TimeoutError as exc: + raise RuntimeError("Discord webhook request timed out") from exc + + +def _chunk_discord_lines(lines, max_chars=3800): + chunks = [] + current = [] + current_length = 0 + for raw_line in lines or []: + line = str(raw_line or "").strip() + if not line: + continue + added_length = len(line) + (1 if current else 0) + if current and current_length + added_length > max_chars: + chunks.append(current) + current = [line] + current_length = len(line) + continue + current.append(line) + current_length += added_length + if current: + chunks.append(current) + return chunks + + +def _post_discord_embeds_with_file_list(webhook_url, content, summary_embed, file_lines, attachments=None): + chunks = _chunk_discord_lines(file_lines) + if not chunks: + _post_discord_webhook(webhook_url, {"content": content, "embeds": [summary_embed]}, attachments=attachments) + return + total_chunks = len(chunks) + first_batch = chunks[:9] + request_embeds = [summary_embed] + for index, chunk in enumerate(first_batch, start=1): + request_embeds.append( + { + "title": f"Saved files ({index}/{total_chunks})", + "description": "\n".join(chunk)[:4096], + "color": summary_embed.get("color", 5763719), + } + ) + _post_discord_webhook(webhook_url, {"content": content, "embeds": request_embeds}, attachments=attachments) + sent_chunks = len(first_batch) + while sent_chunks < total_chunks: + batch = chunks[sent_chunks : sent_chunks + 10] + embeds = [] + for offset, chunk in enumerate(batch, start=1): + chunk_index = sent_chunks + offset + embeds.append( + { + "title": f"Saved files ({chunk_index}/{total_chunks})", + "description": "\n".join(chunk)[:4096], + "color": summary_embed.get("color", 5763719), + } + ) + _post_discord_webhook(webhook_url, {"content": content, "embeds": embeds}) + sent_chunks += len(batch) + + +def send_discord_webhook_event(config, event_name, payload=None, force=False): + normalized = normalize_config(config or {}) + event = str(event_name or "").strip() + if not discord_webhook_enabled(normalized, event, force=force): + return {"sent": False, "reason": "disabled"} + webhook_url = validate_discord_webhook_url(normalized["discord_webhook_url"]) + payload = payload or {} + + if event == "test": + request_payload = { + "content": "Kaizoku webhook test", + "embeds": [ + { + "title": "Webhook test", + "description": "The Discord webhook is configured and reachable from Kaizoku.", + "color": 5814783, + "footer": {"text": f"{APP_NAME} {VERSION}"}, + } + ], + } + _post_discord_webhook(webhook_url, request_payload) + return {"sent": True, "event": event} + + if event == "watchlist_refresh_new_episodes": + items = payload.get("items") if isinstance(payload.get("items"), list) else [] + if not items: + return {"sent": False, "reason": "no_items"} + embeds = [] + attachments = [] + for index, item in enumerate(items[:10]): + title = str(item.get("title") or item.get("show_id") or "Unknown anime").strip() + sub_count = int(item.get("sub_count") or 0) + dub_count = int(item.get("dub_count") or 0) + sub_delta = int(item.get("sub_delta") or 0) + dub_delta = int(item.get("dub_delta") or 0) + expected_count = int(item.get("expected_count") or 0) + airing_status = str(item.get("airing_status") or "").strip() + description_lines = [ + f"Sub: {sub_count}" + (f" (+{sub_delta})" if sub_delta > 0 else ""), + f"Dub: {dub_count}" + (f" (+{dub_delta})" if dub_delta > 0 else ""), + ] + if expected_count > 0: + description_lines.append(f"Expected episodes: {expected_count}") + if airing_status: + description_lines.append(f"Status: {airing_status}") + embed = { + "title": title[:256], + "description": "\n".join(description_lines)[:4096], + "color": 5763719, + } + thumb_path = thumbnail_file_path(item.get("thumbnail_path")) + if thumb_path and thumb_path.exists(): + attachment_name = f"thumb-{index}{thumb_path.suffix or '.jpg'}" + attachments.append({"path": thumb_path, "filename": attachment_name}) + embed["thumbnail"] = {"url": f"attachment://{attachment_name}"} + embeds.append(embed) + extra_count = max(0, len(items) - len(embeds)) + request_payload = { + "content": ( + f"Watchlist refresh found new episodes for {len(items)} anime." + + (f" Showing the first {len(embeds)} entries." if extra_count else "") + ), + "embeds": embeds, + } + _post_discord_webhook(webhook_url, request_payload, attachments=attachments) + return {"sent": True, "event": event, "count": len(items)} + + if event == "download_completed": + title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip() + moved_files = payload.get("moved_files") if isinstance(payload.get("moved_files"), list) else [] + description_lines = [] + if payload.get("mode"): + description_lines.append(f"Mode: {str(payload.get('mode')).strip()}") + if payload.get("episodes"): + description_lines.append(f"Episodes: {str(payload.get('episodes')).strip()}") + if payload.get("category"): + description_lines.append(f"Watchlist category: {str(payload.get('category')).strip()}") + if moved_files: + description_lines.append(f"Saved files: {len(moved_files)}") + embed = { + "title": title[:256], + "description": "\n".join(description_lines)[:4096] or "Download completed.", + "color": 5763719, + } + attachments = [] + thumb_path = thumbnail_file_path(payload.get("thumbnail_path")) + if thumb_path and thumb_path.exists(): + attachment_name = f"download-thumb{thumb_path.suffix or '.jpg'}" + attachments.append({"path": thumb_path, "filename": attachment_name}) + embed["thumbnail"] = {"url": f"attachment://{attachment_name}"} + _post_discord_embeds_with_file_list( + webhook_url, + "Kaizoku finished a download.", + embed, + moved_files, + attachments=attachments, + ) + return {"sent": True, "event": event} + + if event == "jellyfin_sync_success": + title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip() + moved_files = payload.get("moved_files") if isinstance(payload.get("moved_files"), list) else [] + description_lines = [ + f"Media type: {str(payload.get('media_type') or 'tv').strip()}", + f"Source: {str(payload.get('source') or 'manual').strip()}", + ] + if payload.get("target"): + description_lines.append(f"Target: {str(payload.get('target')).strip()}") + if moved_files: + description_lines.append(f"Moved files: {len(moved_files)}") + embed = { + "title": f"Jellyfin handoff: {title[:236]}", + "description": "\n".join(description_lines)[:4096], + "color": 5763719, + } + _post_discord_embeds_with_file_list( + webhook_url, + "Kaizoku moved a finished library into Jellyfin.", + embed, + moved_files, + ) + return {"sent": True, "event": event} + + if event == "jellyfin_sync_failed": + title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip() + reason = str(payload.get("reason") or "unknown").strip() + description_lines = [ + f"Reason: {reason}", + f"Media type: {str(payload.get('media_type') or 'tv').strip()}", + f"Source: {str(payload.get('source') or 'manual').strip()}", + ] + if payload.get("error"): + description_lines.append(f"Error: {str(payload.get('error')).strip()}") + if payload.get("source_path"): + description_lines.append(f"Source path: {str(payload.get('source_path')).strip()}") + if payload.get("target"): + description_lines.append(f"Target path: {str(payload.get('target')).strip()}") + request_payload = { + "content": "Kaizoku could not move a finished library into Jellyfin.", + "embeds": [ + { + "title": f"Jellyfin handoff failed: {title[:229]}", + "description": "\n".join(description_lines)[:4096], + "color": 15158332, + } + ], + } + _post_discord_webhook(webhook_url, request_payload) + return {"sent": True, "event": event} + + if event == "watchlist_refresh_failed": + error_lines = [] + for item in (payload.get("items") if isinstance(payload.get("items"), list) else [])[:8]: + title = str(item.get("title") or item.get("show_id") or "Unknown anime").strip() + error_text = str(item.get("error") or "").strip() + if title and error_text: + error_lines.append(f"{title}: {error_text}") + if payload.get("error") and not error_lines: + error_lines.append(str(payload["error"]).strip()) + request_payload = { + "content": "Watchlist refresh reported errors.", + "embeds": [ + { + "title": "Refresh errors", + "description": "\n".join(error_lines[:8])[:4096] or "The refresh completed with errors.", + "color": 15158332, + "fields": [ + {"name": "Completed", "value": str(int(payload.get("completed") or 0)), "inline": True}, + {"name": "Total", "value": str(int(payload.get("total") or 0)), "inline": True}, + {"name": "Errors", "value": str(int(payload.get("errors") or 0)), "inline": True}, + ], + "footer": {"text": f"Source: {str(payload.get('source') or 'manual')}"}, + } + ], + } + _post_discord_webhook(webhook_url, request_payload) + return {"sent": True, "event": event} + + if event == "watchlist_refresh_interrupted": + request_payload = { + "content": "Watchlist refresh was interrupted.", + "embeds": [ + { + "title": "Refresh interrupted", + "description": str(payload.get("message") or "The watchlist refresh did not finish.").strip()[:4096], + "color": 16763904, + "fields": [ + {"name": "Completed", "value": str(int(payload.get("completed") or 0)), "inline": True}, + {"name": "Total", "value": str(int(payload.get("total") or 0)), "inline": True}, + ], + "footer": {"text": f"Source: {str(payload.get('source') or 'manual')}"}, + } + ], + } + _post_discord_webhook(webhook_url, request_payload) + return {"sent": True, "event": event} + + if event == "runtime_error": + source = str(payload.get("source") or "runtime").strip() + error_text = str(payload.get("error") or "Unknown error").strip() + details = str(payload.get("details") or "").strip() + description = error_text if not details else f"{error_text}\n{details}" + request_payload = { + "content": "Kaizoku logged an unexpected error.", + "embeds": [ + { + "title": source[:256], + "description": description[:4096], + "color": 15158332, + } + ], + } + _post_discord_webhook(webhook_url, request_payload) + return {"sent": True, "event": event} + + raise ValueError(f"Unsupported Discord webhook event: {event}") + + +def strip_control(value): + return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n") + + +def cli_executable_exists(command): + text = str(command or "").strip() + if not text: + return False + return bool(shutil.which(text) or (Path(text).exists() and os.access(text, os.X_OK))) + + +def cli_executable_exists_any(*commands): + return any(cli_executable_exists(command) for command in commands) + + +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 normalize_episode_offset(value): + text = str(value or "").strip() + if not text: + return None + if not re.match(r"^[0-9]+$", text): + raise ValueError("Episode offset must be a positive number") + number = int(text, 10) + if number < 1: + raise ValueError("Episode offset must be a positive number") + return str(number) + + +def normalize_media_type(value, default="tv"): + media_type = str(value or default).strip().lower() + return media_type if media_type in MEDIA_TYPE_CHOICES else default + + +def job_output_dir(job): + anime_name = sanitize_path_component(job.get("anime_name") or job.get("title")) + media_type = normalize_media_type(job.get("media_type")) + library_root = Path(job["download_dir"]).expanduser() / media_type / anime_name + if media_type == "movie": + return library_root + return library_root / 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 apply_episode_offset(value, offset): + normalized_offset = normalize_episode_offset(offset) + text = str(value or "").strip() + if not normalized_offset or not text: + return text + match = re.match(r"^0*([0-9]+)(.*)$", text) + if not match: + return text + number = int(match.group(1) or "0", 10) + if number < 1: + return text + suffix = match.group(2) or "" + return f"{int(normalized_offset, 10) + number - 1}{suffix}" + + +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 episode_values_from_spec(value): + spec = str(value or "").strip() + if not spec: + return [] + values = [] + for part in re.split(r"\s+", spec): + if not part: + continue + if "-" in part: + start_text, end_text = [piece.strip() for piece in part.split("-", 1)] + if start_text.isdigit() and end_text.isdigit(): + start = int(start_text, 10) + end = int(end_text, 10) + if start <= end: + values.extend(str(number) for number in range(start, end + 1)) + continue + values.append(part) + return values + + +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")) + media_type = normalize_media_type(job.get("media_type")) + season = season_label(job) + episode_offset = normalize_episode_offset(job.get("episode_offset")) + target_dir.mkdir(parents=True, exist_ok=True) + files = sorted( + [path for path in staging_dir.rglob("*") if path.is_file()], + key=lambda path: (path.stat().st_mtime, str(path.relative_to(staging_dir)).lower()), + ) + if not files: + raise RuntimeError("No downloaded files were found in the staging folder") + + moved = [] + fallback_episode = 1 + requested_episode_values = episode_values_from_spec(job.get("episodes")) + backend = str(job.get("download_backend") or "").strip().lower() + for source in files: + if media_type == "movie": + final_name = f"{library_name}{source.suffix}" + else: + ep = None + if backend in {"anipy-cli", "animdl"} and requested_episode_values: + ep = requested_episode_values.pop(0) + if ep is None: + ep = extract_episode_from_filename(source) + if ep is None: + ep = str(fallback_episode) + fallback_episode += 1 + ep = apply_episode_offset(ep, episode_offset) + 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)) + + shutil.rmtree(staging_dir, ignore_errors=True) + 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") + + 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() + media_type = normalize_media_type(payload.get("media_type"), default="tv") + provider = str(payload.get("provider") or config.get("provider") or "").strip().lower() + if not provider and ":" in str(payload.get("show_id") or query): + provider = str(payload.get("show_id") or query).split(":", 1)[0].strip().lower() + if provider not in PROVIDER_CHOICES: + provider = "anikoto" + 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, + "media_type": media_type, + "season": normalize_season(payload.get("season")), + "episode_offset": normalize_episode_offset(payload.get("episode_offset")), + "query": query, + "provider": provider, + "result_index": normalize_result_index(payload.get("result_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 normalize_result_index(value): + if value in (None, ""): + return None + try: + index = int(str(value).strip()) + except (TypeError, ValueError): + return None + if index < 1: + return None + return index + + +def anipy_search_spec(job): + query = re.sub(r"\s+", " ", str(job.get("query") or "").replace(":", " ")).strip() + mode = "dub" if str(job.get("mode") or "").strip().lower() == "dub" else "sub" + return f"{query}:{job['episodes']}:{mode}" + + +def command_for_job(job, backend="kaizoku", download_path=None): + show_id = str(job.get("show_id") or job.get("query") or "").strip() + provider = str(job.get("provider") or "").strip().lower() + if not provider and ":" in show_id: + provider = show_id.split(":", 1)[0].strip().lower() + if provider not in PROVIDER_CHOICES: + provider = "anikoto" + command = [ + "python3", + str(PROJECT_ROOT / "provider_downloader.py"), + "--provider", + provider, + "--show-id", + show_id, + "--title", + str(job.get("anime_name") or job.get("title") or job.get("query") or "Anime"), + "--episodes", + str(job.get("episodes") or "1"), + "--mode", + str(job.get("mode") or "sub"), + "--quality", + str(job.get("quality") or "best"), + "--output-dir", + str(download_path or job_staging_dir(job)), + ] + return command diff --git a/config_page.py b/config_page.py new file mode 100644 index 0000000..8f4a06f --- /dev/null +++ b/config_page.py @@ -0,0 +1,1151 @@ +#!/usr/bin/env python3 + +"""Config page template for Kaizoku.""" + +from app_support import DISCORD_WEBHOOK_EVENT_CHOICES, DISCORD_WEBHOOK_EVENT_LABELS +from template_helpers import BROWSER_API_HELPER, render_page_links, render_sidebar_brand + + +WEBHOOK_EVENT_OPTIONS = "\n".join( + ( + f' " + ) + for event_name in DISCORD_WEBHOOK_EVENT_CHOICES +) + + +CONFIG_HTML = r""" + + + + + Kaizoku - config + + + + +
+ + +
+
+
+
+
+
+
+

Defaults

+

Manage the folder, mode, and quality that the Search page starts with.

+
+ +
+
+ +
+
+ + +
+
Tip: these are the saved defaults only. The active search form can still be adjusted per download.
+
+ +
+
+
+

Provider

+

Choose the default source used for search, watchlist refresh, and auto-downloads.

+
+
+ +
Search results and watchlist entries store their provider in the show ID, so existing tracked anime keep using their original source.
+
+ +
+
+
+

Watchlist schedule

+

Periodically queue a background watchlist refresh to check for newly available episodes.

+
+
+
+ + + +
+
When enabled, the server periodically starts the existing background “Refresh All” job using this interval. Bulk refreshes now wait this many seconds between shows to avoid flooding upstream sources.
+
+ +
+
+
+

Auto-download

+

Automatically queue newly discovered episodes for shows in the `Watching` category after manual or scheduled refreshes.

+
+
+
+ + + +
+
Adding a new watchlist entry does not auto-download anything. Auto-download only reacts to later manual refreshes or scheduled refreshes that find new episodes.
+
+ +
+
+
+

Jellyfin handoff

+

Move fully completed anime libraries from the default download area into the folders that Jellyfin scans.

+
+ +
+
+ + + +
+
TV entries move only after the show is marked `Finished` and at least one downloaded language covers the expected episode count. Movie entries move after they are downloaded. The manual trigger scans the current watchlist and moves anything already eligible.
+
No recent Jellyfin handoff job.
+
+ +
+
+
+

Watchlist filesystem sync

+

Manually reconcile downloaded episode flags with the files currently present under the download library.

+
+ +
+
This scans the configured download library, removes downloaded episode flags for files that no longer exist, and adds flags for episodes already present on disk.
+
No watchlist filesystem sync run yet.
+
+ +
+
+
+

Watchlist backup

+

Export or restore a complete watchlist database snapshot, including removed-show records.

+
+
+ + +
+
+ +
Import replaces the current watchlist with the selected backup file.
+
No watchlist backup action run yet.
+
+ +
+
+
+

Discord notifications

+

Send webhook notifications for refresh discoveries, refresh problems, and unexpected runtime errors.

+
+ +
+
+ +
The new-episodes notification includes one card per anime, with local cached thumbnails attached to the webhook when available.
+
+
+ +
+""" + WEBHOOK_EVENT_OPTIONS + r""" +
+
+
+
+
+
+ + + + + + + + +""" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..65f4388 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,23 @@ +services: + kaizoku: + build: + context: . + dockerfile: Dockerfile + image: kaizoku:latest + container_name: kaizoku + restart: unless-stopped + ports: + - "${ANI_CLI_WEB_PORT:-8421}:8421" + environment: + USER_UID: ${USER_UID:-} + USER_GID: ${USER_GID:-} + ANI_CLI_WEB_HOST: 0.0.0.0 + ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421} + ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false} + ANI_CLI_WEB_AUTH_USERNAME: ${ANI_CLI_WEB_AUTH_USERNAME:-} + ANI_CLI_WEB_AUTH_PASSWORD: ${ANI_CLI_WEB_AUTH_PASSWORD:-} + ANI_CLI_WEB_JOB_STDOUT: ${ANI_CLI_WEB_JOB_STDOUT:-true} + ANI_CLI_DOWNLOAD_DIR: /downloads + volumes: + - ./downloads:/downloads + - ./.kaizoku:/app/.kaizoku diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..d2aece8 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +is_true() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1 | true | yes | on) return 0 ;; + *) return 1 ;; + esac +} + +is_integer() { + case "${1:-}" in + '' | *[!0-9]*) return 1 ;; + *) return 0 ;; + esac +} + +run_as_requested_user() { + uid="${USER_UID:-}" + gid="${USER_GID:-}" + + if [ -z "$uid" ] && [ -z "$gid" ]; then + return 1 + fi + + if [ -z "$uid" ] || [ -z "$gid" ]; then + echo "Both USER_UID and USER_GID must be set together." >&2 + exit 1 + fi + + if ! is_integer "$uid" || ! is_integer "$gid"; then + echo "USER_UID and USER_GID must be numeric." >&2 + exit 1 + fi + + mkdir -p /app/.kaizoku/home + chown "$uid:$gid" /downloads /app/.kaizoku /app/.kaizoku/home + + cd /app + exec setpriv \ + --reuid "$uid" \ + --regid "$gid" \ + --clear-groups \ + env HOME=/app/.kaizoku/home python3 /app/app.py "$@" +} + +mkdir -p /downloads /app/.kaizoku + +run_as_requested_user "$@" || true + +cd /app +exec python3 /app/app.py "$@" diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000..9f2653e Binary files /dev/null and b/favicon.png differ diff --git a/http_handler.py b/http_handler.py new file mode 100644 index 0000000..b52255d --- /dev/null +++ b/http_handler.py @@ -0,0 +1,1067 @@ +#!/usr/bin/env python3 + +"""HTTP handler and route wiring for Kaizoku.""" + +import json +import mimetypes +import os +import shutil +import subprocess +import traceback +from base64 import b64decode +from binascii import Error as BinasciiError +from dataclasses import dataclass +from functools import lru_cache +from http import HTTPStatus +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler +from html import escape as html_escape +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse + +import provider_bridge + +from app_support import ( + APP_NAME, + CHANGELOG_FILE, + CLIENT_DISCONNECT_ERRORS, + MAX_JSON_BODY_BYTES, + MODE_CHOICES, + PROJECT_ROOT, + VERSION, + client_address_is_local, + configured_remote_path_roots, + debug_enabled, + debug_log, + load_project_text_file, + cli_executable_exists_any, + cli_executable_exists, + normalize_config, + path_is_within_roots, + remote_access_allowed, + remote_access_credentials_configured, + remote_access_credentials_match, + remote_access_session_matches, + remote_access_session_value, + sanitize_public_config, + send_discord_webhook_event, + validate_discord_webhook_url, +) + +FAVICON_PATH = PROJECT_ROOT / "favicon.png" + +@dataclass(frozen=True) +class HandlerContext: + add_to_watchlist: object + config_html: str + download_watchlist_item: object + ensure_runtime: object + episode_list: object + export_watchlist_backup: object + get_config_snapshot: object + get_jellyfin_sync_status: object + get_watchlist_homepage_summary: object + get_watchlist: object + get_watchlist_refresh_status: object + http_error: object + index_html: str + import_watchlist_backup: object + queue_html: str + reconcile_watchlist_downloaded_files: object + remove_from_watchlist: object + run_jellyfin_sync: object + runtime_state: object + save_runtime_config: object + search_anime: object + resolve_search_thumbnail: object + start_jellyfin_sync: object + test_discord_webhook_config: object + thumbnail_file_path: object + update_all_watchlist_statuses: object + update_watchlist_auto_download: object + update_watchlist_category: object + update_watchlist_media_type: 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 = ["node", "python3", "ffmpeg"] + result = {name: bool(shutil.which(name)) for name in checks} + result["provider-bridge"] = provider_bridge.BRIDGE.exists() + return result + + +def browse_filesystem(path_value="", mode="dir", allowed_roots=None): + raw_mode = str(mode or "dir").strip().lower() + browse_mode = raw_mode if raw_mode in {"dir", "file"} else "dir" + raw_path = str(path_value or "").strip() + if raw_path: + base_path = Path(raw_path).expanduser() + elif allowed_roots: + base_path = allowed_roots[0] + else: + base_path = Path.home() + try: + resolved = base_path.resolve() + except OSError as exc: + raise ValueError(f"Could not access path: {exc}") from exc + if not resolved.exists(): + raise ValueError("Selected path does not exist.") + if resolved.is_file(): + resolved = resolved.parent + if not resolved.is_dir(): + raise ValueError("Selected path is not a directory.") + if allowed_roots and not path_is_within_roots(resolved, allowed_roots): + raise PermissionError("That path is outside the allowed remote browse roots.") + + entries = [] + try: + for child in resolved.iterdir(): + try: + is_dir = child.is_dir() + except OSError: + continue + if allowed_roots and not path_is_within_roots(child, allowed_roots): + continue + if browse_mode == "dir" and not is_dir: + continue + entries.append( + { + "name": child.name, + "path": str(child), + "is_dir": is_dir, + } + ) + except OSError as exc: + raise ValueError(f"Could not list directory: {exc}") from exc + + entries.sort(key=lambda item: (not item["is_dir"], item["name"].lower(), item["name"])) + parent_path = str(resolved.parent if resolved.parent != resolved else resolved) + if allowed_roots and not path_is_within_roots(parent_path, allowed_roots): + parent_path = str(resolved) + root_paths = [str(Path(root)) for root in (allowed_roots or [])] + return { + "path": str(resolved), + "parent_path": parent_path, + "home_path": str(allowed_roots[0] if allowed_roots else Path.home()), + "root_paths": root_paths, + "mode": browse_mode, + "entries": entries, + } + + +def queue_list_payload(runtime, page=1, per_page=10): + page = max(1, int(page or 1)) + per_page = min(50, max(1, int(per_page or 10))) + download_queue = runtime["download_queue"] + jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") + download_payload = download_queue.list(page=1, per_page=50) + jobs = list(download_payload.get("jobs") or []) + for download_page in range(2, int(download_payload.get("pages") or 1) + 1): + jobs.extend(download_queue.list(page=download_page, per_page=50).get("jobs") or []) + if jellyfin_jobs is not None: + jobs.extend(jellyfin_jobs.list_queue_jobs(limit=200)) + jobs.sort(key=lambda job: str(job.get("created_at") or ""), reverse=True) + total = len(jobs) + pages = max(1, (total + per_page - 1) // per_page) + offset = (page - 1) * per_page + return { + "jobs": jobs[offset : offset + per_page], + "page": page, + "per_page": per_page, + "total": total, + "pages": pages, + } + + +def queue_get_job(runtime, job_id): + try: + return runtime["download_queue"].get(job_id) + except KeyError: + jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") + if jellyfin_jobs is None: + raise + return jellyfin_jobs.get_queue_job(job_id) + + +def queue_remove_job(runtime, job_id): + try: + return runtime["download_queue"].remove(job_id) + except KeyError: + jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") + if jellyfin_jobs is None: + raise + return jellyfin_jobs.remove_queue_job(job_id) + + +def queue_clear_finished(runtime): + result = runtime["download_queue"].clear_finished() + jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") + if jellyfin_jobs is not None: + result["count"] = int(result.get("count") or 0) + int(jellyfin_jobs.clear_finished_queue_jobs().get("count") or 0) + return result + + +def queue_clear_failed(runtime): + result = runtime["download_queue"].clear_failed() + jellyfin_jobs = runtime.get("watchlist_jellyfin_sync") + if jellyfin_jobs is not None: + result["count"] = int(result.get("count") or 0) + int(jellyfin_jobs.clear_failed_queue_jobs().get("count") or 0) + return result + + +@lru_cache(maxsize=1) +def installed_ani_cli_version(): + return "Disregarded" + + +@lru_cache(maxsize=1) +def installed_anipy_cli_version(): + return "Disregarded" + + +@lru_cache(maxsize=1) +def installed_animdl_version(): + return "Disregarded" + + +class Handler(BaseHTTPRequestHandler): + server_version = "Kaizoku/0.1" + remote_session_cookie_name = "ani_cli_web_session" + remote_session_cookie_max_age_seconds = 30 * 24 * 60 * 60 + quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status", "/api/config/jellyfin/sync-status"} + + 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 _authorization_header(self): + return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip() + + def _host_header(self): + host = str(self.headers.get("X-Forwarded-Host") or self.headers.get("x-forwarded-host") or "").strip() + if host: + return host.split(",", 1)[0].strip() + return str(self.headers.get("Host") or self.headers.get("host") or "").strip() + + def _origin(self): + return str(self.headers.get("Origin") or self.headers.get("origin") or "").strip() + + def _content_type(self): + return str(self.headers.get("Content-Type") or self.headers.get("content-type") or "").strip() + + def _request_is_secure(self): + forwarded_proto = str(self.headers.get("X-Forwarded-Proto") or self.headers.get("x-forwarded-proto") or "").strip() + if forwarded_proto: + return forwarded_proto.split(",", 1)[0].strip().lower() == "https" + forwarded = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip() + if forwarded: + for part in forwarded.split(";"): + key, _, value = part.partition("=") + if key.strip().lower() == "proto": + return value.strip().strip('"').lower() == "https" + return False + + def _origin_matches_host(self): + origin = Handler._origin(self) + if not origin: + return False + parsed = urlparse(origin) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return False + return parsed.netloc.lower() == Handler._host_header(self).lower() + + def _basic_auth_credentials(self): + header = Handler._authorization_header(self) + if not header.startswith("Basic "): + return ("", "") + encoded = header[len("Basic ") :].strip() + if not encoded: + return ("", "") + try: + decoded = b64decode(encoded.encode("ascii"), validate=True).decode("utf-8") + except (BinasciiError, UnicodeDecodeError, ValueError): + return ("", "") + username, separator, password = decoded.partition(":") + if not separator: + return ("", "") + return (username.strip(), password.strip()) + + def _bearer_token(self): + header = str(self.headers.get("Authorization") or "").strip() + if not header.startswith("Bearer "): + return "" + return header[len("Bearer ") :].strip() + + def _cookie_value(self, name): + raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip() + if not raw: + return "" + try: + cookie = SimpleCookie() + cookie.load(raw) + except Exception: + return "" + morsel = cookie.get(name) + if not morsel: + return "" + return morsel.value.strip() + + def _session_cookie(self): + return Handler._cookie_value(self, Handler.remote_session_cookie_name) + + def _remote_credentials_valid(self): + session = Handler._session_cookie(self) + if session and remote_access_session_matches(session): + return True + username, password = Handler._basic_auth_credentials(self) + return remote_access_credentials_match(username, password) + + def _normalized_next_path(self, value): + raw = str(value or "").strip() + if not raw: + return "/" + parsed = urlparse(raw) + if parsed.scheme or parsed.netloc: + return "/" + path = parsed.path or "/" + if not path.startswith("/"): + return "/" + if path == "/auth/login": + path = "/" + query = parsed.query or "" + return urlunparse(("", "", path, "", query, "")) + + def _current_path_without_auth_query(self, parsed): + params = parse_qs(parsed.query, keep_blank_values=True) + params.pop("token", None) + params.pop("remote_token", None) + query = urlencode(params, doseq=True) + return urlunparse(("", "", parsed.path or "/", "", query, "")) + + def _remote_auth_cookie(self, session_value): + attributes = ( + f"{Handler.remote_session_cookie_name}={session_value}; " + f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}" + ) + if Handler._request_is_secure(self): + attributes += "; Secure" + return attributes + + def _clear_remote_auth_cookie(self): + attributes = ( + f"{Handler.remote_session_cookie_name}=; " + "Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + ) + if Handler._request_is_secure(self): + attributes += "; Secure" + return attributes + + def _set_remote_auth_cookie_and_redirect(self, session_value, location): + Handler.write_response_bytes( + self, + b"", + HTTPStatus.SEE_OTHER, + { + "Location": Handler._normalized_next_path(self, location), + "Set-Cookie": Handler._remote_auth_cookie(self, session_value), + }, + ) + + def _is_browser_page_request(self, parsed): + if getattr(self, "command", "") != "GET": + return False + return not str(parsed.path or "").startswith("/api/") + + def _render_remote_login_page(self, parsed, message=""): + next_path = Handler._current_path_without_auth_query(self, parsed) + error_html = "" + if message: + error_html = f'

{html_escape(message)}

' + html = f""" + + + + + {APP_NAME} sign in + + + + +
+
+

{APP_NAME}

+

Sign in with the configured username and password. After that, this browser keeps a persistent auth session cookie.

+
+ {error_html} +
+ + + + +
+

API clients can also use Authorization: Basic <base64(username:password)>.

+
+ + +""" + Handler.write_response_bytes( + self, + html.encode("utf-8"), + HTTPStatus.UNAUTHORIZED, + {"Content-Type": "text/html; charset=utf-8"}, + ) + return True + + 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_credentials_configured(): + raise PermissionError( + "Remote access requires ANI_CLI_WEB_AUTH_USERNAME and ANI_CLI_WEB_AUTH_PASSWORD for non-local clients." + ) + if not Handler._remote_credentials_valid(self): + raise PermissionError("Remote username or password missing or invalid.") + + def enforce_same_origin(self): + origin = Handler._origin(self) + if not origin: + if Handler._session_cookie(self): + raise PermissionError("Browser API requests must include a same-origin Origin header.") + return + if not Handler._origin_matches_host(self): + raise PermissionError("Cross-origin browser requests are not allowed.") + + def _remote_path_roots(self): + return configured_remote_path_roots(Handler._context(self).get_config_snapshot()) + + def validate_remote_path(self, path_value, label): + client_host = Handler._effective_client_host(self) + if client_address_is_local(client_host): + return + roots = Handler._remote_path_roots(self) + if not roots: + raise PermissionError(f"Remote {label} changes are disabled until a server-side path root is configured.") + if not path_is_within_roots(path_value, roots): + raise PermissionError(f"Remote {label} must stay inside the configured remote path roots.") + + def do_GET(self): + parsed = urlparse(self.path) + try: + if parsed.path == "/auth/login": + if Handler._session_cookie(self) and remote_access_session_matches(Handler._session_cookie(self)): + params = parse_qs(parsed.query, keep_blank_values=True) + next_path = (params.get("next") or ["/"])[0] + Handler._set_remote_auth_cookie_and_redirect(self, Handler._session_cookie(self), next_path) + return + if remote_access_allowed() and remote_access_credentials_configured(): + Handler._render_remote_login_page(self, parsed) + return + self.ensure_client_access() + if parsed.path == "/": + self.html(Handler._context(self).index_html) + elif parsed.path in {"/favicon.png", "/favicon.ico"}: + if not FAVICON_PATH.exists(): + self.error(HTTPStatus.NOT_FOUND, "Favicon not found") + return + self.file(FAVICON_PATH) + elif parsed.path == "/queue": + self.html(Handler._context(self).queue_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(sanitize_public_config(Handler._context(self).get_config_snapshot())) + elif parsed.path == "/api/version": + self.json( + { + "name": APP_NAME, + "version": VERSION, + "ani_cli_version": installed_ani_cli_version(), + "anipy_cli_version": installed_anipy_cli_version(), + "animdl_version": installed_animdl_version(), + "providers": list(provider_bridge.PROVIDERS), + } + ) + elif parsed.path == "/api/changelog": + self.json({"content": load_project_text_file(CHANGELOG_FILE, default="Changelog unavailable.")}) + elif parsed.path == "/api/dependencies": + self.json(dependency_status()) + elif parsed.path == "/api/fs/browse": + params = parse_qs(parsed.query) + client_host = Handler._effective_client_host(self) + allowed_roots = None if client_address_is_local(client_host) else Handler._remote_path_roots(self) + self.json( + browse_filesystem( + (params.get("path") or [""])[0], + (params.get("mode") or ["dir"])[0], + allowed_roots=allowed_roots, + ) + ) + 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 == "/api/search/thumb": + Handler._runtime(self) + params = parse_qs(parsed.query) + title = (params.get("title") or [""])[0].strip() + if not title: + raise ValueError("Title is required") + path = Handler._context(self).resolve_search_thumbnail(title) + self.file(path) + 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(queue_list_payload(runtime, page=page, per_page=per_page)) + elif parsed.path.startswith("/api/queue/"): + runtime = Handler._runtime(self) + parts = parsed.path.strip("/").split("/") + if len(parts) != 3: + self.error(HTTPStatus.NOT_FOUND, "Not found") + return + _, _, job_id = parts + self.json(queue_get_job(runtime, job_id)) + 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 == "/api/watchlist/homepage": + Handler._runtime(self) + self.json(Handler._context(self).get_watchlist_homepage_summary()) + 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()) + elif parsed.path == "/api/config/jellyfin/sync-status": + Handler._runtime(self) + self.json(Handler._context(self).get_jellyfin_sync_status()) + elif parsed.path == "/api/config/watchlist/export": + Handler._runtime(self) + self.json_attachment( + Handler._context(self).export_watchlist_backup(), + "kaizoku-watchlist-backup.json", + ) + else: + self.error(HTTPStatus.NOT_FOUND, "Not found") + except Exception as exc: + self.exception(exc) + + def do_POST(self): + parsed = urlparse(self.path) + try: + if parsed.path == "/auth/login": + if not remote_access_allowed() or not remote_access_credentials_configured(): + raise PermissionError("Remote access sign-in is not available.") + payload = Handler.body_form(self) + username = str(payload.get("username") or "").strip() + password = str(payload.get("password") or "").strip() + next_path = payload.get("next") or "/" + if not remote_access_credentials_match(username, password): + Handler._render_remote_login_page( + self, + urlparse(Handler._normalized_next_path(self, next_path)), + "Username or password missing or invalid.", + ) + return + Handler._set_remote_auth_cookie_and_redirect(self, remote_access_session_value(), next_path) + return + self.ensure_client_access() + Handler.enforce_same_origin(self) + if parsed.path == "/api/config": + Handler.update_config(self, Handler.require_json_object(self, self.body_json())) + elif parsed.path == "/api/config/jellyfin/sync": + payload = Handler.require_json_object(self, self.body_json()) + current = Handler._context(self).get_config_snapshot() + merged = dict(current) + merged.update(payload) + for key, label in ( + ("download_dir", "download directory"), + ("jellyfin_tv_dir", "Jellyfin TV directory"), + ("jellyfin_movie_dir", "Jellyfin movie directory"), + ): + if str(merged.get(key) or "").strip(): + Handler.validate_remote_path(self, merged[key], label) + self.json(Handler._context(self).start_jellyfin_sync(merged), HTTPStatus.ACCEPTED) + elif parsed.path == "/api/config/watchlist/reconcile-downloads": + payload = Handler.require_json_object(self, self.body_json()) + current = Handler._context(self).get_config_snapshot() + merged = dict(current) + merged.update(payload) + if str(merged.get("download_dir") or "").strip(): + Handler.validate_remote_path(self, merged["download_dir"], "download directory") + self.json(Handler._context(self).reconcile_watchlist_downloaded_files(merged)) + elif parsed.path == "/api/config/watchlist/import": + payload = Handler.require_json_object(self, self.body_json()) + self.json(Handler._context(self).import_watchlist_backup(payload)) + elif parsed.path == "/api/config/webhook/test": + payload = Handler.require_json_object(self, self.body_json()) + validate_discord_webhook_url(payload.get("discord_webhook_url")) + self.json(Handler._context(self).test_discord_webhook_config(payload)) + elif parsed.path == "/api/queue": + runtime = Handler._runtime(self) + payload = Handler.require_json_object(self, self.body_json()) + if str(payload.get("download_dir") or "").strip(): + Handler.validate_remote_path(self, payload["download_dir"], "download directory") + self.json(runtime["download_queue"].add(payload), 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(queue_clear_finished(runtime)) + elif parsed.path == "/api/queue/clear-failed": + runtime = Handler._runtime(self) + self.json(queue_clear_failed(runtime)) + 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": lambda job_id: queue_remove_job(runtime, job_id), + } + 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) + result = Handler._context(self).add_to_watchlist(Handler.require_json_object(self, self.body_json())) + status = HTTPStatus.CREATED if result.get("created") else HTTPStatus.OK + self.json(result, status) + elif parsed.path == "/api/watchlist/ensure-thumbnails": + runtime = Handler._runtime(self) + payload = Handler.require_json_object(self, self.body_json()) + self.json( + runtime["watchlist"].ensure_thumbnails( + Handler.optional_list_field(self, payload, "show_ids") or [], + limit=payload.get("limit", 6), + force=Handler.optional_bool_field(self, payload, "force", default=False), + ) + ) + elif parsed.path == "/api/watchlist/update-status": + Handler._runtime(self) + payload = Handler.require_fields(self, self.body_json(), "show_id") + self.json(Handler._context(self).update_watchlist_status(payload["show_id"], payload.get("mode"))) + elif parsed.path == "/api/watchlist/download-all": + Handler._runtime(self) + payload = Handler.require_fields(self, self.body_json(), "show_id", "mode") + self.json(Handler._context(self).download_watchlist_item(payload["show_id"], payload["mode"]), HTTPStatus.CREATED) + elif parsed.path == "/api/watchlist/update-category": + Handler._runtime(self) + payload = Handler.require_fields(self, self.body_json(), "show_id") + self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category"))) + elif parsed.path == "/api/watchlist/update-media-type": + Handler._runtime(self) + payload = Handler.require_fields(self, self.body_json(), "show_id") + self.json(Handler._context(self).update_watchlist_media_type(payload["show_id"], payload.get("media_type"))) + elif parsed.path == "/api/watchlist/update-auto-download": + Handler._runtime(self) + payload = Handler.require_fields(self, self.body_json(), "show_id") + self.json( + Handler._context(self).update_watchlist_auto_download( + payload["show_id"], + payload.get("mode"), + payload.get("quality"), + payload.get("name"), + payload.get("source_name"), + payload.get("series"), + payload.get("episode_offset"), + ) + ) + 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 = Handler.require_fields(self, self.body_json(), "show_id") + self.json(Handler._context(self).remove_from_watchlist(payload["show_id"])) + elif parsed.path == "/api/watchlist/upload-thumbnail": + Handler._runtime(self) + payload = Handler.require_fields(self, self.body_json(), "show_id", "data_url") + 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): + payload = Handler.require_json_object(self, payload) + current = Handler._context(self).get_config_snapshot() + merged = dict(current) + merged.update(payload) + config = normalize_config(merged) + for key, label in ( + ("download_dir", "download directory"), + ("jellyfin_tv_dir", "Jellyfin TV directory"), + ("jellyfin_movie_dir", "Jellyfin movie directory"), + ): + if str(config.get(key) or "").strip(): + Handler.validate_remote_path(self, config[key], label) + if "discord_webhook_url" in payload and str(config.get("discord_webhook_url") or "").strip(): + validate_discord_webhook_url(config["discord_webhook_url"]) + Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True) + context = Handler._context(self) + context.save_runtime_config(payload) + self.json(sanitize_public_config(context.get_config_snapshot())) + + def require_fields(self, payload, *names): + payload = Handler.require_json_object(self, payload) + missing = [name for name in names if not str(payload.get(name) or "").strip()] + if missing: + if len(missing) == 1: + raise ValueError(f"Missing required field: {missing[0]}") + raise ValueError(f"Missing required fields: {', '.join(missing)}") + return payload + + def require_json_object(self, payload): + if not isinstance(payload, dict): + raise ValueError("Expected a JSON object") + return payload + + def optional_list_field(self, payload, name): + payload = Handler.require_json_object(self, payload) + if name not in payload or payload.get(name) is None: + return None + value = payload.get(name) + if not isinstance(value, list): + raise ValueError(f"Field '{name}' must be a JSON array") + return value + + def optional_bool_field(self, payload, name, default=False): + payload = Handler.require_json_object(self, payload) + if name not in payload or payload.get(name) is None: + return default + value = payload.get(name) + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ValueError(f"Field '{name}' must be a boolean") + + def body_json(self): + content_type = Handler._content_type(self).split(";", 1)[0].strip().lower() + if content_type != "application/json": + raise ValueError("Request body must use Content-Type: application/json.") + 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 body_form(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"" + try: + raw = raw_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("Request body must be valid UTF-8 form data.") from exc + params = parse_qs(raw, keep_blank_values=True) + return {key: values[-1] if values else "" for key, values in params.items()} + + 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 json_attachment(self, payload, filename, status=HTTPStatus.OK): + data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + safe_filename = str(filename or "watchlist-backup.json").replace('"', "") + self.write_response_bytes( + data, + status, + { + "Content-Type": "application/json", + "Content-Disposition": f'attachment; filename="{safe_filename}"', + "Cache-Control": "no-store", + }, + ) + + 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): + parsed = urlparse(getattr(self, "path", "") or "/") + if remote_access_allowed() and remote_access_credentials_configured() and Handler._is_browser_page_request(self, parsed): + Handler._render_remote_login_page(self, parsed, str(exc)) + else: + 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) + try: + send_discord_webhook_event( + Handler._context(self).get_config_snapshot(), + "runtime_error", + { + "source": "http_handler", + "error": str(exc), + "details": f"path={getattr(self, 'path', '')}", + }, + ) + except Exception as notify_exc: + debug_log("discord_webhook.runtime_error_failed", source="http_handler", error=notify_exc) + self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.") + + def log_message(self, fmt, *args): + parsed = urlparse(getattr(self, "path", "") or "") + if self.command == "GET" and parsed.path in Handler.quiet_poll_paths: + return + 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 diff --git a/kaizoku b/kaizoku new file mode 100755 index 0000000..b854b93 --- /dev/null +++ b/kaizoku @@ -0,0 +1,3 @@ +#!/bin/sh +cd "$(dirname "$0")" || exit 1 +exec python3 app.py "$@" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3484b84 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,644 @@ +{ + "name": "kaizoku-provider-bridge", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kaizoku-provider-bridge", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.9", + "cheerio": "^1.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..504b4c1 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "kaizoku-provider-bridge", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "description": "Local provider bridge for Kaizoku anime metadata and stream resolution.", + "dependencies": { + "axios": "^1.7.9", + "cheerio": "^1.0.0" + } +} diff --git a/provider_bridge.py b/provider_bridge.py new file mode 100644 index 0000000..079cb3d --- /dev/null +++ b/provider_bridge.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +"""Python helpers for Kaizoku's Node provider bridge.""" + +import json +import subprocess +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent +BRIDGE = PROJECT_ROOT / "providers" / "bridge.js" +PROVIDERS = ("anikoto", "anineko", "pahe") + + +def parse_provider_id(value, default_provider="anikoto"): + text = str(value or "").strip() + if ":" in text: + provider, provider_id = text.split(":", 1) + provider = provider.strip().lower() + if provider in PROVIDERS: + return provider, provider_id.strip() + provider = str(default_provider or "anikoto").strip().lower() + return provider if provider in PROVIDERS else "anikoto", text + + +def bridge(command, provider, *args): + normalized = str(provider or "anikoto").strip().lower() + if normalized not in PROVIDERS: + normalized = "anikoto" + proc = subprocess.run( + ["node", str(BRIDGE), command, normalized, *[str(arg) for arg in args]], + cwd=PROJECT_ROOT, + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError((proc.stderr or proc.stdout or f"Provider command {command} failed").strip()) + try: + return json.loads(proc.stdout or "{}") + except json.JSONDecodeError as exc: + raise RuntimeError(f"Provider bridge returned invalid JSON: {exc}") from exc + + +def search(query, provider="anikoto", page=1): + return bridge("search", provider, query, page) + + +def info(show_id, provider="anikoto"): + actual_provider, provider_id = parse_provider_id(show_id, provider) + return bridge("info", actual_provider, provider_id) + + +def episodes(show_id, provider="anikoto"): + actual_provider, provider_id = parse_provider_id(show_id, provider) + return bridge("episodes", actual_provider, provider_id).get("episodes") or [] diff --git a/provider_downloader.py b/provider_downloader.py new file mode 100755 index 0000000..494dc2d --- /dev/null +++ b/provider_downloader.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 + +"""Download episodes from Kaizoku's StrawVerse-compatible provider bridge.""" + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.request +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent +BRIDGE = PROJECT_ROOT / "providers" / "bridge.js" +PROVIDERS = ("anikoto", "anineko", "pahe") + + +def clean_component(value, default="Anime"): + text = re.sub(r"[\\/:*?\"<>|]+", " ", str(value or "")).strip() + text = re.sub(r"\s+", " ", text) + return text.strip(". ") or default + + +def parse_show_id(value, default_provider="anikoto"): + text = str(value or "").strip() + if ":" in text: + provider, show_id = text.split(":", 1) + provider = provider.strip().lower() + if provider in PROVIDERS and show_id.strip(): + return provider, show_id.strip() + return default_provider, text + + +def bridge(command, provider, *args): + cmd = ["node", str(BRIDGE), command, provider, *[str(arg) for arg in args]] + proc = subprocess.run(cmd, cwd=PROJECT_ROOT, text=True, capture_output=True, check=False) + if proc.returncode != 0: + raise RuntimeError((proc.stderr or proc.stdout or f"{command} failed").strip()) + try: + return json.loads(proc.stdout or "{}") + except json.JSONDecodeError as exc: + raise RuntimeError(f"Provider bridge returned invalid JSON: {exc}") from exc + + +def episode_values(spec, episodes): + values = [] + by_number = {str(ep.get("number")).rstrip("0").rstrip("."): ep for ep in episodes} + if not str(spec or "").strip(): + return episodes + for part in re.split(r"[\s,]+", str(spec).strip()): + if not part: + continue + if "-" in part: + start, end = part.split("-", 1) + try: + left = int(float(start)) + right = int(float(end)) + except ValueError: + continue + step = 1 if right >= left else -1 + for number in range(left, right + step, step): + item = by_number.get(str(number)) + if item and item not in values: + values.append(item) + continue + item = by_number.get(str(float(part)).rstrip("0").rstrip(".")) if re.match(r"^\d+(?:\.\d+)?$", part) else None + if item and item not in values: + values.append(item) + return values + + +def ffmpeg_headers(headers): + pairs = [] + for key, value in (headers or {}).items(): + if value: + pairs.append(f"{key}: {value}") + return "\r\n".join(pairs) + ("\r\n" if pairs else "") + + +def download_subtitles(subtitles, output_base): + saved = [] + english = [ + sub for sub in subtitles or [] + if sub.get("url") and re.search(r"eng|english", str(sub.get("lang") or ""), re.I) + ] or [sub for sub in subtitles or [] if sub.get("url")] + for index, sub in enumerate(english[:1], start=1): + url = sub["url"] + suffix = ".srt" if ".srt" in url.lower() else ".vtt" + target = output_base.with_suffix(f".eng{suffix}" if index == 1 else f".eng-{index}{suffix}") + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0", "Referer": sub.get("referer") or ""}) + with urllib.request.urlopen(request, timeout=30) as response: + target.write_bytes(response.read()) + saved.append(target) + return saved + + +def download_episode(stream, target): + cmd = ["ffmpeg", "-hide_banner", "-loglevel", "info", "-y"] + headers = ffmpeg_headers(stream.get("headers") or {}) + if headers: + cmd.extend(["-headers", headers]) + referer = (stream.get("headers") or {}).get("Referer") + if referer: + cmd.extend(["-referer", referer]) + cmd.extend(["-i", stream["url"], "-c", "copy", "-bsf:a", "aac_adtstoasc", str(target)]) + return subprocess.call(cmd) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--provider", default="anikoto") + parser.add_argument("--show-id", required=True) + parser.add_argument("--title", required=True) + parser.add_argument("--episodes", required=True) + parser.add_argument("--mode", choices=("sub", "dub"), default="sub") + parser.add_argument("--quality", default="best") + parser.add_argument("--output-dir", required=True) + args = parser.parse_args() + + provider, provider_show_id = parse_show_id(args.show_id, args.provider) + info = bridge("info", provider, provider_show_id) + episodes = bridge("episodes", provider, provider_show_id).get("episodes") or [] + wanted = episode_values(args.episodes, episodes) + if not wanted: + raise SystemExit(f"No matching episodes for spec {args.episodes!r}.") + + series_title = clean_component(args.title or info.get("title")) + output_dir = Path(args.output_dir).expanduser() + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Provider: {provider}") + print(f"Title: {series_title}") + print(f"Episodes: {', '.join(str(ep.get('number')) for ep in wanted)}") + exit_code = 0 + for ep in wanted: + number = ep.get("number") + ep_id = ep.get("id") + padded = f"{int(float(number)):02d}" if str(number).replace(".", "", 1).isdigit() else str(number) + basename = f"{series_title} - S01E{padded}" + target = output_dir / f"{basename}.mp4" + print(f"Resolving episode {number} ({args.mode}, {args.quality})...") + stream = bridge("resolve", provider, ep_id, args.mode, args.quality) + print(f"Downloading episode {number} from {stream.get('quality') or 'auto'}...") + code = download_episode(stream, target) + if code != 0: + print(f"ffmpeg failed for episode {number} with exit code {code}", file=sys.stderr) + exit_code = code + break + try: + for subtitle_path in download_subtitles(stream.get("subtitles") or [], target): + print(f"Saved subtitle: {subtitle_path.name}") + except Exception as exc: + print(f"Subtitle download skipped: {exc}") + print(f"Saved: {target.name}") + raise SystemExit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/providers/bridge.js b/providers/bridge.js new file mode 100755 index 0000000..332d3a5 --- /dev/null +++ b/providers/bridge.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node +"use strict"; + +const path = require("path"); +const axios = require("axios"); + +const PROVIDERS = { + anikoto: "./extensions/Anime/anikoto.js", + anineko: "./extensions/Anime/anineko.js", + pahe: "./extensions/Anime/pahe.js", +}; + +const dynamicReferers = new Map(); +let fallbackReferer = ""; + +global.axios = axios.create({ + timeout: 30000, + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + Accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + }, +}); +global.setDynamicReferer = (domain, referer) => { + if (domain && referer) dynamicReferers.set(String(domain), String(referer)); +}; +global.setFallbackReferer = (referer) => { + if (referer) fallbackReferer = String(referer); +}; + +function fail(message, code = 1) { + process.stderr.write(`${message}\n`); + process.exit(code); +} + +function providerName(raw) { + const value = String(raw || "").trim().toLowerCase(); + if (!value || !PROVIDERS[value]) fail(`Unknown provider: ${raw || ""}`); + return value; +} + +function loadProvider(name) { + return require(path.resolve(__dirname, PROVIDERS[name])); +} + +function parseId(value) { + const text = String(value || "").trim(); + const match = text.match(/^([a-z0-9_-]+):(.+)$/i); + if (match && PROVIDERS[match[1].toLowerCase()]) { + return { provider: match[1].toLowerCase(), id: match[2] }; + } + return { provider: "", id: text }; +} + +async function collectEpisodes(provider, id) { + const first = await provider.fetchEpisode(id, 1); + const episodes = Array.isArray(first?.episodes) ? [...first.episodes] : []; + const totalPages = Number(first?.totalPages || 1); + for (let page = 2; page <= totalPages; page += 1) { + const payload = await provider.fetchEpisode(id, page); + if (Array.isArray(payload?.episodes)) episodes.push(...payload.episodes); + } + return episodes; +} + +function qualityScore(source, wanted) { + const text = `${source.quality || ""} ${source.name || ""}`; + const match = text.match(/(\d{3,4})p?/); + const value = match ? Number(match[1]) : 0; + const normalized = String(wanted || "best").replace(/p$/i, "").toLowerCase(); + if (normalized === "worst") return value ? -value : 0; + if (normalized === "best" || !/^\d+$/.test(normalized)) return value; + const target = Number(normalized); + if (!value) return -9999; + return -Math.abs(target - value); +} + +async function resolve(provider, episodeId, mode, quality) { + const sourcesPayload = await provider.fetchEpisodeSources(episodeId, mode); + let sources = Array.isArray(sourcesPayload?.sources) ? sourcesPayload.sources : []; + if (!sources.length && mode === "dub") { + sourcesPayload = await provider.fetchEpisodeSources(episodeId, "sub"); + sources = Array.isArray(sourcesPayload?.sources) ? sourcesPayload.sources : []; + } + if (!sources.length) throw new Error("No episode sources were returned."); + sources.sort((a, b) => qualityScore(b, quality) - qualityScore(a, quality)); + for (const source of sources) { + const resolved = source.isUnresolved && provider.processServer + ? await provider.processServer(source.rawServer || source) + : source; + if (resolved?.url) { + const headers = Object.assign({}, resolved.headers || {}); + try { + const host = new URL(resolved.url).hostname; + if (!headers.Referer && dynamicReferers.has(host)) { + headers.Referer = dynamicReferers.get(host); + } + } catch (_) {} + if (!headers.Referer && fallbackReferer) headers.Referer = fallbackReferer; + return { + url: resolved.url, + isM3U8: Boolean(resolved.isM3U8 || String(resolved.url).includes(".m3u8")), + quality: resolved.quality || source.quality || source.name || "auto", + type: resolved.type || source.type || mode, + headers, + subtitles: resolved.subtitles || sourcesPayload.subtitles || [], + }; + } + } + throw new Error("Could not resolve a playable source."); +} + +async function main() { + const [command, rawProvider, ...rest] = process.argv.slice(2); + if (!command) fail("Missing command."); + + if (command === "providers") { + console.log(JSON.stringify(Object.keys(PROVIDERS))); + return; + } + + const name = providerName(rawProvider); + const provider = loadProvider(name); + + if (command === "search") { + const query = rest[0] || ""; + const page = Number(rest[1] || 1); + const data = await provider.SearchAnime(query, { page }); + const results = (data.results || []).map((item, index) => ({ + id: `${name}:${item.id}`, + provider: name, + provider_id: item.id, + title: item.title || item.name || item.id, + image: item.image || null, + index: index + 1, + })); + console.log(JSON.stringify({ ...data, results })); + return; + } + + const parsed = parseId(rest[0]); + if (parsed.provider && parsed.provider !== name) { + fail(`Show id provider ${parsed.provider} does not match requested ${name}.`); + } + const id = parsed.id; + if (!id) fail("Missing id."); + + if (command === "info") { + const data = await provider.AnimeInfo(id); + console.log(JSON.stringify({ ...data, id: `${name}:${data.id || id}`, provider: name, provider_id: data.id || id })); + return; + } + + if (command === "episodes") { + const episodes = await collectEpisodes(provider, id); + console.log(JSON.stringify({ episodes })); + return; + } + + if (command === "resolve") { + const mode = rest[1] || "sub"; + const quality = rest[2] || "best"; + const data = await resolve(provider, id, mode, quality); + console.log(JSON.stringify(data)); + return; + } + + fail(`Unknown command: ${command}`); +} + +main().catch((err) => fail(err?.stack || err?.message || String(err))); diff --git a/providers/extensions/Anime/anikoto.js b/providers/extensions/Anime/anikoto.js new file mode 100644 index 0000000..c4cc699 --- /dev/null +++ b/providers/extensions/Anime/anikoto.js @@ -0,0 +1,483 @@ +/** + * StrawVerse Extension - Anikoto Scraper + * Copyright (C) 2026 TheYogMehta + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * DISCLAIMER: This extension is intended for research, educational, + * and developer testing purposes only. It functions as a client-side parser + * of publicly available web pages. The developers do not host or distribute + * any copyrighted media. Users are responsible for compliance with the terms of + * service of the target website. + */ + +const cheerio = require("cheerio"); + +const baseUrl = "https://anikototv.to"; + +function parsePagination($, defaultPage) { + let totalPages = 1; + $(".pagination a").each((i, el) => { + const href = $(el).attr("href"); + if (href) { + const match = href.match(/page=(\d+)/); + if (match) { + const pageNum = parseInt(match[1]); + if (pageNum > totalPages) { + totalPages = pageNum; + } + } + } + }); + + let hasNextPage = false; + $(".pagination a").each((i, el) => { + const rel = $(el).attr("rel"); + if (rel === "next") { + hasNextPage = true; + } + }); + + const activePageText = $( + ".pagination li.active, .pagination li.page-item.active", + ) + .text() + .trim(); + const currentPage = parseInt(activePageText) || defaultPage || 1; + + return { + currentPage, + hasNextPage, + totalPages, + }; +} + +async function SearchAnime(query, filters = {}) { + try { + const page = filters?.page || 1; + const { data: html } = await global.axios.get( + `${baseUrl}/search?keyword=${encodeURIComponent(query)}&page=${page}`, + ); + const $ = cheerio.load(html); + const results = []; + + $("div.item").each((i, el) => { + const aTag = $(el).find(".name.d-title"); + const title = + aTag.text().trim() || + aTag.attr("data-jp") || + aTag.attr("title") || + $(el).find(".name.d-title").text().trim() || + $(el).find(".title").text().trim(); + let href = aTag.attr("href"); + if (!href) return; + const match = href.match(/\/watch\/([^\/]+)/); + if (!match) return; + const id = match[1]; + + const image = + $(el).find(".ani.poster img").attr("src") || + $(el).find("img").attr("src"); + + results.push({ + id: id, + title: title, + image: image || null, + }); + }); + + const pagination = parsePagination($, page); + + return { + currentPage: pagination.currentPage, + hasNextPage: pagination.hasNextPage, + totalPages: pagination.totalPages, + results: results, + }; + } catch (err) { + throw new Error(err.message); + } +} + +async function fetchRecentEpisodes(filters = {}) { + try { + const page = filters?.page || 1; + const { data: html } = await global.axios.get( + `${baseUrl}/latest-updated?page=${page}`, + ); + const $ = cheerio.load(html); + const results = []; + + $(".item").each((i, el) => { + const aTag = $(el).find(".name.d-title").length + ? $(el).find(".name.d-title").first() + : $(el).find("a").last(); + const imgTag = $(el).find("img"); + + const title = + $(el).find(".name.d-title").text().trim() || + $(el).find(".title").text().trim() || + imgTag.attr("title") || + imgTag.attr("alt") || + aTag.attr("title") || + aTag.attr("data-jp") || + aTag + .text() + .trim() + .replace(/TV\s*Sub\s*Dub/i, "") + .trim(); + + let href = $(el).find("a").first().attr("href") || aTag.attr("href"); + + if (!href) return; + const match = href.match(/\/watch\/([^\/]+)/); + if (!match) return; + const id = match[1]; + + const image = + $(el).find(".ani.poster img").attr("src") || + $(el).find("img").attr("src"); + + results.push({ + id: id, + title: title, + image: image || null, + }); + }); + + const pagination = parsePagination($, page); + + return { + currentPage: pagination.currentPage, + hasNextPage: pagination.hasNextPage, + totalPages: pagination.totalPages, + results: results, + }; + } catch (err) { + throw new Error(err.message); + } +} + +async function AnimeInfo(id) { + const animeInfo = { + id: id, + title: "", + }; + + try { + const { data: html } = await global.axios.get(`${baseUrl}/watch/${id}`); + const $ = cheerio.load(html); + + const dataId = $("#watch-main").attr("data-id"); + + animeInfo.title = + $('h1[itemprop="name"]').text().trim() || + $(".title").first().text().trim() || + id; + animeInfo.image = + $('img[itemprop="image"]').attr("src") || + $(".ani.poster img").attr("src") || + null; + animeInfo.description = + $(".synopsis").text().trim() || $(".description").text().trim() || ""; + + const genres = []; + $(".genre a").each((i, el) => { + genres.push($(el).text().trim()); + }); + animeInfo.genres = genres; + animeInfo.status = "Unknown"; + + $(".info .item").each((i, el) => { + const text = $(el).text(); + if (text.includes("Status:")) { + const status = $(el).find(".name").text().trim(); + if (status.includes("Currently Airing")) animeInfo.status = "Ongoing"; + else if (status.includes("Finished Airing")) + animeInfo.status = "Completed"; + } + }); + animeInfo.dataId = dataId; + + return animeInfo; + } catch (error) { + console.error("Error fetching data from AnikotoTV:", error); + throw error; + } +} + +async function fetchEpisode(dataId, page = 1) { + try { + let numericId = dataId; + if (!/^\d+$/.test(String(dataId))) { + try { + const { data: watchHtml } = await global.axios.get( + `${baseUrl}/watch/${dataId}`, + ); + const $w = cheerio.load(watchHtml); + numericId = + $w("#watch-main").attr("data-id") || + $w("[data-id]").attr("data-id") || + $w("#wrapper").attr("data-id"); + } catch (e) {} + } + + if (!numericId) { + return { episodes: [], totalPages: 0, total: 0, currentPage: page }; + } + + const url = `${baseUrl}/ajax/episode/list/${numericId}`; + const { data } = await global.axios.get(url, { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }); + + const $ = cheerio.load(data.result || ""); + let episodes = []; + + $("a[data-id][data-ids], .ep-item, li a, .ssl-item, a.item").each( + (i, el) => { + const epNum = + $(el).attr("data-num") || $(el).attr("data-number") || String(i + 1); + const epId = $(el).attr("data-id"); + const dataIds = $(el).attr("data-ids"); + const title = + $(el).attr("title") || + $(el).find(".d-title").text().trim() || + `Episode ${epNum}`; + + if (epId && dataIds) { + const langs = []; + if ($(el).attr("data-sub") === "1") langs.push("sub"); + if ($(el).attr("data-dub") === "1") langs.push("dub"); + + episodes.push({ + id: `${epId}|${dataIds}`, + number: parseFloat(epNum), + title: title, + duration: "Unknown", + langs, + }); + } + }, + ); + + return { + episodes: episodes, + totalPages: 1, + total: episodes.length, + currentPage: 1, + }; + } catch (err) { + return { episodes: [], totalPages: 0, total: 0, currentPage: page }; + } +} + +async function processServer(server) { + if (!server || !server.linkId) return null; + try { + const linkRes = await global.axios.get( + `${baseUrl}/ajax/server?get=${server.linkId}`, + { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }, + ); + + let iframeUrl = + linkRes.data?.result?.url || + linkRes.data?.url || + (typeof linkRes.data?.result === "string" ? linkRes.data.result : null); + if (!iframeUrl && typeof linkRes.data?.result === "string") { + const match = linkRes.data.result.match(/src=["']([^"']+)["']/); + if (match) iframeUrl = match[1]; + } + if (!iframeUrl || typeof iframeUrl !== "string") return null; + if (iframeUrl.startsWith("//")) iframeUrl = "https:" + iframeUrl; + + const iframeRes = await global.axios.get(iframeUrl, { + headers: { + "X-Requested-With": "XMLHttpRequest", + Referer: baseUrl, + }, + }); + + const $iframe = cheerio.load(iframeRes.data); + let playerDbId = + $iframe("#megaplay-player").attr("data-id") || + $iframe("[data-id]").attr("data-id"); + if (!playerDbId) { + const match = iframeRes.data.match(/data-id=["']([^"']+)["']/); + if (match) playerDbId = match[1]; + } + if (!playerDbId) return null; + + const typeMatch = + iframeRes.data.match(/type\s*:\s*'([^']+)'/) || + iframeUrl.match(/\/stream\/[^\/]+\/([^\/\?]+)/); + const type = typeMatch ? typeMatch[1] : ""; + + const ciduMatch = iframeRes.data.match(/cidu\s*:\s*'([^']+)'/); + const cidu = ciduMatch ? ciduMatch[1] : ""; + + const domainName = new URL(iframeUrl).origin; + const playerReferer = domainName + "/"; + const sourcesRes = await global.axios.get( + `${domainName}/stream/getSources?id=${playerDbId}${type ? `&type=${encodeURIComponent(type)}` : ""}${cidu ? `&cidu=${encodeURIComponent(cidu)}` : ""}`, + { + headers: { + "X-Requested-With": "XMLHttpRequest", + Referer: playerReferer, + }, + }, + ); + + if (sourcesRes.data && sourcesRes.data.sources) { + const rawSrc = sourcesRes.data.sources; + const m3u8Url = + typeof rawSrc === "string" + ? rawSrc + : rawSrc.file || + rawSrc.url || + (Array.isArray(rawSrc) + ? rawSrc[0]?.file || rawSrc[0]?.url || (typeof rawSrc[0] === "string" ? rawSrc[0] : null) + : null); + if (m3u8Url) { + try { + const cdnDomain = new URL(m3u8Url).hostname; + global.setDynamicReferer(cdnDomain, playerReferer); + global.setFallbackReferer(playerReferer); + } catch (e) {} + + const subtitles = (sourcesRes.data.tracks || []) + .filter( + (t) => t.file && (!t.kind || t.kind.toLowerCase() !== "thumbnails"), + ) + .map((t) => { + let sUrl = t.file; + if (sUrl.startsWith("//")) { + sUrl = "https:" + sUrl; + } else if ( + !sUrl.startsWith("http://") && + !sUrl.startsWith("https://") + ) { + try { + sUrl = new URL(sUrl, iframeUrl).href; + } catch (e) {} + } + return { + url: sUrl, + lang: t.label || t.language || "English", + }; + }); + + return { + url: m3u8Url, + isM3U8: true, + quality: server.name || "auto", + isDub: server.type === "dub", + isHsub: server.type === "hsub", + type: server.type, + headers: { Referer: playerReferer }, + subtitles: subtitles, + }; + } + } + } catch (err) { + console.error(`Failed to process server ${server.name}:`, err.message); + } + return null; +} + +async function fetchEpisodeSources(episodeIdStr, category = null) { + try { + const parts = episodeIdStr.split("|"); + const epId = parts[0]; + const dataIds = parts[1]; + + const serverUrl = `${baseUrl}/ajax/server/list?servers=${dataIds}`; + const serverRes = await global.axios.get(serverUrl, { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }); + + const $ = cheerio.load(serverRes.data.result); + + let selector = + ".type[data-type='sub'] li, .type[data-type='dub'] li, .type[data-type='hsub'] li"; + + const servers = []; + $(selector).each((i, el) => { + const type = $(el).closest(".type").attr("data-type"); + servers.push({ + type: type, + linkId: $(el).attr("data-link-id"), + name: $(el).text().trim(), + }); + }); + + let targetServers = servers; + if (category) { + const catLower = category.toLowerCase(); + targetServers = servers.filter((s) => { + const typeLower = (s.type || "").toLowerCase(); + if (catLower === "hsub" || catLower === "hardsub") { + return typeLower === "hsub" || typeLower === "hardsub"; + } else if (catLower === "sub" || catLower === "softsub") { + return typeLower === "sub" || typeLower === "softsub"; + } else if (catLower === "dub") { + return typeLower === "dub"; + } + return typeLower === catLower; + }); + } + + const requestedCategory = (category || "sub").toLowerCase(); + if (targetServers.length === 0) { + return { sources: [], subtitles: [] }; + } + + const sources = targetServers.map((s) => ({ + quality: s.name, + name: s.name, + linkId: s.linkId, + lang: s.type || requestedCategory, + type: s.type || requestedCategory, + isUnresolved: true, + rawServer: s, + })); + + return { + sources, + subtitles: [], + }; + } catch (err) { + console.error("Error fetching data from AnikotoTV:", err); + return { sources: [], subtitles: [] }; + } +} + +module.exports = { + name: "anikoto", + version: "5.0.1", + SearchAnime, + AnimeInfo, + fetchEpisodeSources, + processServer, + fetchRecentEpisodes, + fetchEpisode, +}; diff --git a/providers/extensions/Anime/anineko.js b/providers/extensions/Anime/anineko.js new file mode 100644 index 0000000..8199be6 --- /dev/null +++ b/providers/extensions/Anime/anineko.js @@ -0,0 +1,628 @@ +/** + * StrawVerse Extension - AniNeko Scraper + * Copyright (C) 2026 TheYogMehta + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * DISCLAIMER: This extension is intended for research, educational, + * and developer testing purposes only. It functions as a client-side parser + * of publicly available web pages. The developers do not host or distribute + * any copyrighted media. Users are responsible for compliance with the terms of + * service of the target website. + */ + +const cheerio = require("cheerio"); + +const baseUrl = "https://anineko.to"; + +function parsePagination($, defaultPage) { + let totalPages = 1; + $(".pagination a.page-link").each((i, el) => { + const href = $(el).attr("href"); + if (href) { + const match = href.match(/page=(\d+)/); + if (match) { + const pageNum = parseInt(match[1]); + if (pageNum > totalPages) { + totalPages = pageNum; + } + } + } + }); + + let hasNextPage = false; + $(".pagination li.next, .pagination li.page-item.next").each((i, el) => { + hasNextPage = true; + }); + + const activeText = $(".pagination li.active a.page-link").text().trim(); + const currentPage = parseInt(activeText) || defaultPage || 1; + + return { + currentPage, + hasNextPage, + totalPages, + }; +} + +// Anime Search +async function SearchAnime(query, filters = {}) { + try { + const page = filters?.page || 1; + const { data: html } = await global.axios.get( + `${baseUrl}/browser?keyword=${encodeURIComponent(query)}&page=${page}`, + ); + const $ = cheerio.load(html); + const results = []; + + $("article.nv-anime-card").each((i, el) => { + const titleEl = $(el).find("h3.nv-anime-title a"); + const title = titleEl.text().trim(); + let href = titleEl.attr("href") || $(el).find("a").first().attr("href"); + if (!href) return; + + const match = href.match(/\/watch\/([^\/]+)/); + if (!match) return; + const id = match[1]; + + const image = + $(el).find(".nv-anime-thumb img").attr("src") || + $(el).find("img").attr("src") || + null; + + results.push({ + id, + title, + image, + }); + }); + + const pagination = parsePagination($, page); + + return { + currentPage: pagination.currentPage, + hasNextPage: pagination.hasNextPage, + totalPages: pagination.totalPages, + results, + }; + } catch (err) { + throw new Error(err.message); + } +} + +// Recent Episodes +async function fetchRecentEpisodes(filters = {}) { + try { + const page = filters?.page || 1; + const { data: html } = await global.axios.get( + `${baseUrl}/updates?page=${page}`, + ); + const $ = cheerio.load(html); + const results = []; + + $("article.nv-anime-card").each((i, el) => { + const titleEl = $(el).find("h3.nv-anime-title a"); + const title = titleEl.text().trim(); + let href = titleEl.attr("href") || $(el).find("a").first().attr("href"); + if (!href) return; + + const match = href.match(/\/watch\/([^\/]+)/); + if (!match) return; + const id = match[1]; + + const image = + $(el).find(".nv-anime-thumb img").attr("src") || + $(el).find("img").attr("src") || + null; + + results.push({ + id, + title, + image, + }); + }); + + const pagination = parsePagination($, page); + + return { + currentPage: pagination.currentPage, + hasNextPage: pagination.hasNextPage, + totalPages: pagination.totalPages, + results, + }; + } catch (err) { + throw new Error(err.message); + } +} + +// Anime Info +async function AnimeInfo(id) { + const animeInfo = { + id: id, + title: "", + }; + + try { + const { data: html } = await global.axios.get(`${baseUrl}/watch/${id}`); + const $ = cheerio.load(html); + + animeInfo.title = $("h1").first().text().trim() || id; + animeInfo.image = $(".nv-info-poster img").attr("src") || null; + animeInfo.description = + $(".nv-info-desc").text().trim() || + $(".nv-info-synopsis").text().trim() || + ""; + + const genres = []; + $(".nv-info-tags span").each((i, el) => { + const text = $(el).text().trim(); + if ( + text && + !["SUB", "DUB", "Hardsub", "HD", "HSUB"].includes(text) && + !text.match(/^\d{4}$/) && + ![ + "Currently Airing", + "Finished Airing", + "Not yet aired", + "TV", + "Movie", + "OVA", + "ONA", + "Special", + "Music", + "TV_SHORT", + ].includes(text) + ) { + genres.push(text); + } + }); + animeInfo.genres = genres; + animeInfo.status = "Unknown"; + const statusText = $(".nv-info-tags span, .nv-pill").text(); + if (statusText.includes("Currently Airing")) { + animeInfo.status = "Ongoing"; + } else if (statusText.includes("Finished Airing")) { + animeInfo.status = "Completed"; + } + const typeEl = $(".nv-info-stats div").first().find("strong").text().trim(); + animeInfo.type = typeEl || "TV"; + + animeInfo.dataId = id; + + return animeInfo; + } catch (error) { + console.error("Error fetching data from AniNeko:", error); + throw error; + } +} + +// Fetch Episodes +async function fetchEpisode(id, page = 1) { + try { + const { data: html } = await global.axios.get(`${baseUrl}/watch/${id}`); + const $ = cheerio.load(html); + let episodes = []; + + $("article.nv-info-episode-item").each((i, el) => { + const mainLink = $(el).find("a.nv-info-episode-main"); + const href = mainLink.attr("href") || ""; + const epMatch = href.match(/\/ep-(\d+)/); + const epNum = epMatch ? parseInt(epMatch[1]) : i + 1; + + const titleStrong = mainLink.find("strong").text().trim(); + const titleSpan = mainLink.find("span").text().trim(); + const title = titleSpan || titleStrong || `Episode ${epNum}`; + + const badges = $(el) + .find(".nv-info-episode-badges span") + .map((j, badge) => $(badge).text().trim().toUpperCase()) + .get(); + + const langs = []; + if (badges.includes("SUB")) langs.push("sub"); + if (badges.includes("HSUB") || badges.includes("HARDSUB")) + langs.push("hsub"); + if (badges.includes("DUB")) langs.push("dub"); + if ( + badges.includes("SOFTSUB") || + badges.includes("SOFT SUB") || + badges.includes("SOFT-SUB") + ) + langs.push("sub"); + if ( + badges.includes("SOFTDUB") || + badges.includes("SOFT DUB") || + badges.includes("SOFT-DUB") + ) + langs.push("dub"); + + const watchPathMatch = href.match(/\/watch\/(.+)$/); + const epSlug = watchPathMatch ? watchPathMatch[1] : `${id}/ep-${epNum}`; + + episodes.push({ + id: epSlug, + number: epNum, + title, + duration: "Unknown", + langs, + }); + }); + + return { + episodes, + totalPages: 1, + total: episodes.length, + currentPage: 1, + }; + } catch (err) { + return { episodes: [], totalPages: 0, total: 0, currentPage: page }; + } +} + +// Fetch Episode Sources +async function fetchEpisodeSources(episodeId, category = null) { + try { + const { data: html } = await global.axios.get( + `${baseUrl}/watch/${episodeId}`, + ); + const $ = cheerio.load(html); + + const servers = []; + + $(".lang-group, .server-group, [data-lang]").each((i, panel) => { + let rawType = ( + $(panel).attr("data-lang") || + $(panel).attr("data-type") || + $(panel).attr("data-id") || + "" + ).toLowerCase(); + + let panelType = ""; + if ( + rawType.includes("hsub") || + rawType.includes("hardsub") || + rawType.includes("hard-sub") + ) { + panelType = "hsub"; + } else if (rawType.includes("dub")) { + panelType = "dub"; + } else if (rawType.includes("sub")) { + panelType = "sub"; + } + + $(panel) + .find("button.server-video, a.server-video, .server-item") + .each((j, btn) => { + const videoUrl = + $(btn).attr("data-video") || + $(btn).attr("data-url") || + $(btn).attr("href"); + if ( + !videoUrl || + videoUrl === "#" || + videoUrl.startsWith("javascript:") + ) + return; + + const cloned = $(btn).clone(); + cloned.find("span").remove(); + const serverName = cloned.text().trim() || "Server"; + + let btnRaw = ( + $(btn).attr("data-lang") || + $(btn).attr("data-type") || + "" + ).toLowerCase(); + + let btnType = panelType; + if (btnRaw.includes("hsub") || btnRaw.includes("hardsub")) { + btnType = "hsub"; + } else if (btnRaw.includes("dub")) { + btnType = "dub"; + } else if (btnRaw.includes("sub") && !btnRaw.includes("hsub")) { + btnType = "sub"; + } + + servers.push({ + url: videoUrl, + name: serverName, + type: btnType || "sub", + isDefault: $(btn).hasClass("default") || $(btn).hasClass("active"), + }); + }); + }); + + let targetServers = servers; + if (category) { + const catLower = category.toLowerCase(); + targetServers = servers.filter((s) => { + const typeLower = (s.type || "").toLowerCase(); + if (catLower === "hsub" || catLower === "hardsub") { + return typeLower === "hsub" || typeLower === "hardsub"; + } else if (catLower === "sub" || catLower === "softsub") { + return typeLower === "sub" || typeLower === "softsub"; + } else if (catLower === "dub") { + return typeLower === "dub"; + } + return typeLower === catLower; + }); + } + + const requestedCategory = (category || "sub").toLowerCase(); + if (targetServers.length === 0) { + return { sources: [], subtitles: [] }; + } + + const sources = targetServers.map((s) => ({ + quality: s.name, + name: s.name, + url: s.url, + lang: s.type || requestedCategory, + type: s.type || requestedCategory, + isUnresolved: true, + rawServer: s, + })); + + return { + sources, + subtitles: [], + }; + } catch (err) { + console.error("Error fetching data from AniNeko:", err); + return { sources: [], subtitles: [] }; + } +} + +// Process an embed server URL to extract the actual video source +async function processEmbedServer(server) { + try { + const embedUrl = server.url; + let typeLabel = server.type ? server.type.toUpperCase() : ""; + if (server.type === "sub") typeLabel = "Sub"; + if (server.type === "hsub") typeLabel = "HSub"; + if (server.type === "dub") typeLabel = "Dub"; + const qualityLabel = `${server.name} ${typeLabel}`; + let subtitles = []; + try { + const urlObj = new URL(embedUrl); + const subParam = urlObj.searchParams.get("sub"); + const captionParam = urlObj.searchParams.get("caption_1"); + const c1FileParam = urlObj.searchParams.get("c1_file"); + let subUrl = subParam || captionParam || c1FileParam; + if (subUrl) { + if (subUrl.startsWith("//")) { + subUrl = "https:" + subUrl; + } else if ( + !subUrl.startsWith("http://") && + !subUrl.startsWith("https://") + ) { + try { + subUrl = new URL(subUrl, embedUrl).href; + } catch (e) {} + } + subtitles.push({ + url: subUrl, + lang: "English", + }); + } + } catch (e) {} + + const { data: embedHtml } = await global.axios.get(embedUrl, { + headers: { + Referer: `${baseUrl}/`, + }, + }); + + try { + const tracksRegex = /tracks\s*[:=]\s*(\[[^\]]+\])/i; + const tracksMatch = embedHtml.match(tracksRegex); + if (tracksMatch) { + const arrayStr = tracksMatch[1]; + const objRegex = /\{([^}]+)\}/g; + let objMatch; + while ((objMatch = objRegex.exec(arrayStr)) !== null) { + const objContent = objMatch[1]; + const fileM = objContent.match( + /['"]?file['"]?\s*:\s*['"]([^'"]+)['"]/, + ); + if (fileM) { + let sUrl = fileM[1]; + const labelM = objContent.match( + /['"]?label['"]?\s*:\s*['"]([^'"]+)['"]/, + ); + const kindM = objContent.match( + /['"]?kind['"]?\s*:\s*['"]([^'"]+)['"]/, + ); + const langM = objContent.match( + /['"]?(?:language|lang)['"]?\s*:\s*['"]([^'"]+)['"]/, + ); + + const kind = kindM ? kindM[1].toLowerCase() : ""; + if (!kind || kind !== "thumbnails") { + if (sUrl.startsWith("//")) { + sUrl = "https:" + sUrl; + } else if ( + !sUrl.startsWith("http://") && + !sUrl.startsWith("https://") + ) { + try { + sUrl = new URL(sUrl, embedUrl).href; + } catch (e) {} + } + subtitles.push({ + url: sUrl, + lang: labelM ? labelM[1] : langM ? langM[1] : "English", + }); + } + } + } + } + + if (subtitles.length === 0) { + const subFileRegex = + /["']?file["']?\s*:\s*["']([^"']+\.(?:vtt|srt)[^"']*)["']/gi; + let subMatch; + while ((subMatch = subFileRegex.exec(embedHtml)) !== null) { + let sUrl = subMatch[1]; + if (sUrl.startsWith("//")) { + sUrl = "https:" + sUrl; + } else if ( + !sUrl.startsWith("http://") && + !sUrl.startsWith("https://") + ) { + try { + sUrl = new URL(sUrl, embedUrl).href; + } catch (e) {} + } + subtitles.push({ url: sUrl, lang: "English" }); + } + } + } catch (e) {} + let m3u8Match = embedHtml.match(/https?:\/\/[^\s"']+\.m3u8[^\s"']*/); + if (!m3u8Match) { + const srcMatch = embedHtml.match( + /src\s*=\s*["']([^"']*\.m3u8[^"']*)["']/, + ); + if (srcMatch) { + m3u8Match = [srcMatch[1]]; + } + } + if (!m3u8Match) { + const evalMatch = /(eval)(\(f.*?)(<\/script>)/s.exec(embedHtml); + if (evalMatch) { + try { + const unpacked = eval(evalMatch[2].replace("eval", "")); + m3u8Match = unpacked.match(/https?:\/\/[^\s"']+\.m3u8[^\s"']*/); + } catch (e) {} + } + } + if (!m3u8Match) { + const fileMatch = embedHtml.match( + /["']?file["']?\s*:\s*["']([^"']+\.m3u8[^"']*)["']/, + ); + if (fileMatch) { + m3u8Match = [fileMatch[1]]; + } + } + if (!m3u8Match) { + const sourcesMatch = embedHtml.match( + /sources\s*[:=]\s*\[\s*\{[^}]*["']?file["']?\s*:\s*["']([^"']+)["']/, + ); + if (sourcesMatch) { + m3u8Match = [sourcesMatch[1]]; + } + } + + if (m3u8Match) { + let m3u8Url = m3u8Match[0].replace(/["'\\]/g, ""); + if (m3u8Url.startsWith("//")) { + m3u8Url = "https:" + m3u8Url; + } else if ( + !m3u8Url.startsWith("http://") && + !m3u8Url.startsWith("https://") + ) { + try { + m3u8Url = new URL(m3u8Url, embedUrl).href; + } catch (e) {} + } + + try { + const cdnDomain = new URL(m3u8Url).hostname; + const embedDomain = new URL(embedUrl).origin + "/"; + if (global.setDynamicReferer) { + global.setDynamicReferer(cdnDomain, embedDomain); + global.setFallbackReferer(embedDomain); + } + for (const sub of subtitles) { + try { + const subDomain = new URL(sub.url).hostname; + if (subDomain !== cdnDomain && global.setDynamicReferer) { + global.setDynamicReferer(subDomain, embedDomain); + } + } catch (_) {} + } + } catch (e) {} + + return { + url: m3u8Url, + isM3U8: true, + quality: qualityLabel, + isDub: server.type === "dub", + isHsub: server.type === "hsub", + type: server.type, + headers: { Referer: new URL(embedUrl).origin + "/" }, + subtitles: subtitles.length > 0 ? subtitles : undefined, + }; + } + const mp4Match = embedHtml.match( + /["']?file["']?\s*:\s*["']([^"']+\.mp4[^"']*)["']/, + ); + if (mp4Match) { + let mp4Url = mp4Match[1]; + if (mp4Url.startsWith("//")) { + mp4Url = "https:" + mp4Url; + } else if ( + !mp4Url.startsWith("http://") && + !mp4Url.startsWith("https://") + ) { + try { + mp4Url = new URL(mp4Url, embedUrl).href; + } catch (e) {} + } + try { + const cdnDomain = new URL(mp4Url).hostname; + const embedDomain = new URL(embedUrl).origin + "/"; + if (global.setDynamicReferer) { + global.setDynamicReferer(cdnDomain, embedDomain); + global.setFallbackReferer(embedDomain); + } + for (const sub of subtitles) { + try { + const subDomain = new URL(sub.url).hostname; + if (subDomain !== cdnDomain && global.setDynamicReferer) { + global.setDynamicReferer(subDomain, embedDomain); + } + } catch (_) {} + } + } catch (e) {} + + return { + url: mp4Url, + isM3U8: false, + quality: qualityLabel, + isDub: server.type === "dub", + isHsub: server.type === "hsub", + type: server.type, + headers: { Referer: new URL(embedUrl).origin + "/" }, + subtitles: subtitles.length > 0 ? subtitles : undefined, + }; + } + + return null; + } catch (err) { + console.error(`Failed to process embed ${server.url}:`, err.message); + return null; + } +} + +module.exports = { + name: "anineko", + version: "3.0.2", + SearchAnime, + AnimeInfo, + fetchEpisodeSources, + processServer: processEmbedServer, + fetchRecentEpisodes, + fetchEpisode, +}; diff --git a/providers/extensions/Anime/pahe.js b/providers/extensions/Anime/pahe.js new file mode 100644 index 0000000..8eecd9b --- /dev/null +++ b/providers/extensions/Anime/pahe.js @@ -0,0 +1,367 @@ +/** + * StrawVerse Extension - AnimePahe Scraper + * Copyright (C) 2026 TheYogMehta + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * DISCLAIMER: This extension is intended for research, educational, + * and developer testing purposes only. It functions as a client-side parser + * of publicly available web pages. The developers do not host or distribute + * any copyrighted media. Users are responsible for compliance with the terms of + * service of the target website. + */ + +// imports +const cheerio = require("cheerio"); + +// variables +const baseUrl = "https://animepahe.pw"; + +// Anime Search +async function SearchAnime(query, filters = {}) { + try { + const { data } = await global.axios.get( + `${baseUrl}/api?m=search&q=${encodeURIComponent(query)}`, + { + headers: { + Referer: baseUrl, + }, + }, + ); + const res = { + currentPage: 1, + hasNextPage: false, + totalPages: 1, + results: data.data.map((item) => ({ + id: `${item.session}`, + title: item.title, + image: item?.poster, + })), + }; + return res; + } catch (err) { + throw new Error(err.message); + } +} + +// Recent Episodes +async function fetchRecentEpisodes(filters = {}) { + try { + const { data } = await global.axios.get( + `${baseUrl}/api?m=airing&page=${filters.page}`, + { + headers: { + Referer: baseUrl, + }, + }, + ); + const res = { + currentPage: filters.page, + hasNextPage: data?.next_page_url?.length > 0 ? true : false, + totalPages: data?.last_page ?? 0, + results: data.data.map((item) => ({ + id: `${item.anime_session}`, + title: item.anime_title, + image: item?.snapshot, + episode: item.episode, + })), + }; + return res; + } catch (err) { + throw new Error(err.message); + } +} + +// Animeinfo +async function AnimeInfo(id) { + const animeInfo = { + id: id, + title: "", + }; + + try { + const { data } = await global.axios.get(`${baseUrl}/anime/${id}`, { + headers: { + Referer: baseUrl, + }, + }); + const $ = (0, cheerio.load)(data); + + let MalId = + parseInt($('meta[name="myanimelist"]').attr("content") ?? null) ?? null; + + animeInfo.malid = MalId; + animeInfo.title = $("div.title-wrapper > h1 > span").first().text(); + let image = $("div.anime-poster a").attr("href") ?? null; + animeInfo.image = image; + animeInfo.description = $("div.anime-summary").text(); + animeInfo.genres = $("div.anime-genre ul li") + .map((i, el) => $(el).find("a").attr("title")) + .get(); + switch ( + $('div.col-sm-4.anime-info p:icontains("Status:") a').text().trim() + ) { + case "Currently Airing": + animeInfo.status = "Ongoing"; + break; + case "Finished Airing": + animeInfo.status = "Completed"; + break; + default: + animeInfo.status = "Unknown"; + } + + animeInfo.type = $('div.col-sm-4.anime-info p:icontains("Type") a') + .text() + .trim() + .toUpperCase(); + + animeInfo.aired = $('div.col-sm-4.anime-info p:icontains("Aired")') + .text() + .replace("Aired:", "") + .replaceAll("\n", " ") + .replaceAll(" ", "") + .trim(); + + animeInfo.dataId = id; + + return animeInfo; + } catch (error) { + console.error("Error fetching data from AnimePahe:", error); + throw error; + } +} + +const firstEpCache = {}; + +async function getFirstEpisodeNumber(id, lastPage) { + if (firstEpCache[id] !== undefined) { + return firstEpCache[id]; + } + try { + const { data } = await global.axios.get( + `${baseUrl}/api?m=release&id=${id}&sort=episode_desc&page=${lastPage}`, + { + headers: { + Referer: baseUrl, + }, + }, + ); + if (data?.data && data.data.length > 0) { + const firstEp = data.data[data.data.length - 1].episode; + firstEpCache[id] = firstEp; + return firstEp; + } + } catch (err) { + console.error("Failed to fetch first episode number:", err); + } + firstEpCache[id] = 1; + return 1; +} + +// Fetching Episodes Pages +async function fetchEpisode(id, page = 1) { + try { + let episodes = []; + + let { last_page, data, total } = ( + await global.axios.get( + `${baseUrl}/api?m=release&id=${id}&sort=episode_desc&page=${page}`, + { + headers: { + Referer: baseUrl, + }, + }, + ) + ).data; + + const firstEpNum = await getFirstEpisodeNumber(id, last_page); + const offset = firstEpNum - 1; + + data.forEach((item) => { + let hasEngAudio = item?.audio && item?.audio?.toLowerCase() === "eng"; + let mappedNumber = item.episode - offset; + if (mappedNumber < 1) mappedNumber = item.episode; + + const langs = ["sub"]; + if (hasEngAudio) langs.push("dub"); + + episodes.push({ + id: `${id}/${item.session}`, + number: mappedNumber, + title: item.title, + duration: item.duration, + langs, + }); + }); + + return { + episodes: episodes, + totalPages: last_page, + total: total, + currentPage: page, + }; + } catch (err) { + return { episodes: [], totalPages: 0, total: 0, currentPage: page }; + } +} + +// fetching Episodes Download Links +async function fetchEpisodeSources(episodeId, category = null) { + try { + const { data } = await global.axios.get(`${baseUrl}/play/${episodeId}`, { + headers: { + Referer: baseUrl, + }, + }); + const $ = (0, cheerio.load)(data); + + let linksArray = $("div#resolutionMenu > button") + .map((i, el) => ({ + url: $(el).attr("data-src"), + quality: extractQualityNumber($(el).text()), + audio: $(el).attr("data-audio"), + })) + .get(); + + if (category) { + const catLower = category.toLowerCase(); + if (catLower === "dub") { + const filtered = linksArray.filter((l) => l.audio === "eng"); + if (filtered.length > 0) linksArray = filtered; + } else if (catLower === "sub") { + const filtered = linksArray.filter((l) => l.audio !== "eng"); + if (filtered.length > 0) linksArray = filtered; + } + } + + if (linksArray.length === 0) { + return { sources: [], subtitles: [] }; + } + + const sources = linksArray.map((l) => ({ + quality: l.quality || "auto", + name: `Server ${l.quality}`, + url: l.url, + lang: l.audio === "eng" ? "dub" : "sub", + type: l.audio === "eng" ? "dub" : "sub", + isUnresolved: true, + rawServer: l, + })); + + return { + sources, + subtitles: [], + }; + } catch (err) { + console.error("Error fetching data from AnimePahe:", err); + return { sources: [], subtitles: [] }; + } +} + +async function processServer(server) { + if (!server?.url) return null; + try { + const embedUrlObj = new URL(server.url); + const playerReferer = embedUrlObj.origin + "/"; + const res = await extract(embedUrlObj); + if (res && res[0]) { + const streamUrl = res[0].url; + try { + const streamDomain = new URL(streamUrl).hostname; + if (global.setDynamicReferer) { + global.setDynamicReferer(streamDomain, playerReferer); + global.setFallbackReferer(playerReferer); + } + } catch (e) {} + + return { + url: streamUrl, + quality: server.quality || "auto", + isM3U8: res[0].isM3U8 || streamUrl.includes(".m3u8"), + headers: { Referer: playerReferer }, + lang: server.audio === "eng" ? "dub" : "sub", + type: server.audio === "eng" ? "dub" : "sub", + }; + } + } catch (err) { + console.error("Failed to extract server:", err.message); + } + return null; +} + +module.exports = { + name: "pahe", + version: "3.1.4", + SearchAnime, + AnimeInfo, + fetchEpisodeSources, + processServer, + fetchRecentEpisodes, + fetchEpisode, +}; + +// helpers for extracting video links +function extractQualityNumber(qualityString) { + const match = qualityString.match(/\d+p/); + return match ? match[0] : ""; +} + +// helpers for extracting video links +async function extract(videoUrl, retries = 2, delay = 1000) { + let sources = []; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const { data } = await global.axios.get(videoUrl.href, { + headers: { + Referer: "https://animepahe.pw/", + }, + }); + const match = /(eval)(\(f.*?)(<\/script>)/s.exec(data); + if (!match) { + throw new Error("Failed to find video source packer block"); + } + const source = eval(match[2].replace("eval", "")).match(/https.*?m3u8/); + sources.push({ + url: source[0], + isM3U8: source[0].includes(".m3u8"), + }); + return sources; + } catch (err) { + if ( + (err.response?.status === 429 || err.message.includes("429")) && + attempt < retries + ) { + console.warn( + `Request to ${videoUrl.href} returned 429. Retrying in ${delay}ms (attempt ${attempt}/${retries})...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + delay *= 2; + continue; + } + throw new Error(err.message); + } + } +} + +module.exports = { + name: "pahe", + version: "4.0.0", + SearchAnime, + AnimeInfo, + fetchEpisodeSources, + processServer, + fetchRecentEpisodes, + fetchEpisode, +}; diff --git a/queue_jobs.py b/queue_jobs.py new file mode 100644 index 0000000..4528605 --- /dev/null +++ b/queue_jobs.py @@ -0,0 +1,1776 @@ +#!/usr/bin/env python3 + +"""Queue and background job services for Kaizoku.""" + +import json +import os +import shutil +import signal +import sqlite3 +import subprocess +import threading +import time +import re + +from app_support import ( + DOWNLOAD_METHOD_CHOICES, + MAX_LOG_LINES, + PROJECT_ROOT, + QUEUE_PATH, + STATE_DB_PATH, + WORKER_SHUTDOWN_TIMEOUT_SECONDS, + build_job, + cli_executable_exists, + command_for_job, + debug_log, + finalize_library_files, + job_output_dir, + job_staging_dir, + job_stdout_enabled, + load_json, + normalize_config, + now_iso, + printable_command, + send_discord_webhook_event, + strip_control, +) + + +LOG_PERSIST_INTERVAL_SECONDS = 0.75 +LOG_PERSIST_LINE_BATCH = 8 +DOWNLOAD_BACKEND_COMMANDS = { + "kaizoku": "python3", +} +JOB_STRUCTURED_COLUMNS = ( + "show_id", + "title", + "anime_name", + "media_type", + "season", + "query", + "provider", + "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, config_getter=None, start_worker=True): + self.store = store + self.config_getter = config_getter or (lambda: {}) + 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_SHUTDOWN_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 _refresh_delay_seconds(self): + config = self.config_getter() or {} + try: + delay = int(str(config.get("watchlist_refresh_delay_seconds", 5)).strip() or "5") + except (TypeError, ValueError): + delay = 5 + return max(0, min(300, delay)) + + 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 _stdout_log(self, message): + print(f"[watchlist-refresh] {message}", flush=True) + + 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, source="manual"): + source_name = str(source or "manual").strip().lower() or "manual" + with self.lock: + job = next((candidate for candidate in self.jobs if candidate["status"] in {"pending", "running"}), None) + if job is not None: + if source_name == "scheduled": + self._stdout_log( + f"Scheduled refresh skipped because another refresh is already {job['status']} (job={job['id']})." + ) + 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", + "source": source_name, + "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() + if source_name == "scheduled": + self._stdout_log(f"Scheduled refresh queued (job={job['id']}).") + 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): + interrupted = False + source_name = str(job.get("source") or "manual").strip().lower() or "manual" + new_episode_items = [] + refresh_error_items = [] + try: + show_ids = self.store.all_show_ids(categories=("watching", "planned")) + delay_seconds = self._refresh_delay_seconds() + 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) + if source_name == "scheduled": + self._stdout_log(f"Scheduled refresh started (job={job['id']}, total={len(show_ids)}).") + + for index, show_id in enumerate(show_ids, start=1): + if self.stop_event.is_set(): + interrupted = True + break + title = show_id + errored = False + skipped = False + error_message = "" + try: + try: + item = self.store.refresh(show_id, include_thumbnail=False, refresh_source=source_name) + except TypeError as exc: + if "refresh_source" not in str(exc): + raise + 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 "" + if item.get("had_new_episodes"): + new_episode_items.append( + { + "show_id": item.get("show_id"), + "title": title, + "sub_count": item.get("sub_count"), + "dub_count": item.get("dub_count"), + "sub_delta": item.get("sub_delta"), + "dub_delta": item.get("dub_delta"), + "expected_count": item.get("expected_count"), + "airing_status": item.get("airing_status"), + "thumbnail_path": item.get("thumbnail_path"), + } + ) + except Exception as exc: + errored = True + error_message = str(exc) + debug_log("watchlist.refresh_all.item_error", show_id=show_id, error=exc) + with self.lock: + job["completed"] = index + job["errors"] += 1 if errored else 0 + job["current_title"] = title + job["updated_at"] = now_iso() + if errored and error_message: + job["last_error"] = error_message + refresh_error_items.append({"show_id": show_id, "title": title, "error": error_message}) + if 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) + if index < len(show_ids) and delay_seconds > 0: + if self.stop_event.wait(delay_seconds): + interrupted = True + break + + finished_at = now_iso() + with self.lock: + job["status"] = "interrupted" if interrupted else "done" + job["finished_at"] = finished_at + job["updated_at"] = finished_at + job["current_title"] = None + if interrupted: + job["message"] = f"Watchlist refresh interrupted after {job['completed']} entries." + elif 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) + if source_name == "scheduled": + if interrupted: + self._stdout_log( + f"Scheduled refresh interrupted (job={job['id']}, completed={job['completed']}/{job['total']})." + ) + elif job["errors"]: + self._stdout_log( + f"Scheduled refresh finished with errors (job={job['id']}, completed={job['completed']}/{job['total']}, errors={job['errors']})." + ) + else: + self._stdout_log( + f"Scheduled refresh finished successfully (job={job['id']}, completed={job['completed']}/{job['total']})." + ) + config = self.config_getter() or {} + if interrupted: + send_discord_webhook_event( + config, + "watchlist_refresh_interrupted", + { + "source": source_name, + "completed": job.get("completed"), + "total": job.get("total"), + "message": job.get("message"), + }, + ) + else: + if new_episode_items: + send_discord_webhook_event( + config, + "watchlist_refresh_new_episodes", + { + "source": source_name, + "job_id": job.get("id"), + "items": new_episode_items, + }, + ) + if job.get("errors"): + send_discord_webhook_event( + config, + "watchlist_refresh_failed", + { + "source": source_name, + "completed": job.get("completed"), + "total": job.get("total"), + "errors": job.get("errors"), + "items": refresh_error_items, + "error": job.get("last_error"), + }, + ) + 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}" + job["last_error"] = str(exc) + self._save_job_locked(job) + if source_name == "scheduled": + self._stdout_log(f"Scheduled refresh failed (job={job['id']}): {exc}") + send_discord_webhook_event( + self.config_getter() or {}, + "watchlist_refresh_failed", + { + "source": source_name, + "completed": job.get("completed"), + "total": job.get("total"), + "errors": max(1, int(job.get("errors") or 0)), + "items": refresh_error_items, + "error": str(exc), + }, + ) + finally: + with self.lock: + self.current_executor = None + self.current_job_id = None + + +class WatchlistAutoRefreshScheduler: + def __init__(self, config_getter, refresh_start_fn, start_worker=True): + self.config_getter = config_getter + self.refresh_start_fn = refresh_start_fn + self.lock = threading.RLock() + self.wakeup = threading.Event() + self.stop_event = threading.Event() + self.worker = None + self.last_enabled = None + self.last_interval_seconds = None + self.last_attempt_monotonic = None + if start_worker: + self.ensure_worker() + + 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() + self.wakeup.set() + worker = self.worker + if wait and worker is not None and worker.is_alive(): + worker.join(timeout=WORKER_SHUTDOWN_TIMEOUT_SECONDS) + return worker is None or not worker.is_alive() + + def notify_config_changed(self): + self.wakeup.set() + + def _config_state(self): + config = self.config_getter() or {} + enabled = bool(config.get("watchlist_auto_refresh_enabled")) + try: + minutes = int(str(config.get("watchlist_auto_refresh_minutes", 60)).strip() or "60") + except (TypeError, ValueError): + minutes = 60 + minutes = max(1, min(7 * 24 * 60, minutes)) + return enabled, minutes * 60 + + def tick(self, now_monotonic=None): + now = time.monotonic() if now_monotonic is None else float(now_monotonic) + enabled, interval_seconds = self._config_state() + with self.lock: + changed = enabled != self.last_enabled or interval_seconds != self.last_interval_seconds + if changed: + self.last_enabled = enabled + self.last_interval_seconds = interval_seconds + self.last_attempt_monotonic = now + if not enabled: + return None + if self.last_attempt_monotonic is None: + self.last_attempt_monotonic = now + return interval_seconds + remaining = interval_seconds - (now - self.last_attempt_monotonic) + if remaining > 0: + return remaining + self.last_attempt_monotonic = now + try: + result = self.refresh_start_fn(source="scheduled") + debug_log("watchlist.auto_refresh.tick", result=result) + except Exception as exc: + debug_log("watchlist.auto_refresh.error", error=exc) + send_discord_webhook_event( + self.config_getter() or {}, + "runtime_error", + { + "source": "watchlist.auto_refresh", + "error": str(exc), + }, + ) + return interval_seconds + + def _run(self): + while not self.stop_event.is_set(): + wait_seconds = self.tick() + self.wakeup.wait(timeout=wait_seconds) + self.wakeup.clear() + + +class JellyfinSyncJobs: + CONFIG_KEYS = ( + "download_dir", + "jellyfin_sync_enabled", + "jellyfin_tv_dir", + "jellyfin_movie_dir", + "discord_webhook_url", + "discord_webhook_events", + ) + + def __init__(self, store, sync_fn, config_getter=None, start_worker=True): + self.store = store + self.sync_fn = sync_fn + self.config_getter = config_getter or (lambda: {}) + self.lock = threading.RLock() + self.wakeup = threading.Event() + self.stop_event = threading.Event() + self.pending = [] + self.jobs = [] + self.current_job_id = 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() + self.wakeup.set() + worker = self.worker + if wait and worker is not None and worker.is_alive(): + worker.join(timeout=WORKER_SHUTDOWN_TIMEOUT_SECONDS) + return worker is None or not worker.is_alive() + + def _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 jellyfin_sync_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_jellyfin_sync_created_at ON jellyfin_sync_jobs(created_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_jellyfin_sync_status ON jellyfin_sync_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 jellyfin_sync_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 jellyfin_sync_jobs ORDER BY created_at DESC LIMIT -1 OFFSET 10" + ).fetchall() + if rows: + conn.executemany("DELETE FROM jellyfin_sync_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 jellyfin_sync_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 jellyfin_sync_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["current_file"] = 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 _queue_job_payload(self, job): + config = job.get("config") or {} + target_paths = [ + str(config.get(key) or "").strip() + for key in ("jellyfin_tv_dir", "jellyfin_movie_dir") + if str(config.get(key) or "").strip() + ] + log_lines = [str(job.get("message") or "")] + if job.get("current_file"): + log_lines.append(f"Moving: {job['current_file']}") + for item in job.get("items") or []: + title = str(item.get("title") or item.get("show_id") or "Unknown title") + outcome = "moved" if item.get("moved") else str(item.get("reason") or "skipped") + target = str(item.get("target") or "").strip() + suffix = f" -> {target}" if target else "" + log_lines.append(f"{title}: {outcome}{suffix}") + payload = self._copy_job(job) + payload.update( + { + "job_type": "jellyfin_handoff", + "title": "Jellyfin handoff", + "anime_name": "Jellyfin handoff", + "mode": "copy", + "quality": "library", + "episodes": f"{int(job.get('completed') or 0)}/{int(job.get('total') or 0)} entries", + "download_dir": str(config.get("download_dir") or ""), + "target_dir": ", ".join(target_paths), + "exit_code": 1 if job.get("status") == "failed" else None, + "log": [line for line in log_lines if line], + } + ) + return payload + + def list_queue_jobs(self, limit=50): + limit = min(200, max(1, int(limit or 50))) + with self.lock, self._connect() as conn: + rows = conn.execute( + "SELECT payload FROM jellyfin_sync_jobs ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [self._queue_job_payload(self._job_from_row(row)) for row in rows] + + def get_queue_job(self, job_id): + with self.lock, self._connect() as conn: + row = conn.execute("SELECT payload FROM jellyfin_sync_jobs WHERE id = ?", (job_id,)).fetchone() + if row: + return self._queue_job_payload(self._job_from_row(row)) + raise KeyError("Job not found") + + def remove_queue_job(self, job_id): + with self.lock: + job = self.get_queue_job(job_id) + if job["status"] in {"pending", "running"}: + raise ValueError("Running Jellyfin handoff jobs cannot be removed") + with self._connect() as conn: + conn.execute("DELETE FROM jellyfin_sync_jobs WHERE id = ?", (job_id,)) + return {"ok": True} + + def clear_finished_queue_jobs(self): + with self.lock, self._connect() as conn: + cursor = conn.execute("DELETE FROM jellyfin_sync_jobs WHERE status IN ('done', 'interrupted')") + return {"ok": True, "count": cursor.rowcount} + + def clear_failed_queue_jobs(self): + with self.lock, self._connect() as conn: + cursor = conn.execute("DELETE FROM jellyfin_sync_jobs WHERE status = 'failed'") + return {"ok": True, "count": cursor.rowcount} + + def _find_locked(self, job_id): + for job in self.jobs: + if job["id"] == job_id: + return job + return None + + def _job_config(self, config): + normalized = normalize_config(config or self.config_getter() or {}) + return {key: normalized.get(key) for key in self.CONFIG_KEYS} + + 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, config=None, source="manual"): + source_name = str(source or "manual").strip().lower() or "manual" + 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": "Jellyfin handoff already queued." if job["status"] == "pending" else "Jellyfin handoff already running.", + "job": self._copy_job(job), + "created": False, + } + now = now_iso() + job = { + "id": os.urandom(16).hex(), + "status": "pending", + "source": source_name, + "config": self._job_config(config), + "created_at": now, + "updated_at": now, + "started_at": None, + "finished_at": None, + "total": 0, + "completed": 0, + "moved": 0, + "errors": 0, + "moved_files": 0, + "current_title": None, + "current_file": None, + "message": "Queued Jellyfin handoff.", + "items": [], + } + 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": "Jellyfin handoff 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): + interrupted = False + result_items = [] + try: + active_config = self._job_config(job.get("config") or self.config_getter() or {}) + 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["moved"] = 0 + job["errors"] = 0 + job["moved_files"] = 0 + job["current_title"] = None + job["current_file"] = None + job["items"] = [] + job["message"] = f"Moving 0/{len(show_ids)} watchlist entries to Jellyfin." + self._save_job_locked(job) + + for index, show_id in enumerate(show_ids, start=1): + if self.stop_event.is_set(): + interrupted = True + break + try: + item = self.store.get(show_id) + except KeyError: + item = None + if item is None: + with self.lock: + job["completed"] = index + job["updated_at"] = now_iso() + job["message"] = f"Moving {index}/{len(show_ids)}: removed entry" + self._save_job_locked(job) + continue + + title = str(item.get("title") or show_id) + + def progress(update): + with self.lock: + job["current_title"] = title + job["current_file"] = update.get("current_file") + job["updated_at"] = now_iso() + file_total = int(update.get("file_total") or 0) + file_index = int(update.get("file_index") or 0) + if file_total > 0: + job["message"] = ( + f"Moving {index}/{len(show_ids)}: {title} " + f"({file_index}/{file_total} files)" + ) + else: + job["message"] = f"Moving {index}/{len(show_ids)}: {title}" + self._save_job_locked(job) + + result = self.sync_fn(item, automatic=False, config=active_config, progress_fn=progress) + result_items.append( + { + "show_id": item.get("show_id"), + "title": item.get("title"), + "media_type": item.get("media_type"), + "moved": bool(result.get("moved")), + "reason": result.get("reason"), + "target": result.get("target"), + } + ) + moved_files = len(result.get("moved_files") or []) if isinstance(result.get("moved_files"), list) else 0 + errored = str(result.get("reason") or "").strip() in {"error", "target_missing", "source_missing", "target_incomplete"} + with self.lock: + job["completed"] = index + job["moved"] += 1 if result.get("moved") else 0 + job["moved_files"] += moved_files + job["errors"] += 1 if errored else 0 + job["current_title"] = title + job["current_file"] = None + job["updated_at"] = now_iso() + job["items"] = result_items[-20:] + if errored and result.get("error"): + job["last_error"] = str(result.get("error")) + outcome = "moved" if result.get("moved") else str(result.get("reason") or "skipped") + job["message"] = f"Moving {index}/{len(show_ids)}: {title} ({outcome})" + self._save_job_locked(job) + + finished_at = now_iso() + with self.lock: + job["status"] = "interrupted" if interrupted else "done" + job["finished_at"] = finished_at + job["updated_at"] = finished_at + job["current_title"] = None + job["current_file"] = None + job["items"] = result_items[-20:] + if interrupted: + job["message"] = f"Jellyfin handoff interrupted after {job['completed']} entries." + elif job["errors"]: + job["message"] = ( + f"Moved {job['moved']} of {job['completed']} processed watchlist entries to Jellyfin " + f"with {job['errors']} issues." + ) + else: + job["message"] = ( + f"Moved {job['moved']} watchlist entr{'y' if job['moved'] == 1 else 'ies'} to Jellyfin libraries." + ) + 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["current_file"] = None + job["message"] = f"Jellyfin handoff failed: {exc}" + job["last_error"] = str(exc) + self._save_job_locked(job) + finally: + with self.lock: + 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_SHUTDOWN_TIMEOUT_SECONDS) + if worker.is_alive(): + self._signal_active_process(signal.SIGKILL) + worker.join(timeout=WORKER_SHUTDOWN_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, + media_type TEXT, + season TEXT, + query TEXT, + provider 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"), + ("media_type", "ALTER TABLE jobs ADD COLUMN media_type TEXT"), + ("season", "ALTER TABLE jobs ADD COLUMN season TEXT"), + ("query", "ALTER TABLE jobs ADD COLUMN query TEXT"), + ("provider", "ALTER TABLE jobs ADD COLUMN provider 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, media_type, season, query, provider, 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, + media_type = excluded.media_type, + season = excluded.season, + query = excluded.query, + provider = excluded.provider, + 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("media_type"), + job.get("season"), + job.get("query"), + job.get("provider"), + 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 get(self, job_id): + with self.lock: + return self._find(job_id) + + def _job_identity_fields(self, job): + return ( + str(job.get("show_id") or "").strip(), + str(job.get("title") or "").strip(), + str(job.get("anime_name") or "").strip(), + str(job.get("media_type") or "").strip(), + str(job.get("season") or "").strip(), + str(job.get("episode_offset") or "").strip(), + str(job.get("query") or "").strip(), + job.get("result_index"), + str(job.get("mode") or "").strip().lower(), + str(job.get("quality") or "").strip().lower(), + str(job.get("episodes") or "").strip(), + str(job.get("download_dir") or "").strip(), + ) + + def _find_reusable_failed_job_locked(self, job): + expected = self._job_identity_fields(job) + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * + FROM jobs + WHERE status = 'failed' + ORDER BY created_at DESC + """ + ).fetchall() + for row in rows: + candidate = self._job_from_row(row) + if self._job_identity_fields(candidate) == expected: + return candidate + return None + + def _reset_retryable_job_locked(self, job): + job["status"] = "pending" + job["exit_code"] = None + job["pid"] = None + job["started_at"] = None + job["finished_at"] = None + job["updated_at"] = now_iso() + job["cancel_requested"] = False + job["log"] = [] + self._save_job_locked(job) + return job + + def _prepare_job(self, job): + if self.job_prepare_fn is None: + return job + prepared = self.job_prepare_fn(dict(job)) + return prepared if prepared is not None else job + + def add(self, payload): + job = build_job(payload, self.config_getter()) + job = self._prepare_job(job) + with self.lock: + reusable = self._find_reusable_failed_job_locked(job) + if reusable is not None: + job = self._reset_retryable_job_locked(reusable) + else: + self._save_job_locked(job) + self.wakeup.set() + return job + + def _expand_episode_spec(self, episode_spec, available_episodes): + spec = str(episode_spec or "").strip() + values = [str(value).strip() for value in available_episodes or [] if str(value).strip()] + if not spec or not values: + return [] + expanded = [] + seen = set() + parts = [part for part in re.split(r"\s+", spec) if part] + index_map = {value: index for index, value in enumerate(values)} + for part in parts: + if "-" in part: + start, end = [piece.strip() for piece in part.split("-", 1)] + if start in index_map and end in index_map: + start_index = index_map[start] + end_index = index_map[end] + if start_index > end_index: + start_index, end_index = end_index, start_index + for value in values[start_index : end_index + 1]: + if value not in seen: + seen.add(value) + expanded.append(value) + continue + if part in index_map and part not in seen: + seen.add(part) + expanded.append(part) + return expanded + + def covered_episodes(self, show_id, mode, available_episodes): + normalized_show_id = str(show_id or "").strip() + normalized_mode = str(mode or "").strip().lower() + if not normalized_show_id or not normalized_mode: + return set() + with self.lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT episodes + FROM jobs + WHERE show_id = ? AND mode = ? AND status IN ('pending', 'running', 'done') + ORDER BY created_at ASC + """, + (normalized_show_id, normalized_mode), + ).fetchall() + covered = set() + for row in rows: + covered.update(self._expand_episode_spec((row["episodes"] if isinstance(row, sqlite3.Row) else row[0]), available_episodes)) + return covered + + def detach_show(self, show_id): + normalized_show_id = str(show_id or "").strip() + if not normalized_show_id: + return {"ok": True, "count": 0} + count = 0 + with self.lock, self._connect() as conn: + rows = conn.execute("SELECT * FROM jobs WHERE show_id = ?", (normalized_show_id,)).fetchall() + for row in rows: + job = self._job_from_row(row) + job["show_id"] = "" + job["skip_watchlist_sync"] = True + job["updated_at"] = now_iso() + self._upsert_job_conn(conn, job) + count += 1 + if self.current_job is not None and str(self.current_job.get("show_id") or "").strip() == normalized_show_id: + self.current_job["show_id"] = "" + self.current_job["skip_watchlist_sync"] = True + self.current_job["updated_at"] = now_iso() + return {"ok": True, "count": count} + + def retry(self, job_id): + with self.lock: + job = self._find(job_id) + if job["status"] == "running": + raise ValueError("Running jobs cannot be retried") + if job["status"] not in {"failed", "canceled"}: + raise ValueError("Only failed or canceled jobs can be retried") + job = self._prepare_job(job) + with self.lock: + job = self._reset_retryable_job_locked(job) + self.wakeup.set() + return job + + def retry_all_failed(self): + count = 0 + with self.lock: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM jobs WHERE status = 'failed'").fetchall() + for row in rows: + job = self._prepare_job(self._job_from_row(row)) + with self.lock: + self._reset_retryable_job_locked(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 clear_failed(self): + with self.lock, self._connect() as conn: + cursor = conn.execute("DELETE FROM jobs WHERE status = 'failed'") + 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 + self._stdout_log(job, text) + 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 _stdout_log(self, job, line): + if not job_stdout_enabled(): + return + text = strip_control(str(line)).strip() + if not text: + return + job_id = str((job or {}).get("id") or "-") + backend = str((job or {}).get("download_backend") or "download") + print(f"[download:{job_id}:{backend}] {text}", flush=True) + + def _cleanup_staging_dir(self, job): + staging_dir = job_staging_dir(job) + if not staging_dir.exists(): + return False + try: + shutil.rmtree(staging_dir) + debug_log("queue.cleanup.staging_removed", job_id=job.get("id"), staging_dir=staging_dir) + return True + except OSError as exc: + debug_log("queue.cleanup.staging_failed", job_id=job.get("id"), staging_dir=staging_dir, error=exc) + return False + + def _fail_job_startup(self, job, exc): + message = f"Could not start download: {exc}" + with self.lock: + job["status"] = "failed" + job["exit_code"] = 1 + job["pid"] = None + job["finished_at"] = now_iso() + job["updated_at"] = job["finished_at"] + job.setdefault("log", []).append(message) + self.current_process = None + self.current_job_id = None + self.current_job = None + self._save_job_locked(job) + self._cleanup_staging_dir(job) + + def _fail_job_runtime(self, job, exc, process=None): + if process is not None: + try: + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + except OSError: + try: + process.terminate() + except OSError: + pass + message = f"Download worker failed unexpectedly: {exc}" + with self.lock: + job["status"] = "failed" + job["exit_code"] = 1 + job["pid"] = None + job["finished_at"] = now_iso() + job["updated_at"] = job["finished_at"] + job.setdefault("log", []).append(message) + self.current_process = None + self.current_job_id = None + self.current_job = None + self._save_job_locked(job) + self._cleanup_staging_dir(job) + + def _command_attempts(self, job, staging_dir): + def attempt_for(method): + return { + "backend": method, + "command": command_for_job(job, backend=method, download_path=staging_dir), + "env": { + **os.environ.copy(), + "TERM": os.environ.get("TERM", "xterm-256color"), + }, + } + + config = normalize_config(self.config_getter() or {}) + selected_methods = [ + method + for method in config.get("download_methods") or ["kaizoku"] + if method in DOWNLOAD_METHOD_CHOICES + ] + if not selected_methods: + selected_methods = ["kaizoku"] + attempts = [] + unavailable_methods = [] + for method in selected_methods: + if not cli_executable_exists(DOWNLOAD_BACKEND_COMMANDS.get(method)): + unavailable_methods.append(method) + continue + attempts.append(attempt_for(method)) + return attempts, selected_methods, unavailable_methods + + 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): + staging_dir = job_staging_dir(job) + target_dir = job_output_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + target_dir.mkdir(parents=True, exist_ok=True) + attempts, selected_methods, unavailable_methods = self._command_attempts(job, staging_dir) + if not attempts: + with self.lock: + job["status"] = "failed" + job["started_at"] = now_iso() + job["finished_at"] = job["started_at"] + job["updated_at"] = job["finished_at"] + job["exit_code"] = 1 + job["pid"] = None + job["staging_dir"] = str(staging_dir) + job["target_dir"] = str(target_dir) + job["download_backend"] = selected_methods[0] if selected_methods else None + job["fallback_used"] = False + job["log"] = [ + f"Staging in: {job['staging_dir']}", + f"Final folder: {job['target_dir']}", + f"Download methods: {', '.join(selected_methods)}", + ( + "No selected download methods are installed or executable: " + f"{', '.join(unavailable_methods or selected_methods)}." + ), + ] + self.current_process = None + self.current_job_id = None + self.current_job = None + self._save_job_locked(job) + self._cleanup_staging_dir(job) + return + primary_command = attempts[0]["command"] + + with self.lock: + job["status"] = "running" + job["started_at"] = now_iso() + job["updated_at"] = job["started_at"] + job["command"] = printable_command(primary_command) + job["staging_dir"] = str(staging_dir) + job["target_dir"] = str(target_dir) + job["download_backend"] = attempts[0]["backend"] + job["fallback_used"] = False + job["log"] = [ + f"Starting: {job['command']}", + f"Staging in: {job['staging_dir']}", + f"Final folder: {job['target_dir']}", + f"Download methods: {', '.join(selected_methods)}", + ] + if len(attempts) > 1: + job["log"].append("Fallback retry is enabled for the selected download methods.") + if unavailable_methods: + job["log"].append( + "Skipped unavailable download method" + f"{'s' if len(unavailable_methods) != 1 else ''}: {', '.join(unavailable_methods)}." + ) + job["cancel_requested"] = False + job["_dirty_log_lines"] = 0 + self._save_job_locked(job) + + try: + exit_code = 1 + successful_backend = None + finalize_error = None + moved_files = [] + watchlist_sync_error = None + synced_watchlist_item = None + cleanup_staging = False + for index, attempt in enumerate(attempts): + backend = attempt["backend"] + command = attempt["command"] + should_fallback = index + 1 < len(attempts) + with self.lock: + job["download_backend"] = backend + job["command"] = printable_command(command) + if index > 0: + job["fallback_used"] = True + job["pid"] = None + job["updated_at"] = now_iso() + if index > 0: + job.setdefault("log", []).append(f"Retrying with {backend}: {job['command']}") + self._save_job_locked(job) + try: + process = subprocess.Popen( + command, + cwd=str(PROJECT_ROOT), + env=attempt["env"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + ) + except Exception as exc: + if should_fallback: + self._append_log(job, f"{backend} could not start: {exc}") + self._append_log(job, f"Retrying with {attempts[index + 1]['backend']} fallback.") + self._cleanup_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + continue + self._fail_job_startup(job, exc) + return + 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() + with self.lock: + job["pid"] = None + self.current_process = None + self.current_job_id = None + self.current_job = None + self._save_job_locked(job) + + if job.get("cancel_requested"): + break + + if exit_code != 0 and should_fallback: + self._append_log(job, f"{backend} failed with exit code {exit_code}.") + self._append_log(job, f"Retrying with {attempts[index + 1]['backend']} fallback.") + self._cleanup_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + continue + + if exit_code == 0: + try: + moved_files = finalize_library_files(job) + except Exception as exc: + finalize_error = exc + no_files_downloaded = "No downloaded files were found in the staging folder" in str(exc) + if no_files_downloaded and should_fallback: + self._append_log( + job, + f"{backend} exited without downloading any files. Retrying with {attempts[index + 1]['backend']} fallback.", + ) + self._cleanup_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + finalize_error = None + continue + else: + successful_backend = backend + if self.watchlist_sync_fn is not None: + try: + synced_watchlist_item = self.watchlist_sync_fn(job) + except Exception as exc: + watchlist_sync_error = exc + break + break + + 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" + message = str(finalize_error).strip() + if "No downloaded files were found in the staging folder" in message: + job.setdefault("log", []).append( + f"{job.get('download_backend') or 'Downloader'} exited without downloading any files. The selected result or episodes may not be downloadable." + ) + else: + job.setdefault("log", []).append(f"Library rename failed: {finalize_error}") + cleanup_staging = True + elif exit_code == 0: + job["status"] = "done" + if successful_backend and successful_backend != attempts[0]["backend"]: + job.setdefault("log", []).append(f"Fallback download completed with {successful_backend}.") + 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: + if job.get("skip_watchlist_sync"): + job.setdefault("log", []).append("Watchlist sync skipped for detached job.") + else: + job.setdefault("log", []).append("Watchlist download state synced.") + else: + job["status"] = "failed" + job.setdefault("log", []).append( + f"{job.get('download_backend') or 'Download'} failed with exit code {exit_code}." + ) + cleanup_staging = True + if canceled: + cleanup_staging = True + self.current_process = None + self.current_job_id = None + self.current_job = None + self._save_job_locked(job) + if cleanup_staging: + self._cleanup_staging_dir(job) + elif exit_code == 0 and not finalize_error and not canceled: + download_title = ( + str((synced_watchlist_item or {}).get("title") or "").strip() + or str(job.get("title") or "").strip() + or str(job.get("query") or "").strip() + or "Unknown anime" + ) + try: + send_discord_webhook_event( + self.config_getter() or {}, + "download_completed", + { + "show_id": (synced_watchlist_item or {}).get("show_id") or job.get("show_id"), + "title": download_title, + "mode": job.get("mode"), + "episodes": job.get("episodes"), + "category": (synced_watchlist_item or {}).get("category") or "finished", + "thumbnail_path": (synced_watchlist_item or {}).get("thumbnail_path"), + "moved_files": moved_files, + }, + ) + except Exception as exc: + debug_log("discord_webhook.download_completed_failed", title=download_title, error=exc) + except Exception as exc: + self._fail_job_runtime(job, exc, process=process) diff --git a/queue_page.py b/queue_page.py new file mode 100644 index 0000000..c900d1f --- /dev/null +++ b/queue_page.py @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 + +"""Queue page template for Kaizoku.""" + +from template_helpers import BROWSER_API_HELPER, BROWSER_POLL_HELPER, render_page_links, render_sidebar_brand + + +QUEUE_HTML = r""" + + + + + Kaizoku queue + + + + +
+ + +
+
+
+

Download queue

+

Newest log lines are shown first for each job.

+
+
+
+ +
+
+
+
+ + + + +""" diff --git a/search_page.py b/search_page.py new file mode 100644 index 0000000..3069a68 --- /dev/null +++ b/search_page.py @@ -0,0 +1,750 @@ +#!/usr/bin/env python3 + +"""Search page template for Kaizoku.""" + +from template_helpers import BROWSER_API_HELPER, render_page_links, render_sidebar_brand + + +INDEX_HTML = r""" + + + + + Kaizoku + + + + +
+ + +
+
+
+
+

Select a result

+

Episodes will appear here.

+
+
+ + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+
+
+
+
+
+

Select results

+

Poster cards appear here after a search.

+
+
+
+
+
+
+ + + + +""" diff --git a/template_helpers.py b/template_helpers.py new file mode 100644 index 0000000..3eb6c62 --- /dev/null +++ b/template_helpers.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +"""Shared page-shell helpers for inline HTML templates.""" + +PAGE_LINKS = ( + ("search", "Search", "/"), + ("queue", "Queue", "/queue"), + ("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; + let active = true; + let timer = null; + const stop = () => { + active = false; + if (timer !== null) { + window.clearTimeout(timer); + timer = null; + } + }; + const tick = async () => { + if (!active || inFlight) { + if (active) timer = window.setTimeout(tick, intervalMs); + return; + } + inFlight = true; + try { + await callback(); + } finally { + inFlight = false; + if (active) timer = window.setTimeout(tick, intervalMs); + } + }; + window.addEventListener("pagehide", stop, { once: true }); + window.addEventListener("beforeunload", stop, { once: true }); + timer = window.setTimeout(tick, intervalMs); + return { stop }; + } +""" + + +def render_sidebar_brand(note, badge, badge_id=""): + badge_attr = f' id="{badge_id}"' if badge_id else "" + return ( + '
\n' + '
\n' + '

ani-cli web

\n' + f"

{note}

\n" + "
\n" + f" {badge}\n" + "
\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' {label}') + return " \n" diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..7aa8b43 --- /dev/null +++ b/test_app.py @@ -0,0 +1,4243 @@ +#!/usr/bin/env python3 +import base64 +import importlib.util +import io +import json +import os +import sys +import tempfile +import threading +import unittest +from http import HTTPStatus +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parent +MODULE_PATH = ROOT / "app.py" +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 + +SPEC = importlib.util.spec_from_file_location("ani_cli_web_app", MODULE_PATH) +APP = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(APP) +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 + and APP.WATCHLIST_JELLYFIN_SYNC 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) + self.headers.setdefault("Content-Type", "application/json") + self.rfile = io.BytesIO(body) + self.json_payload = None + self.json_status = None + self.error_status = None + self.error_message = None + self.file_path = None + self.response_status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def ensure_client_access(self): + return APP.Handler.ensure_client_access(self) + + def body_json(self): + return APP.Handler.body_json(self) + + def json(self, payload, status=HTTPStatus.OK): + self.json_payload = payload + self.json_status = status + + def json_attachment(self, payload, filename, status=HTTPStatus.OK): + return APP.Handler.json_attachment(self, payload, filename, status=status) + + def write_response_bytes(self, payload, status, headers): + return APP.Handler.write_response_bytes(self, payload, status, headers) + + def error(self, status, message): + self.error_status = status + self.error_message = message + + def file(self, path): + self.file_path = path + + def html(self, _content): + raise AssertionError("HTML should not be served in this test") + + def exception(self, exc): + return APP.Handler.exception(self, exc) + + def send_response(self, status): + self.response_status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + return None + + +class NetworkGuardTests(unittest.TestCase): + def test_runtime_bootstrap_is_deferred_until_initialize(self): + self.assertTrue(IMPORT_RUNTIME_WAS_DEFERRED) + + def test_client_address_is_local_accepts_loopback(self): + self.assertTrue(APP.client_address_is_local("127.0.0.1")) + self.assertTrue(APP.client_address_is_local("::1")) + self.assertTrue(APP.client_address_is_local("localhost")) + + def test_client_address_is_local_rejects_non_loopback(self): + 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_credentials_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("AUTH_USERNAME", str(ctx.exception)) + + def test_remote_access_accepts_valid_basic_auth_for_non_local_clients(self): + handler = DummyHandler("/api/config") + handler.client_address = ("192.168.1.25", 8421) + encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") + handler.headers["Authorization"] = f"Basic {encoded}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.ensure_client_access(handler) + + def test_remote_access_accepts_valid_session_cookie_for_non_local_clients(self): + handler = DummyHandler("/api/config") + handler.client_address = ("192.168.1.25", 8421) + session_value = http_handler.remote_access_session_value( + {"auth_username": "demo", "auth_password": "secret-pass"} + ) + handler.headers["Cookie"] = f"ani_cli_web_session={session_value}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.ensure_client_access(handler) + + def test_remote_browser_get_without_session_renders_login_page(self): + handler = DummyHandler("/") + handler.client_address = ("192.168.1.25", 8421) + handler.command = "GET" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + body = handler.wfile.getvalue().decode("utf-8") + self.assertEqual(handler.response_status, HTTPStatus.UNAUTHORIZED) + self.assertIn('
', body) + self.assertIn("Username", body) + self.assertIn("Password", body) + + def test_favicon_route_serves_checked_in_png(self): + handler = DummyHandler("/favicon.png") + handler.command = "GET" + + APP.Handler.do_GET(handler) + + self.assertEqual(handler.file_path, http_handler.FAVICON_PATH) + + def test_remote_login_page_escapes_hidden_next_value(self): + handler = DummyHandler('/?next=">') + handler.client_address = ("192.168.1.25", 8421) + handler.command = "GET" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + body = handler.wfile.getvalue().decode("utf-8") + self.assertNotIn('', body) + self.assertIn('value="/?next=%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"', body) + + def test_remote_login_post_sets_cookie_and_redirects(self): + body = b"username=demo&password=secret-pass&next=%2Fwatchlist" + handler = DummyHandler("/auth/login", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Content-Type"] = "application/x-www-form-urlencoded" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + headers = dict(handler.response_headers) + self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER) + self.assertEqual(headers.get("Location"), "/watchlist") + self.assertIn("ani_cli_web_session=", headers.get("Set-Cookie", "")) + + def test_remote_session_cookie_rejects_missing_origin_on_post(self): + body = b'{"show_id":"show-1"}' + handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Host"] = "anime.example:8421" + handler.headers["Cookie"] = ( + "ani_cli_web_session=" + + http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"}) + ) + handler.handler_context = mock.Mock(remove_from_watchlist=mock.Mock(return_value={"ok": True}), http_error=APP.HttpError) + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN) + self.assertIn("origin", handler.error_message.lower()) + + def test_remote_session_cookie_rejects_cross_origin_post(self): + body = b'{"show_id":"show-1"}' + handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Host"] = "anime.example:8421" + handler.headers["Origin"] = "https://elsewhere.example" + handler.headers["Cookie"] = ( + "ani_cli_web_session=" + + http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"}) + ) + handler.handler_context = mock.Mock(remove_from_watchlist=mock.Mock(return_value={"ok": True}), http_error=APP.HttpError) + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN) + self.assertIn("cross-origin", handler.error_message.lower()) + + def test_remote_session_cookie_accepts_same_origin_post(self): + body = b'{"show_id":"show-1"}' + handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Host"] = "anime.example:8421" + handler.headers["Origin"] = "https://anime.example:8421" + handler.headers["Cookie"] = ( + "ani_cli_web_session=" + + http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"}) + ) + remove_from_watchlist = mock.Mock(return_value={"ok": True}) + handler.handler_context = mock.Mock(remove_from_watchlist=remove_from_watchlist, http_error=APP.HttpError) + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + remove_from_watchlist.assert_called_once_with("show-1") + + def test_search_thumbnail_route_serves_cached_file(self): + with tempfile.TemporaryDirectory() as temp_dir: + image_path = Path(temp_dir) / "search-thumb.jpg" + image_path.write_bytes(b"fake-image-data") + handler = DummyHandler("/api/search/thumb?title=Demo") + handler.handler_context = mock.Mock( + ensure_runtime=mock.Mock(), + runtime_state=mock.Mock(return_value={"config": {}}), + resolve_search_thumbnail=mock.Mock(return_value=image_path), + ) + + APP.Handler.do_GET(handler) + + self.assertEqual(handler.file_path, image_path) + + def test_loopback_proxy_with_forwarded_remote_ip_requires_credentials(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("AUTH_USERNAME", 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() + queue.current_job_id = "job-1" + queue.current_process = mock.Mock(pid=4321) + queue.current_job = {"id": "job-1", "status": "running", "cancel_requested": False} + + saved = [] + queue._save_job_locked = lambda job: saved.append(dict(job)) + queue._find = lambda job_id: {"id": job_id, "status": "running", "cancel_requested": False} + + with mock.patch.object(APP.os, "getpgid", return_value=4321), mock.patch.object(APP.os, "killpg") as killpg: + result = APP.DownloadQueue.cancel(queue, "job-1") + + killpg.assert_called_once() + self.assertTrue(result["cancel_requested"]) + self.assertTrue(queue.current_job["cancel_requested"]) + 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_append_log_can_mirror_download_output_to_stdout(self): + queue = object.__new__(APP.DownloadQueue) + queue.lock = threading.RLock() + queue._save_job_locked = lambda _job: None + job = {"id": "job-1", "download_backend": "animdl", "log": [], "updated_at": APP.now_iso()} + + with mock.patch.dict(os.environ, {"ANI_CLI_WEB_JOB_STDOUT": "true"}, clear=False), mock.patch("builtins.print") as print_mock: + APP.DownloadQueue._append_log(queue, job, "\x1b[32mDownloading episode 1\x1b[0m\n") + + print_mock.assert_called_once_with( + "[download:job-1:animdl] Downloading episode 1", + flush=True, + ) + self.assertEqual(job["log"], ["Downloading episode 1"]) + + +class QueueApiTests(unittest.TestCase): + def setUp(self): + APP.reset_runtime(wait=True) + APP.initialize_runtime(start_workers=False) + self.queue = APP.DOWNLOAD_QUEUE + with self.queue._connect() as conn: + conn.execute("DELETE FROM jobs") + conn.execute("DELETE FROM jellyfin_sync_jobs") + + def test_download_queue_get_returns_single_job(self): + created = self.queue.add( + { + "query": "Example Show", + "title": "Example Show", + "anime_name": "Example Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + + fetched = self.queue.get(created["id"]) + + self.assertEqual(fetched["id"], created["id"]) + self.assertEqual(fetched["title"], "Example Show") + + def test_queue_item_get_route_returns_single_job_payload(self): + created = self.queue.add( + { + "query": "Route Show", + "title": "Route Show", + "anime_name": "Route Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + handler = DummyHandler(f"/api/queue/{created['id']}") + + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["id"], created["id"]) + + def test_queue_route_includes_jellyfin_handoff_jobs(self): + APP.WATCHLIST_JELLYFIN_SYNC.start( + config={ + "download_dir": "/tmp/downloads", + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": "/tmp/jellyfin-tv", + "jellyfin_movie_dir": "/tmp/jellyfin-movies", + } + ) + handler = DummyHandler("/api/queue?page=1&per_page=10") + + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + jellyfin_jobs = [job for job in handler.json_payload["jobs"] if job.get("job_type") == "jellyfin_handoff"] + self.assertEqual(len(jellyfin_jobs), 1) + self.assertEqual(jellyfin_jobs[0]["title"], "Jellyfin handoff") + self.assertEqual(jellyfin_jobs[0]["mode"], "copy") + + def test_queue_item_get_route_returns_jellyfin_handoff_job(self): + created = APP.WATCHLIST_JELLYFIN_SYNC.start( + config={ + "download_dir": "/tmp/downloads", + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": "/tmp/jellyfin-tv", + "jellyfin_movie_dir": "", + } + )["job"] + handler = DummyHandler(f"/api/queue/{created['id']}") + + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["id"], created["id"]) + self.assertEqual(handler.json_payload["job_type"], "jellyfin_handoff") + + def test_retry_rejects_done_jobs(self): + created = self.queue.add( + { + "query": "Done Show", + "title": "Done Show", + "anime_name": "Done Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + with self.queue.lock: + job = self.queue._find(created["id"]) + job["status"] = "done" + self.queue._save_job_locked(job) + + with self.assertRaisesRegex(ValueError, "Only failed or canceled jobs can be retried"): + self.queue.retry(created["id"]) + + def test_retry_allows_canceled_jobs(self): + created = self.queue.add( + { + "query": "Canceled Show", + "title": "Canceled Show", + "anime_name": "Canceled Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + with self.queue.lock: + job = self.queue._find(created["id"]) + job["status"] = "canceled" + job["cancel_requested"] = True + self.queue._save_job_locked(job) + + retried = self.queue.retry(created["id"]) + + self.assertEqual(retried["status"], "pending") + self.assertFalse(retried["cancel_requested"]) + + def test_retry_prepares_failed_job_before_requeue(self): + queue = APP.DownloadQueue( + lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, + job_prepare_fn=lambda job: {**job, "result_index": 3}, + start_worker=False, + ) + created = queue.add( + { + "query": "Retry Show", + "title": "Retry Show", + "anime_name": "Retry Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + with queue.lock: + job = queue._find(created["id"]) + job["status"] = "failed" + job["result_index"] = None + queue._save_job_locked(job) + + retried = queue.retry(created["id"]) + + self.assertEqual(retried["status"], "pending") + self.assertEqual(retried["result_index"], 3) + + def test_retry_all_failed_prepares_jobs_before_requeue(self): + queue = APP.DownloadQueue( + lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, + job_prepare_fn=lambda job: {**job, "result_index": 4}, + start_worker=False, + ) + created = queue.add( + { + "query": "Retry All Show", + "title": "Retry All Show", + "anime_name": "Retry All Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + with queue.lock: + job = queue._find(created["id"]) + job["status"] = "failed" + job["result_index"] = None + queue._save_job_locked(job) + + result = queue.retry_all_failed() + retried = queue.get(created["id"]) + + self.assertEqual(result["count"], 1) + self.assertEqual(retried["status"], "pending") + self.assertEqual(retried["result_index"], 4) + + def test_add_reuses_matching_failed_job_instead_of_creating_duplicate(self): + payload = { + "show_id": "show-42", + "query": "Failed Show", + "title": "Failed Show", + "anime_name": "Failed Show", + "media_type": "tv", + "mode": "dub", + "quality": "best", + "episodes": "11", + "download_dir": "/tmp/example", + "season": "2", + "episode_offset": "13", + } + created = self.queue.add(payload) + with self.queue.lock: + job = self.queue._find(created["id"]) + job["status"] = "failed" + job["exit_code"] = 1 + job["cancel_requested"] = True + job["started_at"] = APP.now_iso() + job["finished_at"] = APP.now_iso() + job["log"] = ["Download failed"] + self.queue._save_job_locked(job) + + retried = self.queue.add(payload) + listing = self.queue.list(per_page=10) + + self.assertEqual(retried["id"], created["id"]) + self.assertEqual(retried["status"], "pending") + self.assertEqual(retried["log"], []) + self.assertIsNone(retried["exit_code"]) + self.assertFalse(retried["cancel_requested"]) + self.assertEqual(listing["total"], 1) + + def test_add_keeps_new_job_when_failed_job_differs(self): + created = self.queue.add( + { + "show_id": "show-42", + "query": "Failed Show", + "title": "Failed Show", + "anime_name": "Failed Show", + "media_type": "tv", + "mode": "dub", + "quality": "best", + "episodes": "11", + "download_dir": "/tmp/example", + "season": "2", + "episode_offset": "13", + } + ) + with self.queue.lock: + job = self.queue._find(created["id"]) + job["status"] = "failed" + self.queue._save_job_locked(job) + + new_job = self.queue.add( + { + "show_id": "show-42", + "query": "Failed Show", + "title": "Failed Show", + "anime_name": "Failed Show", + "media_type": "tv", + "mode": "dub", + "quality": "720", + "episodes": "11", + "download_dir": "/tmp/example", + "season": "2", + "episode_offset": "13", + } + ) + listing = self.queue.list(per_page=10) + + self.assertNotEqual(new_job["id"], created["id"]) + self.assertEqual(listing["total"], 2) + + def test_clear_failed_removes_only_failed_jobs(self): + failed = self.queue.add( + { + "query": "Failed Show", + "title": "Failed Show", + "anime_name": "Failed Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + done = self.queue.add( + { + "query": "Done Show", + "title": "Done Show", + "anime_name": "Done Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + with self.queue.lock: + failed_job = self.queue._find(failed["id"]) + failed_job["status"] = "failed" + self.queue._save_job_locked(failed_job) + done_job = self.queue._find(done["id"]) + done_job["status"] = "done" + self.queue._save_job_locked(done_job) + + result = self.queue.clear_failed() + listing = self.queue.list() + + self.assertEqual(result["count"], 1) + self.assertEqual([job["id"] for job in listing["jobs"]], [done["id"]]) + + def test_detach_show_clears_queue_coverage_and_sync_binding(self): + created = self.queue.add( + { + "show_id": "show-42", + "query": "Queued Show", + "title": "Queued Show", + "anime_name": "Queued Show", + "mode": "dub", + "quality": "best", + "episodes": "1-3", + "download_dir": "/tmp/example", + "season": "1", + } + ) + + result = self.queue.detach_show("show-42") + detached = self.queue.get(created["id"]) + + self.assertEqual(result["count"], 1) + self.assertEqual(detached["show_id"], "") + self.assertTrue(detached["skip_watchlist_sync"]) + self.assertEqual(self.queue.covered_episodes("show-42", "dub", ["1", "2", "3"]), set()) + + 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", + "media_type": "movie", + "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["media_type"], "movie") + 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"]) + self.assertEqual(restored["media_type"], "movie") + + def test_movie_finalizer_uses_movie_folder_and_single_file_name(self): + with tempfile.TemporaryDirectory() as temp_root: + job = APP.app_support.build_job( + { + "query": "Movie Show", + "title": "Movie Show", + "anime_name": "Movie Show", + "media_type": "movie", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": temp_root, + "season": "1", + }, + {"mode": "sub", "quality": "best", "download_dir": temp_root}, + ) + staging_dir = APP.app_support.job_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + source = staging_dir / "Movie Show Episode 1.mp4" + source.write_bytes(b"movie-bytes") + + moved = APP.app_support.finalize_library_files(job) + + self.assertEqual(moved, [f"{temp_root}/movie/Movie Show/Movie Show.mp4"]) + self.assertFalse(source.exists()) + self.assertTrue(Path(moved[0]).exists()) + + def test_tv_finalizer_applies_episode_offset_to_named_files(self): + with tempfile.TemporaryDirectory() as temp_root: + job = APP.app_support.build_job( + { + "query": "Split Season Show", + "title": "Split Season Show", + "anime_name": "Split Season Show", + "media_type": "tv", + "mode": "sub", + "quality": "best", + "episodes": "1-2", + "download_dir": temp_root, + "season": "2", + "episode_offset": "13", + }, + {"mode": "sub", "quality": "best", "download_dir": temp_root}, + ) + staging_dir = APP.app_support.job_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + first = staging_dir / "Split Season Show Episode 1.mp4" + second = staging_dir / "Split Season Show Episode 2.mp4" + first.write_bytes(b"ep1") + second.write_bytes(b"ep2") + + moved = APP.app_support.finalize_library_files(job) + + self.assertEqual( + moved, + [ + f"{temp_root}/tv/Split Season Show/Season 02/Split Season Show - S02E13.mp4", + f"{temp_root}/tv/Split Season Show/Season 02/Split Season Show - S02E14.mp4", + ], + ) + self.assertTrue(Path(moved[0]).exists()) + self.assertTrue(Path(moved[1]).exists()) + + def test_tv_finalizer_uses_requested_episode_numbers_for_anipy_fallback(self): + with tempfile.TemporaryDirectory() as temp_root: + job = APP.app_support.build_job( + { + "query": "Fallback Show", + "title": "Fallback Show", + "anime_name": "Fallback Show", + "media_type": "tv", + "mode": "sub", + "quality": "best", + "episodes": "11", + "download_dir": temp_root, + "season": "2", + }, + {"mode": "sub", "quality": "best", "download_dir": temp_root}, + ) + job["download_backend"] = "anipy-cli" + staging_dir = APP.app_support.job_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + source = staging_dir / "Fallback Show - S02E01.mp4" + source.write_bytes(b"fallback") + + moved = APP.app_support.finalize_library_files(job) + + self.assertEqual( + moved, + [f"{temp_root}/tv/Fallback Show/Season 02/Fallback Show - S02E11.mp4"], + ) + self.assertTrue(Path(moved[0]).exists()) + + def test_tv_finalizer_uses_requested_episode_numbers_for_animdl_fallback(self): + with tempfile.TemporaryDirectory() as temp_root: + job = APP.app_support.build_job( + { + "query": "Fallback Show", + "title": "Fallback Show", + "anime_name": "Fallback Show", + "media_type": "tv", + "mode": "sub", + "quality": "best", + "episodes": "12", + "download_dir": temp_root, + "season": "2", + }, + {"mode": "sub", "quality": "best", "download_dir": temp_root}, + ) + job["download_backend"] = "animdl" + staging_dir = APP.app_support.job_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + source = staging_dir / "Fallback Show - S02E01.mp4" + source.write_bytes(b"fallback") + + moved = APP.app_support.finalize_library_files(job) + + self.assertEqual( + moved, + [f"{temp_root}/tv/Fallback Show/Season 02/Fallback Show - S02E12.mp4"], + ) + self.assertTrue(Path(moved[0]).exists()) + + 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 DownloadQueueWorkerFailureTests(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_run_job_marks_startup_failure_and_cleans_staging(self): + queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) + staging_dir = queue_jobs.job_staging_dir(job) + + with mock.patch.object(queue_jobs.subprocess, "Popen", side_effect=FileNotFoundError("ani-cli missing")): + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "failed") + self.assertEqual(stored["exit_code"], 1) + self.assertIn("Could not start download", stored["log"][-1]) + self.assertFalse(staging_dir.exists()) + + def test_failed_download_cleans_staging_dir(self): + queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) + staging_dir = queue_jobs.job_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + (staging_dir / "partial.mp4").write_text("partial", encoding="utf-8") + + class FakeProc: + pid = 4321 + stdout = [] + + def wait(self): + return 1 + + with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()): + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "failed") + self.assertFalse(staging_dir.exists()) + + def test_zero_exit_without_downloaded_files_marks_job_failed_with_clear_message(self): + queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1-10"}) + staging_dir = queue_jobs.job_staging_dir(job) + + class FakeProc: + pid = 4321 + stdout = ["Checking dependencies...\n"] + + def wait(self): + return 0 + + with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()): + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "failed") + self.assertEqual(stored["exit_code"], 1) + self.assertIn("without downloading any files", "\n".join(stored["log"])) + self.assertFalse(staging_dir.exists()) + + def test_run_job_marks_post_start_exception_failed_without_killing_worker_state(self): + queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) + staging_dir = queue_jobs.job_staging_dir(job) + + class BrokenStdout: + def __iter__(self): + return self + + def __next__(self): + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "bad output") + + class FakeProc: + pid = 4321 + stdout = BrokenStdout() + + def terminate(self): + return None + + with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), mock.patch.object( + queue_jobs.os, "getpgid", side_effect=OSError("gone") + ): + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "failed") + self.assertEqual(stored["exit_code"], 1) + self.assertIn("unexpectedly", stored["log"][-1].lower()) + self.assertIsNone(queue.current_process) + self.assertIsNone(queue.current_job_id) + self.assertIsNone(queue.current_job) + self.assertFalse(staging_dir.exists()) + + def test_successful_download_sends_completion_notification(self): + queue = APP.DownloadQueue( + lambda: { + "mode": "sub", + "quality": "best", + "download_dir": "/tmp/example", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": ["download_completed"], + }, + watchlist_sync_fn=lambda _job: { + "show_id": "show-55", + "title": "Queue Show", + "category": "finished", + "thumbnail_path": None, + }, + start_worker=False, + ) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1-2"}) + + class FakeProc: + pid = 4321 + stdout = [] + + def wait(self): + return 0 + + with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), mock.patch.object( + queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"] + ), mock.patch.object(queue_jobs, "send_discord_webhook_event") as send_webhook: + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "done") + self.assertIn("Download completed.", stored["log"]) + send_webhook.assert_called_once() + self.assertEqual(send_webhook.call_args.args[1], "download_completed") + self.assertEqual(send_webhook.call_args.args[2]["title"], "Queue Show") + self.assertEqual(send_webhook.call_args.args[2]["category"], "finished") + self.assertEqual(send_webhook.call_args.args[2]["moved_files"], ["/tmp/example/Queue Show - S01E01.mp4"]) + + def test_successful_detached_download_logs_sync_skipped(self): + queue = APP.DownloadQueue( + lambda: { + "mode": "sub", + "quality": "best", + "download_dir": "/tmp/example", + }, + watchlist_sync_fn=lambda _job: None, + start_worker=False, + ) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) + job["skip_watchlist_sync"] = True + + class FakeProc: + pid = 4321 + stdout = [] + + def wait(self): + return 0 + + with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), mock.patch.object( + queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"] + ): + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertIn("Watchlist sync skipped for detached job.", stored["log"]) + self.assertNotIn("Watchlist download state synced.", stored["log"]) + + def test_failed_ani_cli_retries_with_anipy_when_enabled(self): + queue = APP.DownloadQueue( + lambda: { + "mode": "sub", + "quality": "best", + "download_dir": "/tmp/example", + "anipy_cli_fallback_enabled": True, + }, + start_worker=False, + ) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) + commands = [] + + class FakeProc: + def __init__(self, pid, stdout, exit_code): + self.pid = pid + self.stdout = stdout + self._exit_code = exit_code + + def wait(self): + return self._exit_code + + processes = [ + FakeProc(4321, ["ani-cli attempt\n"], 1), + FakeProc(5432, ["anipy-cli attempt\n"], 0), + ] + + def fake_popen(command, **kwargs): + commands.append(command) + return processes.pop(0) + + with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object( + queue_jobs.subprocess, "Popen", side_effect=fake_popen + ), mock.patch.object( + queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"] + ): + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "done") + self.assertTrue(stored["fallback_used"]) + self.assertEqual(commands[0][0], APP.app_support.ANI_CLI) + self.assertEqual(commands[1][0], APP.app_support.ANIPY_CLI) + self.assertIn("Fallback download completed with anipy-cli.", stored["log"]) + + def test_selected_unavailable_animdl_fails_without_start_attempt(self): + queue = APP.DownloadQueue( + lambda: { + "mode": "sub", + "quality": "best", + "download_dir": "/tmp/example", + "download_methods": ["animdl"], + }, + start_worker=False, + ) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) + staging_dir = queue_jobs.job_staging_dir(job) + + with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=False), mock.patch.object( + queue_jobs.subprocess, "Popen" + ) as popen: + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "failed") + self.assertEqual(stored["download_backend"], "animdl") + self.assertIn("No selected download methods are installed or executable: animdl.", stored["log"]) + self.assertFalse(staging_dir.exists()) + popen.assert_not_called() + + def test_selected_download_methods_retry_through_animdl(self): + queue = APP.DownloadQueue( + lambda: { + "mode": "sub", + "quality": "best", + "download_dir": "/tmp/example", + "download_methods": ["ani-cli", "anipy-cli", "animdl"], + }, + start_worker=False, + ) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) + commands = [] + envs = [] + + class FakeProc: + def __init__(self, pid, stdout, exit_code): + self.pid = pid + self.stdout = stdout + self._exit_code = exit_code + + def wait(self): + return self._exit_code + + processes = [ + FakeProc(4321, ["ani-cli attempt\n"], 1), + FakeProc(5432, ["anipy-cli attempt\n"], 1), + FakeProc(6543, ["animdl attempt\n"], 0), + ] + + def fake_popen(command, **kwargs): + commands.append(command) + envs.append(kwargs.get("env", {})) + return processes.pop(0) + + with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object( + queue_jobs.subprocess, "Popen", side_effect=fake_popen + ), mock.patch.object( + queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"] + ): + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "done") + self.assertTrue(stored["fallback_used"]) + self.assertEqual([command[0] for command in commands], [APP.app_support.ANI_CLI, APP.app_support.ANIPY_CLI, APP.app_support.ANIMDL]) + self.assertEqual(envs[0]["ANI_CLI_PLAYER"], "download") + self.assertIn("Fallback download completed with animdl.", stored["log"]) + + +class WatchlistRefreshAllTests(unittest.TestCase): + def test_refresh_all_iterates_only_watching_and_planned_show_ids(self): + store = object.__new__(APP.WatchlistStore) + calls = [] + store.all_show_ids = lambda categories=None: ["show-a", "show-b"] + 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", False), ("show-b", False)]) + self.assertEqual(result["count"], 2) + self.assertEqual([item["show_id"] for item in result["items"]], ["show-a", "show-b"]) + + def test_all_show_ids_can_filter_by_category(self): + with APP.WATCHLIST._connect() as conn: + conn.execute("DELETE FROM watchlist") + now = APP.now_iso() + items = [ + {"show_id": "show-w", "title": "Watching", "category": "watching"}, + {"show_id": "show-p", "title": "Planned", "category": "planned"}, + {"show_id": "show-f", "title": "Finished", "category": "finished"}, + {"show_id": "show-d", "title": "Dropped", "category": "dropped"}, + ] + with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: + for item in items: + APP.WATCHLIST._upsert_conn( + conn, + { + **APP.WATCHLIST._auto_download_defaults(item["title"]), + "show_id": item["show_id"], + "title": item["title"], + "category": item["category"], + "downloaded": False, + "status": "tracked", + "status_message": "seeded", + "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, + }, + ) + + filtered = APP.WATCHLIST.all_show_ids(categories=("watching", "planned")) + + self.assertEqual(filtered, ["show-p", "show-w"]) + + +class WatchlistRefreshJobTests(unittest.TestCase): + def setUp(self): + with APP.WatchlistRefreshJobs(APP.WATCHLIST, start_worker=False)._connect() as conn: + conn.execute("DELETE FROM watchlist_refresh_jobs") + + def test_refresh_job_tracks_completion_and_errors(self): + class FakeStore: + def all_show_ids(self, categories=None): + return ["show-a", "show-b"] + + 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"} + + 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.assertIsNotNone(status) + 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, categories=None): + 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_job_marks_interrupted_when_shutdown_requested(self): + service = APP.WatchlistRefreshJobs(mock.Mock(), start_worker=False) + job = { + "id": "refresh-1", + "status": "running", + "created_at": APP.now_iso(), + "updated_at": APP.now_iso(), + "completed": 0, + "errors": 0, + "current_title": None, + "message": "", + } + service.store.all_show_ids = lambda categories=None: ["show-a"] + service.store.refresh = mock.Mock(return_value={"title": "Show A", "status": "updated"}) + service.stop_event.set() + + service._run_job(job) + + self.assertEqual(job["status"], "interrupted") + self.assertIn("interrupted", job["message"].lower()) + + def test_refresh_start_reuses_pending_job(self): + class FakeStore: + def all_show_ids(self, categories=None): + return [] + + def refresh(self, _show_id, include_thumbnail=True): + return {"title": "Unused", "status": "updated"} + + service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + first = service.start() + second = service.start() + + self.assertTrue(first["created"]) + self.assertFalse(second["created"]) + self.assertEqual(second["job"]["id"], first["job"]["id"]) + self.assertEqual(second["job"]["status"], "pending") + + def test_scheduled_refresh_logs_queue_and_completion(self): + class FakeStore: + def all_show_ids(self, categories=None): + return ["show-a"] + + def refresh(self, _show_id, include_thumbnail=True): + return {"title": "Show A", "status": "updated"} + + service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + with mock.patch("builtins.print") as print_mock: + started = service.start(source="scheduled") + job = service._next_pending() + service._run_job(job) + + self.assertTrue(started["created"]) + logged = "\n".join(call.args[0] for call in print_mock.call_args_list if call.args) + self.assertIn("Scheduled refresh queued", logged) + self.assertIn("Scheduled refresh started", logged) + self.assertIn("Scheduled refresh finished successfully", logged) + + def test_refresh_history_marks_pending_jobs_interrupted_after_restart(self): + class FakeStore: + def all_show_ids(self, categories=None): + return [] + + def refresh(self, _show_id, include_thumbnail=True): + return {"title": "Unused", "status": "updated"} + + service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + started = service.start() + restarted = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + + status = restarted.status()["job"] + self.assertEqual(status["id"], started["job"]["id"]) + self.assertEqual(status["status"], "interrupted") + self.assertIn("restart", status["message"].lower()) + + +class WatchlistAlternativeTitleTests(unittest.TestCase): + def setUp(self): + APP.reset_runtime(wait=True) + APP.initialize_runtime(start_workers=False) + self.watchlist = APP.WATCHLIST + with self.watchlist._connect() as conn: + conn.execute("DELETE FROM watchlist") + + def test_watchlist_schema_includes_alternative_title_column(self): + with self.watchlist._connect() as conn: + columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()} + + self.assertIn("alternative_titles_json", columns) + + def test_refresh_caches_english_anidb_alternative_titles(self): + self.watchlist.add({"show_id": "show-1", "title": "Attack on Titan", "category": "watching"}) + + with mock.patch.object( + APP, + "fetch_show_episode_snapshot", + return_value={"show_id": "show-1", "title": "Attack on Titan", "episodes": {"sub": ["1", "2"], "dub": ["1"]}}, + ), mock.patch.object( + APP, + "resolve_animeschedule_anime", + return_value={"route": "attack-on-titan", "title": "Attack on Titan", "episodes": 2, "status": "Finished"}, + ), mock.patch.object( + APP, + "resolve_anidb_aid", + return_value={"aid": "16498", "title": "Attack on Titan"}, + ), mock.patch.object( + APP, + "get_cached_anidb_titles", + return_value=[ + {"aid": "16498", "title": "Attack on Titan", "type": "official", "lang": "en", "normalized_title": "attack on titan"}, + {"aid": "16498", "title": "AoT", "type": "syn", "lang": "en", "normalized_title": "aot"}, + {"aid": "16498", "title": "Shingeki no Kyojin", "type": "official", "lang": "x-jat", "normalized_title": "shingeki no kyojin"}, + {"aid": "16498", "title": "Attack Titan", "type": "short", "lang": "en", "normalized_title": "attack titan"}, + ], + ): + item = self.watchlist.refresh("show-1", include_thumbnail=False) + + self.assertEqual(item["anidb_aid"], "16498") + self.assertEqual( + item["alternative_titles"], + [ + {"label": "English", "value": "AoT"}, + {"label": "English", "value": "Attack Titan"}, + ], + ) + self.assertTrue(item["has_alternative_titles"]) + + +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() + store.thumbnail_events = {} + store.get = lambda _show_id: { + "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, + } + + class BrokenConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, _sql, _params): + raise RuntimeError("db write failed") + + store._connect = lambda: BrokenConn() + + with mock.patch.object(APP, "fetch_animeschedule_cover", return_value={"route": "route-1", "title": "Show One", "url": "https://example.com/cover.jpg"}), mock.patch.object( + APP, "cache_thumbnail_image", return_value="show-1.jpg" + ): + with self.assertRaises(RuntimeError): + APP.WatchlistStore.ensure_thumbnail(store, "show-1", force=False) + + self.assertEqual(store.thumbnail_events, {}) + + def test_ensure_thumbnail_records_failed_attempt_time_for_retry_backoff(self): + now = APP.now_iso() + item = { + "show_id": "show-thumb-1", + "title": "Broken Cover Show", + "category": "watching", + "downloaded": False, + "status": "tracked", + "status_message": "Waiting for thumbnail.", + "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, + "media_type": "tv", + "thumbnail_path": None, + "thumbnail_checked_at": None, + } + with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: + APP.WATCHLIST._upsert_conn(conn, item) + + with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("lookup failed")) as fetch_cover, mock.patch.object( + APP, "fetch_anidb_cover", side_effect=RuntimeError("fallback failed") + ) as fetch_fallback: + result = APP.WATCHLIST.ensure_thumbnail("show-thumb-1") + + self.assertIsNone(result) + self.assertEqual(fetch_cover.call_count, 1) + self.assertEqual(fetch_fallback.call_count, 1) + failed_item = APP.WATCHLIST.get("show-thumb-1") + self.assertTrue(failed_item["thumbnail_checked_at"]) + self.assertFalse(APP.thumbnail_retry_due(failed_item["thumbnail_checked_at"])) + + with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=AssertionError("retry window should suppress another lookup")), mock.patch.object( + APP, "fetch_anidb_cover", side_effect=AssertionError("retry window should suppress fallback lookup") + ): + second = APP.WATCHLIST.ensure_thumbnail("show-thumb-1") + + self.assertIsNone(second) + + def test_ensure_thumbnail_falls_back_from_stale_animeschedule_route(self): + now = APP.now_iso() + item = { + "show_id": "show-thumb-2", + "title": "Route Fallback Show", + "category": "watching", + "downloaded": False, + "status": "tracked", + "status_message": "Waiting for thumbnail.", + "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": "stale-route", + "animeschedule_title": "Route Fallback Show", + "anidb_aid": None, + "anidb_title": None, + "thumbnail_path": None, + "thumbnail_checked_at": None, + } + with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: + APP.WATCHLIST._upsert_conn(conn, item) + + with mock.patch.object(APP, "fetch_animeschedule_cover_by_route", side_effect=RuntimeError("stale route")), mock.patch.object( + APP, "fetch_animeschedule_cover", return_value={"route": "fresh-route", "title": "Route Fallback Show", "url": "https://example.com/cover.jpg"} + ) as title_lookup, mock.patch.object(APP, "cache_thumbnail_image", return_value="show-thumb-2.jpg"): + result = APP.WATCHLIST.ensure_thumbnail("show-thumb-2") + + title_lookup.assert_called_once_with("Route Fallback Show") + self.assertEqual(result, APP.THUMBNAIL_ROOT / "show-thumb-2.jpg") + refreshed = APP.WATCHLIST.get("show-thumb-2") + self.assertEqual(refreshed["animeschedule_route"], "fresh-route") + + def test_ensure_thumbnail_falls_back_from_stale_anidb_aid(self): + now = APP.now_iso() + item = { + "show_id": "show-thumb-3", + "title": "AniDB Fallback Show", + "category": "watching", + "downloaded": False, + "status": "tracked", + "status_message": "Waiting for thumbnail.", + "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": "12345", + "anidb_title": "AniDB Fallback Show", + "thumbnail_path": None, + "thumbnail_checked_at": None, + } + with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: + APP.WATCHLIST._upsert_conn(conn, item) + + with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("animeschedule failed")), mock.patch.object( + APP, "fetch_anidb_cover_by_aid", side_effect=RuntimeError("stale aid") + ), mock.patch.object( + APP, "fetch_anidb_cover", return_value={"aid": "67890", "title": "AniDB Fallback Show", "url": "https://example.com/cover.jpg"} + ) as title_lookup, mock.patch.object(APP, "cache_thumbnail_image", return_value="show-thumb-3.jpg"): + result = APP.WATCHLIST.ensure_thumbnail("show-thumb-3") + + title_lookup.assert_called_once_with("AniDB Fallback Show") + self.assertEqual(result, APP.THUMBNAIL_ROOT / "show-thumb-3.jpg") + refreshed = APP.WATCHLIST.get("show-thumb-3") + self.assertEqual(refreshed["anidb_aid"], "67890") + + +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_list_clamps_out_of_range_page_before_querying_rows(self): + self.seed_watchlist_item(show_id="show-1", title="Alpha Show", category="watching") + self.seed_watchlist_item(show_id="show-2", title="Beta Show", category="watching") + + listing = APP.WATCHLIST.list(page=3, per_page=1, category="watching") + + self.assertEqual(listing["page"], 2) + self.assertEqual(listing["pages"], 2) + self.assertEqual([item["show_id"] for item in listing["items"]], ["show-2"]) + + 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, source="manual": 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", source="manual") + self.assertEqual(item["status"], "queued") + self.assertEqual(item["status_message"], "Queued from test.") + + def test_mark_downloaded_keeps_existing_category_when_selected_mode_is_incomplete(self): + self.seed_watchlist_item( + show_id="show-9", + title="Queue Show", + category="planned", + downloaded=False, + expected_count=12, + airing_status="Finished", + dub_count=8, + ) + + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object( + APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") + ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3", "4", "5", "6", "7", "8"]): + item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show", mode="dub", episodes="2-3") + + self.assertEqual(item["category"], "planned") + self.assertTrue(item["downloaded"]) + self.assertEqual(item["finished_badge"], "") + self.assertEqual(item["status"], "queued") + self.assertEqual(item["downloaded_dub_episodes"], ["2", "3"]) + + def test_mark_downloaded_moves_existing_category_to_finished_when_selected_mode_is_complete(self): + self.seed_watchlist_item( + show_id="show-9c", + title="Queue Show Complete", + category="watching", + downloaded=False, + expected_count=3, + airing_status="Finished", + dub_count=3, + ) + + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object( + APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") + ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3"]): + item = APP.WATCHLIST.mark_downloaded("show-9c", title="Queue Show Complete", mode="dub", episodes="1-3") + + self.assertEqual(item["category"], "finished") + self.assertTrue(item["downloaded"]) + self.assertEqual(item["finished_badge"], "downloaded") + self.assertEqual(item["downloaded_dub_episodes"], ["1", "2", "3"]) + + def test_mark_downloaded_moves_existing_movie_to_finished(self): + self.seed_watchlist_item( + show_id="movie-1", + title="Movie Show", + category="watching", + downloaded=False, + media_type="movie", + ) + + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": 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("movie-1", title="Movie Show", media_type="movie") + + self.assertEqual(item["category"], "finished") + self.assertEqual(item["media_type"], "movie") + self.assertTrue(item["downloaded"]) + + def test_mark_downloaded_creates_missing_show_in_finished_category(self): + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": 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_mark_downloaded_creates_missing_movie_in_finished_category(self): + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": 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("movie-2", title="Downloaded Movie", media_type="movie") + + self.assertEqual(item["category"], "finished") + self.assertEqual(item["media_type"], "movie") + self.assertTrue(item["downloaded"]) + + def test_mark_downloaded_does_not_recreate_removed_show(self): + self.seed_watchlist_item(show_id="show-removed", title="Removed Show", category="watching") + + APP.WATCHLIST.remove("show-removed") + + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=AssertionError("removed show should not be requeued")), mock.patch.object( + APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") + ): + item = APP.WATCHLIST.mark_downloaded("show-removed", title="Removed Show", mode="sub", episodes="1") + + self.assertIsNone(item) + self.assertFalse(APP.WATCHLIST.has_show("show-removed")) + + def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self): + self.seed_watchlist_item( + show_id="legacy-show-9", + title="Queue Show", + category="watching", + downloaded=False, + expected_count=3, + airing_status="Finished", + sub_count=3, + ) + + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object( + APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") + ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3"]): + item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show", mode="sub", episodes="1-3") + + self.assertEqual(item["show_id"], "resolved-show-9") + self.assertEqual(item["category"], "finished") + self.assertTrue(item["downloaded"]) + self.assertEqual(item["finished_badge"], "downloaded") + self.assertEqual(item["status"], "queued") + self.assertFalse(APP.WATCHLIST.has_show("legacy-show-9")) + self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"]) + + def test_add_queues_refresh_without_sync_refresh(self): + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": 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", source="initial") + self.assertTrue(result["created"]) + self.assertEqual(result["item"]["status"], "queued") + self.assertIn("Refresh queued", result["message"]) + + def test_add_uses_requested_auto_download_series(self): + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)): + result = APP.WATCHLIST.add( + {"show_id": "show-23", "title": "Queued Show", "category": "watching", "auto_download_series": "4"} + ) + + self.assertEqual(result["item"]["auto_download_series"], "4") + + def test_add_uses_requested_auto_download_offset(self): + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)): + result = APP.WATCHLIST.add( + {"show_id": "show-23b", "title": "Queued Show", "category": "watching", "auto_download_offset": "13"} + ) + + self.assertEqual(result["item"]["auto_download_offset"], "13") + + def test_add_uses_requested_media_type(self): + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)): + result = APP.WATCHLIST.add( + {"show_id": "show-24", "title": "Queued Movie", "category": "watching", "media_type": "movie"} + ) + + self.assertEqual(result["item"]["media_type"], "movie") + self.assertEqual(result["item"]["media_type_label"], "Movie") + + def test_refresh_uses_bounded_request_timeout(self): + self.seed_watchlist_item(show_id="show-1", title="Example Show") + snapshot = { + "show_id": "show-1", + "title": "Example Show", + "episodes": {"sub": ["1"], "dub": []}, + } + schedule = { + "route": "example-show", + "title": "Example Show", + "episodes": 12, + "status": "Finished", + } + + with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot) as fetch_snapshot, mock.patch.object( + APP, "resolve_animeschedule_anime", return_value=schedule + ) as resolve_schedule, mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None): + APP.WATCHLIST.refresh("show-1") + + fetch_snapshot.assert_called_once_with("show-1", timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS) + resolve_schedule.assert_called_once_with( + "Example Show", + route=None, + timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS, + ) + + 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", mode="sub", episodes=None, media_type=None) + + 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") + + def test_prepare_download_job_infers_result_index_from_show_id(self): + job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": "show-99"} + + with mock.patch.object( + APP, + "search_anime", + return_value=[ + {"id": "show-42", "title": "Queue Show"}, + {"id": "show-99", "title": "Queue Show Season 4"}, + ], + ): + prepared = APP.prepare_download_job(dict(job)) + + self.assertEqual(prepared["result_index"], 2) + + def test_prepare_download_job_infers_result_index_from_unique_title_match(self): + job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""} + + with mock.patch.object( + APP, + "search_anime", + return_value=[ + {"id": "show-42", "title": "Queue Show"}, + {"id": "show-99", "title": "Different Show"}, + ], + ): + prepared = APP.prepare_download_job(dict(job)) + + self.assertEqual(prepared["result_index"], 1) + + def test_prepare_download_job_accepts_equivalent_season_notation(self): + job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""} + + with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 2"}]): + prepared = APP.prepare_download_job(dict(job)) + + self.assertEqual(prepared["show_id"], "show-42") + self.assertEqual(prepared["title"], "Queue Show Season 2") + + def test_prepare_download_job_resolves_unique_match_without_result_index(self): + job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""} + + with mock.patch.object( + APP, + "search_anime", + return_value=[ + {"id": "show-42", "title": "Queue Show"}, + {"id": "show-99", "title": "Different Show"}, + ], + ): + prepared = APP.prepare_download_job(dict(job)) + + self.assertEqual(prepared["show_id"], "show-42") + self.assertEqual(prepared["title"], "Queue Show") + + def test_prepare_download_job_skips_ambiguous_match_without_result_index(self): + job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""} + + with mock.patch.object( + APP, + "search_anime", + return_value=[ + {"id": "show-42", "title": "Queue Show"}, + {"id": "show-99", "title": "Queue Show (2024)"}, + ], + ): + prepared = APP.prepare_download_job(dict(job)) + + self.assertEqual(prepared["show_id"], "") + + def test_command_for_job_omits_result_switch_when_result_index_missing(self): + job = APP.app_support.build_job( + { + "query": "Queue Show", + "title": "Queue Show", + "mode": "dub", + "quality": "best", + "episodes": "1-10", + "download_dir": "/tmp/example", + "season": "1", + }, + {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, + ) + + self.assertNotIn("-S", APP.app_support.command_for_job(job)) + + def test_command_for_job_includes_result_switch_for_selected_search_result(self): + command = APP.app_support.command_for_job( + { + "query": "Queue Show", + "quality": "best", + "episodes": "1-10", + "mode": "dub", + "result_index": 2, + } + ) + + self.assertIn("-S", command) + self.assertEqual(command[command.index("-S") + 1], "2") + + def test_command_for_job_ignores_invalid_result_index(self): + command = APP.app_support.command_for_job( + { + "query": "Queue Show", + "quality": "best", + "episodes": "1-10", + "mode": "dub", + "result_index": "nope", + } + ) + + self.assertNotIn("-S", command) + + def test_command_for_job_builds_anipy_fallback_search_spec(self): + command = APP.app_support.command_for_job( + { + "query": "Re:Zero", + "quality": "720", + "episodes": "1-3", + "mode": "dub", + }, + backend="anipy-cli", + download_path="/tmp/anipy-staging", + ) + + self.assertEqual(command[0], APP.app_support.ANIPY_CLI) + self.assertEqual(command[1:5], ["-D", "-s", "Re Zero:1-3:dub", "-q"]) + self.assertIn("-l", command) + self.assertEqual(command[-1], "/tmp/anipy-staging") + + def test_command_for_job_builds_animdl_download_command(self): + command = APP.app_support.command_for_job( + { + "query": "Re:Zero", + "quality": "720", + "episodes": "1-3", + "mode": "dub", + }, + backend="animdl", + download_path="/tmp/animdl-staging", + ) + + self.assertEqual(command[0], APP.app_support.ANIMDL) + self.assertEqual(command[1:4], ["download", "Re:Zero", "-r"]) + self.assertIn("-q", command) + self.assertIn("--index", command) + self.assertIn("-d", command) + self.assertEqual(command[-1], "/tmp/animdl-staging") + + def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self): + item = { + "show_id": "show-42", + "title": "Queue Show", + "auto_download_name": "Queue Show Library", + "auto_download_source_name": "Queue Show Source", + "auto_download_series": "4", + "auto_download_quality": "720", + "media_type": "movie", + } + job = {"id": "job-1", "show_id": "show-42", "episodes": "1-12", "mode": "dub"} + with mock.patch.object(APP, "ensure_runtime", return_value={"watchlist": APP.WATCHLIST, "download_queue": mock.Mock(), "config": {"quality": "best", "download_dir": "/tmp/downloads"}}), mock.patch.object( + APP.WATCHLIST, "get", return_value=item + ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "12"]): + runtime = APP.ensure_runtime() + runtime["download_queue"].add.return_value = job + result = APP.download_watchlist_item("show-42", "dub") + + runtime["download_queue"].add.assert_called_once_with( + { + "show_id": "show-42", + "query": "Queue Show Source", + "title": "Queue Show", + "anime_name": "Queue Show Library", + "media_type": "movie", + "season": "4", + "episode_offset": None, + "mode": "dub", + "quality": "720", + "episodes": "1-12", + "download_dir": "/tmp/downloads", + } + ) + self.assertEqual(result["job"], job) + self.assertIn("Queued Queue Show for dub download.", result["message"]) + + def test_remove_from_watchlist_detaches_existing_queue_jobs(self): + runtime = APP.ensure_runtime() + APP.WATCHLIST.add({"show_id": "show-queue-remove", "title": "Queue Remove Show"}) + created = runtime["download_queue"].add( + { + "show_id": "show-queue-remove", + "query": "Queue Remove Show", + "title": "Queue Remove Show", + "anime_name": "Queue Remove Show", + "mode": "sub", + "quality": "best", + "episodes": "1-2", + "download_dir": "/tmp/example", + "season": "1", + } + ) + + APP.remove_from_watchlist("show-queue-remove") + + detached = runtime["download_queue"].get(created["id"]) + self.assertEqual(detached["show_id"], "") + self.assertTrue(detached["skip_watchlist_sync"]) + + def test_download_watchlist_item_does_not_require_search_match(self): + item = { + "show_id": "show-42", + "title": "Queue Show", + "auto_download_name": "Queue Show", + "auto_download_source_name": "Queue Show Alt Search", + "auto_download_series": "1", + "media_type": "tv", + } + runtime = { + "watchlist": APP.WATCHLIST, + "download_queue": mock.Mock(), + "config": {"quality": "best", "download_dir": "/tmp/downloads"}, + } + runtime["download_queue"].add.return_value = {"id": "job-1"} + + with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( + APP.WATCHLIST, "get", return_value=item + ), mock.patch.object(APP, "search_anime", side_effect=AssertionError("search should not be required")), mock.patch.object( + APP, "episode_list", return_value=["1", "2"] + ): + result = APP.download_watchlist_item("show-42", "dub") + + self.assertEqual(result["job"]["id"], "job-1") + + +class JellyfinSyncTests(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-jelly-1", + "title": "Jelly Show", + "category": "finished", + "downloaded": True, + "status": "updated", + "status_message": "Finished.", + "created_at": now, + "updated_at": now, + "last_checked": now, + "sub_count": 12, + "dub_count": 12, + "expected_count": 12, + "airing_status": "Finished", + "sub_latest_episode": "12", + "dub_latest_episode": "12", + "animeschedule_route": None, + "animeschedule_title": None, + "anidb_aid": None, + "anidb_title": None, + "media_type": "tv", + "auto_download_mode": "dub", + "auto_download_name": "Jelly Show", + "auto_download_series": "1", + "downloaded_sub_episodes_json": None, + "downloaded_dub_episodes_json": APP.encode_episode_values([str(index) for index in range(1, 13)]), + "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_run_jellyfin_sync_moves_eligible_tv_library(self): + self.seed_watchlist_item() + with tempfile.TemporaryDirectory() as temp_root: + download_root = Path(temp_root) / "downloads" + source_file = download_root / "tv" / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4" + source_file.parent.mkdir(parents=True, exist_ok=True) + source_file.write_bytes(b"episode-data") + jellyfin_tv = Path(temp_root) / "jellyfin-tv" + + result = APP.run_jellyfin_sync( + { + "download_dir": str(download_root), + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": str(jellyfin_tv), + "jellyfin_movie_dir": "", + } + ) + + self.assertEqual(result["moved"], 1) + target_file = jellyfin_tv / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4" + self.assertTrue(target_file.exists()) + self.assertFalse((download_root / "tv" / "Jelly Show").exists()) + + def test_trigger_jellyfin_sync_for_item_sends_success_notification(self): + self.seed_watchlist_item() + item = APP.WATCHLIST.get("show-jelly-1") + with tempfile.TemporaryDirectory() as temp_root: + download_root = Path(temp_root) / "downloads" + source_file = download_root / "tv" / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4" + source_file.parent.mkdir(parents=True, exist_ok=True) + source_file.write_bytes(b"episode-data") + jellyfin_tv = Path(temp_root) / "jellyfin-tv" + with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: + result = APP.trigger_jellyfin_sync_for_item( + item, + automatic=True, + config={ + "download_dir": str(download_root), + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": str(jellyfin_tv), + "jellyfin_movie_dir": "", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": ["jellyfin_sync_success"], + }, + ) + + self.assertTrue(result["moved"]) + send_webhook.assert_called_once() + self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_success") + self.assertEqual(send_webhook.call_args.args[2]["title"], "Jelly Show") + self.assertEqual(send_webhook.call_args.args[2]["source"], "automatic") + + def test_trigger_jellyfin_sync_for_item_sends_failure_notification(self): + self.seed_watchlist_item() + item = APP.WATCHLIST.get("show-jelly-1") + with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: + result = APP.trigger_jellyfin_sync_for_item( + item, + automatic=True, + config={ + "download_dir": "/tmp/example", + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": "", + "jellyfin_movie_dir": "", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": ["jellyfin_sync_failed"], + }, + ) + + self.assertFalse(result["moved"]) + self.assertEqual(result["reason"], "target_missing") + send_webhook.assert_called_once() + self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_failed") + self.assertEqual(send_webhook.call_args.args[2]["reason"], "target_missing") + + def test_trigger_jellyfin_sync_for_item_skips_already_moved_notification(self): + self.seed_watchlist_item(expected_count=1, dub_count=1, downloaded_dub_episodes_json=APP.encode_episode_values(["1"])) + item = APP.WATCHLIST.get("show-jelly-1") + with tempfile.TemporaryDirectory() as temp_root: + jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show" + jellyfin_tv.mkdir(parents=True, exist_ok=True) + (jellyfin_tv / "Season 01").mkdir(parents=True, exist_ok=True) + (jellyfin_tv / "Season 01" / "Jelly Show - S01E01.mp4").write_bytes(b"episode-data") + with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: + result = APP.trigger_jellyfin_sync_for_item( + item, + automatic=False, + config={ + "download_dir": str(Path(temp_root) / "downloads"), + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": str(Path(temp_root) / "jellyfin-tv"), + "jellyfin_movie_dir": "", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": ["jellyfin_sync_failed", "jellyfin_sync_success"], + }, + ) + + self.assertFalse(result["moved"]) + self.assertEqual(result["reason"], "already_moved") + send_webhook.assert_not_called() + + def test_trigger_jellyfin_sync_for_item_flags_incomplete_target_when_source_missing(self): + self.seed_watchlist_item(expected_count=12, dub_count=12) + item = APP.WATCHLIST.get("show-jelly-1") + with tempfile.TemporaryDirectory() as temp_root: + jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show" + jellyfin_tv.mkdir(parents=True, exist_ok=True) + with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: + result = APP.trigger_jellyfin_sync_for_item( + item, + automatic=False, + config={ + "download_dir": str(Path(temp_root) / "downloads"), + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": str(Path(temp_root) / "jellyfin-tv"), + "jellyfin_movie_dir": "", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": ["jellyfin_sync_failed"], + }, + ) + + self.assertFalse(result["moved"]) + self.assertEqual(result["reason"], "target_incomplete") + send_webhook.assert_called_once() + self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_failed") + + def test_trigger_jellyfin_sync_for_item_skips_incomplete_series(self): + self.seed_watchlist_item( + expected_count=12, + dub_count=8, + dub_latest_episode="8", + downloaded_dub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 9)]), + ) + item = APP.WATCHLIST.get("show-jelly-1") + + result = APP.trigger_jellyfin_sync_for_item( + item, + automatic=False, + config={"download_dir": "/tmp/example", "jellyfin_tv_dir": "/tmp/jellyfin-tv", "jellyfin_movie_dir": ""}, + ) + + self.assertFalse(result["moved"]) + self.assertEqual(result["reason"], "episodes_unavailable") + + def test_trigger_jellyfin_sync_for_item_skips_when_dub_not_fully_downloaded(self): + self.seed_watchlist_item( + expected_count=12, + dub_count=12, + downloaded_dub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 9)]), + ) + item = APP.WATCHLIST.get("show-jelly-1") + + result = APP.trigger_jellyfin_sync_for_item( + item, + automatic=False, + config={"download_dir": "/tmp/example", "jellyfin_tv_dir": "/tmp/jellyfin-tv", "jellyfin_movie_dir": ""}, + ) + + self.assertFalse(result["moved"]) + self.assertEqual(result["reason"], "episodes_missing") + + def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(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": "Different Anime"}]): + with self.assertRaises(ValueError) as ctx: + APP.sync_downloaded_job_to_watchlist(job) + + self.assertIn("title mismatch", str(ctx.exception).lower()) + + def test_sync_downloaded_job_to_watchlist_rejects_cross_season_match(self): + job = {"query": "Queue Show Season 2", "mode": "sub", "result_index": 1, "title": "Queue Show Season 2"} + + with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 1"}]): + with self.assertRaises(ValueError) as ctx: + APP.sync_downloaded_job_to_watchlist(job) + + self.assertIn("title mismatch", str(ctx.exception).lower()) + + +class WatchlistFilesystemReconcileTests(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-reconcile-1", + "title": "Reconcile Show", + "category": "watching", + "downloaded": True, + "status": "updated", + "status_message": "Tracked.", + "created_at": now, + "updated_at": now, + "last_checked": now, + "sub_count": 12, + "dub_count": 12, + "expected_count": 12, + "airing_status": "Finished", + "sub_latest_episode": "12", + "dub_latest_episode": "12", + "animeschedule_route": None, + "animeschedule_title": None, + "anidb_aid": None, + "anidb_title": None, + "media_type": "tv", + "auto_download_mode": "dub", + "auto_download_quality": "best", + "auto_download_name": "Reconcile Show", + "auto_download_source_name": "Reconcile Show", + "auto_download_series": "2", + "auto_download_offset": "13", + "downloaded_sub_episodes_json": None, + "downloaded_dub_episodes_json": APP.encode_episode_values(["1", "3"]), + "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_reconcile_watchlist_downloaded_files_updates_episode_sets_from_filesystem(self): + self.seed_watchlist_item() + with tempfile.TemporaryDirectory() as temp_root: + season_dir = Path(temp_root) / "tv" / "Reconcile Show" / "Season 02" + season_dir.mkdir(parents=True, exist_ok=True) + (season_dir / "Reconcile Show - S02E13.mp4").write_bytes(b"one") + (season_dir / "Reconcile Show - S02E14.mkv").write_bytes(b"two") + + result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"}) + + self.assertEqual(result["changed"], 1) + item = APP.WATCHLIST.get("show-reconcile-1") + self.assertTrue(item["downloaded"]) + self.assertEqual(item["downloaded_dub_episodes"], ["1", "2"]) + self.assertEqual(item["downloaded_sub_episodes"], []) + + def test_reconcile_watchlist_downloaded_files_clears_missing_files(self): + self.seed_watchlist_item( + downloaded_sub_episodes_json=APP.encode_episode_values(["1"]), + downloaded_dub_episodes_json=APP.encode_episode_values(["1", "2"]), + ) + + result = APP.reconcile_watchlist_downloaded_files({"download_dir": "/tmp/missing-download-root", "mode": "sub", "quality": "best"}) + + self.assertEqual(result["changed"], 1) + item = APP.WATCHLIST.get("show-reconcile-1") + self.assertFalse(item["downloaded"]) + self.assertEqual(item["downloaded_sub_episodes"], []) + self.assertEqual(item["downloaded_dub_episodes"], []) + + def test_reconcile_watchlist_downloaded_files_marks_movie_present_when_file_exists(self): + self.seed_watchlist_item( + show_id="show-movie-1", + title="Movie Reconcile", + media_type="movie", + downloaded=False, + auto_download_name="Movie Reconcile", + auto_download_series="1", + auto_download_offset=None, + downloaded_sub_episodes_json=None, + downloaded_dub_episodes_json=None, + ) + with tempfile.TemporaryDirectory() as temp_root: + movie_dir = Path(temp_root) / "movie" / "Movie Reconcile" + movie_dir.mkdir(parents=True, exist_ok=True) + (movie_dir / "Movie Reconcile.mp4").write_bytes(b"movie") + + result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"}) + + self.assertEqual(result["changed"], 1) + item = APP.WATCHLIST.get("show-movie-1") + self.assertTrue(item["downloaded"]) + + +class WatchlistBackupTests(unittest.TestCase): + def setUp(self): + with APP.WATCHLIST._connect() as conn: + conn.execute("DELETE FROM watchlist") + conn.execute("DELETE FROM watchlist_removals") + + def seed_watchlist_item(self, **overrides): + now = APP.now_iso() + item = { + "show_id": "show-backup-1", + "title": "Backup Show", + "category": "finished", + "downloaded": True, + "status": "updated", + "status_message": "Ready.", + "created_at": now, + "updated_at": now, + "last_checked": now, + "sub_count": 2, + "dub_count": 2, + "expected_count": 2, + "airing_status": "Finished", + "sub_latest_episode": "2", + "dub_latest_episode": "2", + "animeschedule_route": "backup-route", + "animeschedule_title": "Backup Schedule", + "anidb_aid": "123", + "anidb_title": "Backup AniDB", + "media_type": "tv", + "auto_download_mode": "dub", + "auto_download_quality": "best", + "auto_download_name": "Backup Show", + "auto_download_source_name": "Backup Search", + "auto_download_series": "1", + "auto_download_offset": None, + "downloaded_sub_episodes_json": APP.encode_episode_values(["1"]), + "downloaded_dub_episodes_json": APP.encode_episode_values(["1", "2"]), + "thumbnail_path": "show-backup-1.jpg", + "thumbnail_checked_at": now, + } + item.update(overrides) + with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: + APP.WATCHLIST._upsert_conn(conn, item) + + def test_export_watchlist_backup_includes_raw_watchlist_and_removal_rows(self): + self.seed_watchlist_item() + with APP.WATCHLIST._connect() as conn: + conn.execute( + "INSERT INTO watchlist_removals (show_id, title, removed_at) VALUES (?, ?, ?)", + ("removed-show", "Removed Show", APP.now_iso()), + ) + + backup = APP.export_watchlist_backup() + + self.assertEqual(backup["format"], "ani-cli-web-watchlist-backup") + self.assertEqual(backup["format_version"], 1) + self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["show_id"], "show-backup-1") + self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["downloaded_dub_episodes_json"], APP.encode_episode_values(["1", "2"])) + self.assertEqual(backup["tables"]["watchlist_removals"]["rows"][0]["show_id"], "removed-show") + + def test_import_watchlist_backup_replaces_existing_watchlist_tables(self): + self.seed_watchlist_item(show_id="old-show", title="Old Show") + backup = APP.export_watchlist_backup() + backup["tables"]["watchlist"]["rows"][0]["show_id"] = "new-show" + backup["tables"]["watchlist"]["rows"][0]["title"] = "New Show" + backup["tables"]["watchlist_removals"]["rows"] = [ + {"show_id": "removed-new", "title": "Removed New", "removed_at": APP.now_iso()} + ] + + result = APP.import_watchlist_backup(backup) + + self.assertEqual(result["imported"], 1) + with self.assertRaises(KeyError): + APP.WATCHLIST.get("old-show") + restored = APP.WATCHLIST.get("new-show") + self.assertEqual(restored["title"], "New Show") + self.assertEqual(restored["downloaded_dub_episodes"], ["1", "2"]) + with APP.WATCHLIST._connect() as conn: + removal = conn.execute("SELECT title FROM watchlist_removals WHERE show_id = ?", ("removed-new",)).fetchone() + self.assertEqual(removal["title"], "Removed New") + + def test_import_watchlist_backup_rejects_unrecognized_format(self): + with self.assertRaises(ValueError): + APP.import_watchlist_backup({"format": "not-this-app", "format_version": 1, "tables": {}}) + + +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", "manual"), ("show-b", "manual")]) + 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", "manual")] + 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, refresh_source="manual"): + calls.append((show_id, refresh_source)) + store.refresh_stop_event.set() + + store.refresh = fake_refresh + + APP.WatchlistStore._refresh_loop(store) + + self.assertEqual(calls, [("show-2", "manual")]) + 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", "manual"), ("show-4", "manual")] + 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", "manual")]) + 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", + "watchlist_auto_refresh_enabled": False, + "watchlist_auto_refresh_minutes": 60, + "watchlist_refresh_delay_seconds": 5, + "auto_download_enabled": False, + "auto_download_mode": "dub", + "auto_download_quality": "best", + "discord_webhook_url": "", + "discord_webhook_events": [], + } + try: + snapshot = APP.get_config_snapshot() + snapshot["mode"] = "dub" + self.assertEqual(APP.CONFIG["mode"], "sub") + finally: + APP.CONFIG = original + + def test_normalize_config_includes_watchlist_schedule_defaults(self): + config = APP.normalize_config({}) + self.assertFalse(config["watchlist_auto_refresh_enabled"]) + self.assertEqual(config["watchlist_auto_refresh_minutes"], 60) + self.assertEqual(config["watchlist_refresh_delay_seconds"], 5) + self.assertFalse(config["anipy_cli_fallback_enabled"]) + self.assertEqual(config["download_methods"], ["ani-cli"]) + self.assertFalse(config["auto_download_enabled"]) + self.assertEqual(config["auto_download_mode"], "dub") + self.assertEqual(config["auto_download_quality"], "best") + self.assertFalse(config["jellyfin_sync_enabled"]) + self.assertEqual(config["jellyfin_tv_dir"], "") + self.assertEqual(config["jellyfin_movie_dir"], "") + self.assertEqual(config["discord_webhook_url"], "") + self.assertEqual(config["discord_webhook_events"], []) + + def test_normalize_config_parses_watchlist_schedule_fields(self): + config = APP.normalize_config( + { + "anipy_cli_fallback_enabled": "true", + "watchlist_auto_refresh_enabled": "true", + "watchlist_auto_refresh_minutes": "15", + "watchlist_refresh_delay_seconds": "7", + "auto_download_enabled": "true", + "auto_download_mode": "sub", + "auto_download_quality": "720p", + "jellyfin_sync_enabled": "true", + "jellyfin_tv_dir": "~/jellyfin/tv", + "jellyfin_movie_dir": "~/jellyfin/movies", + } + ) + self.assertTrue(config["anipy_cli_fallback_enabled"]) + self.assertEqual(config["download_methods"], ["ani-cli", "anipy-cli"]) + self.assertTrue(config["watchlist_auto_refresh_enabled"]) + self.assertEqual(config["watchlist_auto_refresh_minutes"], 15) + self.assertEqual(config["watchlist_refresh_delay_seconds"], 7) + self.assertTrue(config["auto_download_enabled"]) + self.assertEqual(config["auto_download_mode"], "sub") + self.assertEqual(config["auto_download_quality"], "720") + self.assertTrue(config["jellyfin_sync_enabled"]) + self.assertIn("jellyfin", config["jellyfin_tv_dir"]) + self.assertIn("jellyfin", config["jellyfin_movie_dir"]) + + def test_normalize_config_filters_download_methods(self): + config = APP.normalize_config( + { + "download_methods": ["animdl", "bad-method", "anipy-cli", "animdl"], + } + ) + + self.assertEqual(config["download_methods"], ["animdl", "anipy-cli"]) + self.assertTrue(config["anipy_cli_fallback_enabled"]) + + def test_normalize_config_filters_discord_webhook_events(self): + config = APP.normalize_config( + { + "discord_webhook_url": " https://discord.com/api/webhooks/123456/test-token ", + "discord_webhook_events": [ + "watchlist_refresh_new_episodes", + "runtime_error", + "runtime_error", + "nope", + ], + } + ) + self.assertEqual(config["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token") + self.assertEqual( + config["discord_webhook_events"], + ["watchlist_refresh_new_episodes", "runtime_error"], + ) + + def test_test_discord_webhook_config_requires_url(self): + with self.assertRaises(ValueError): + APP.test_discord_webhook_config({"discord_webhook_url": ""}) + + def test_test_discord_webhook_config_sends_test_event(self): + with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: + result = APP.test_discord_webhook_config( + { + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": [], + } + ) + + self.assertEqual(result["message"], "Discord webhook test notification sent.") + send_webhook.assert_called_once() + config, event_name = send_webhook.call_args.args[:2] + self.assertEqual(config["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token") + self.assertEqual(event_name, "test") + self.assertTrue(send_webhook.call_args.kwargs["force"]) + + +class WatchlistAutoRefreshSchedulerTests(unittest.TestCase): + def test_tick_waits_then_triggers_refresh(self): + config = { + "watchlist_auto_refresh_enabled": True, + "watchlist_auto_refresh_minutes": 10, + } + start = mock.Mock(return_value={"created": True}) + scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False) + + self.assertEqual(scheduler.tick(now_monotonic=100.0), 600) + self.assertEqual(scheduler.tick(now_monotonic=699.0), 1) + self.assertEqual(scheduler.tick(now_monotonic=700.0), 600) + start.assert_called_once_with(source="scheduled") + + def test_refresh_job_waits_between_series_using_configured_delay(self): + class FakeStore: + def all_show_ids(self, categories=None): + return ["show-a", "show-b", "show-c"] + + def refresh(self, show_id, include_thumbnail=True): + return {"title": show_id, "status": "updated"} + + service = APP.WatchlistRefreshJobs( + FakeStore(), + config_getter=lambda: {"watchlist_refresh_delay_seconds": 5}, + start_worker=False, + ) + job = service.start()["job"] + job = service._next_pending() + + with mock.patch.object(service.stop_event, "wait", return_value=False) as wait_mock: + service._run_job(job) + + self.assertEqual(wait_mock.call_count, 2) + wait_mock.assert_called_with(5) + + def test_refresh_job_sends_new_episode_and_failure_notifications(self): + class FakeStore: + def all_show_ids(self, categories=None): + return ["show-a", "show-b"] + + def refresh(self, show_id, include_thumbnail=True): + if show_id == "show-a": + return { + "show_id": "show-a", + "title": "Show A", + "status": "updated", + "had_new_episodes": True, + "sub_count": 12, + "dub_count": 4, + "sub_delta": 1, + "dub_delta": 0, + "expected_count": 12, + "airing_status": "Finished", + "thumbnail_path": None, + } + return { + "show_id": "show-b", + "title": "Show B", + "status": "error", + "status_message": "Could not refresh episodes: boom", + "had_new_episodes": False, + } + + service = APP.WatchlistRefreshJobs( + FakeStore(), + config_getter=lambda: { + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": [ + "watchlist_refresh_new_episodes", + "watchlist_refresh_failed", + ], + }, + start_worker=False, + ) + job = service.start()["job"] + job = service._next_pending() + + with mock.patch.object(queue_jobs, "send_discord_webhook_event") as send_webhook: + service._run_job(job) + + self.assertEqual(send_webhook.call_count, 2) + self.assertEqual(send_webhook.call_args_list[0].args[1], "watchlist_refresh_new_episodes") + self.assertEqual(send_webhook.call_args_list[0].args[2]["items"][0]["title"], "Show A") + self.assertEqual(send_webhook.call_args_list[1].args[1], "watchlist_refresh_failed") + self.assertEqual(send_webhook.call_args_list[1].args[2]["items"][0]["title"], "Show B") + + +class DiscordWebhookFormattingTests(unittest.TestCase): + def test_download_completed_webhook_sends_full_file_list_across_embeds(self): + payloads = [] + with mock.patch.object(APP.app_support, "_post_discord_webhook", side_effect=lambda url, payload, attachments=None, timeout=10: payloads.append(payload)): + result = APP.app_support.send_discord_webhook_event( + { + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": ["download_completed"], + }, + "download_completed", + { + "title": "Big Download", + "mode": "sub", + "episodes": "1-12", + "category": "finished", + "moved_files": [f"/tmp/downloads/Big Download/File {index:02d}.mp4" for index in range(1, 130)], + }, + ) + + self.assertTrue(result["sent"]) + self.assertGreaterEqual(len(payloads), 1) + all_descriptions = "\n".join( + embed.get("description", "") + for payload in payloads + for embed in payload.get("embeds", []) + ) + self.assertIn("/tmp/downloads/Big Download/File 01.mp4", all_descriptions) + self.assertIn("/tmp/downloads/Big Download/File 129.mp4", all_descriptions) + + def test_jellyfin_success_webhook_includes_moved_files(self): + payloads = [] + with mock.patch.object(APP.app_support, "_post_discord_webhook", side_effect=lambda url, payload, attachments=None, timeout=10: payloads.append(payload)): + result = APP.app_support.send_discord_webhook_event( + { + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": ["jellyfin_sync_success"], + }, + "jellyfin_sync_success", + { + "title": "Jelly Show", + "media_type": "tv", + "source": "automatic", + "target": "/srv/jellyfin/Jelly Show", + "moved_files": ["/srv/jellyfin/Jelly Show/Season 01/Jelly Show - S01E01.mp4"], + }, + ) + + self.assertTrue(result["sent"]) + self.assertEqual(payloads[0]["content"], "ani-cli-web moved a finished library into Jellyfin.") + self.assertIn("/srv/jellyfin/Jelly Show/Season 01/Jelly Show - S01E01.mp4", payloads[0]["embeds"][1]["description"]) + + def test_tick_resets_timer_when_schedule_changes(self): + config = { + "watchlist_auto_refresh_enabled": True, + "watchlist_auto_refresh_minutes": 10, + } + start = mock.Mock(return_value={"created": True}) + scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False) + + self.assertEqual(scheduler.tick(now_monotonic=100.0), 600) + config["watchlist_auto_refresh_minutes"] = 5 + self.assertEqual(scheduler.tick(now_monotonic=150.0), 300) + start.assert_not_called() + + def test_tick_returns_none_when_disabled(self): + config = { + "watchlist_auto_refresh_enabled": False, + "watchlist_auto_refresh_minutes": 10, + } + start = mock.Mock(return_value={"created": True}) + scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False) + + self.assertIsNone(scheduler.tick(now_monotonic=100.0)) + start.assert_not_called() + + +class AutoDownloadQueueTests(unittest.TestCase): + def test_queue_watchlist_auto_download_skips_initial_refresh(self): + item = { + "show_id": "show-1", + "title": "Example Show", + "category": "watching", + "previous_last_checked": None, + "had_new_episodes": True, + } + with mock.patch.object( + APP, + "ensure_runtime", + return_value={"config": {"auto_download_enabled": True}, "download_queue": mock.Mock()}, + ): + result = APP.queue_watchlist_auto_download(item, refresh_source="initial") + self.assertFalse(result["queued"]) + self.assertEqual(result["reason"], "source:initial") + + def test_queue_watchlist_auto_download_queues_only_missing_new_episodes(self): + item = { + "show_id": "show-42", + "title": "Queue Show", + "category": "watching", + "previous_last_checked": "2026-05-25T10:00:00+00:00", + "had_new_episodes": False, + "previous_dub_count": 10, + "previous_sub_count": 10, + "dub_episode_values": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"], + "sub_episode_values": ["1", "2", "3"], + "auto_download_mode": "dub", + "auto_download_quality": "best", + "auto_download_name": 'Queue Show: Alt/Name', + "auto_download_source_name": "Queue Show Source", + "auto_download_series": "3", + "auto_download_offset": "13", + "downloaded_dub_episodes": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], + "downloaded_sub_episodes": [], + } + download_queue = mock.Mock() + download_queue.covered_episodes.return_value = {"11"} + download_queue.add.side_effect = [ + {"id": "job-12", "episodes": "12"}, + ] + runtime = { + "config": { + "auto_download_enabled": True, + "auto_download_mode": "dub", + "auto_download_quality": "best", + "download_dir": "/tmp/downloads", + }, + "download_queue": download_queue, + } + with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( + APP, + "search_anime", + side_effect=AssertionError("search should not be required"), + ): + result = APP.queue_watchlist_auto_download(item, refresh_source="scheduled") + + self.assertTrue(result["queued"]) + self.assertEqual(result["episodes"], ["12"]) + download_queue.add.assert_called_once_with( + { + "show_id": "show-42", + "query": "Queue Show Source", + "title": "Queue Show", + "anime_name": "Queue Show- Alt-Name", + "media_type": "tv", + "season": "3", + "episode_offset": "13", + "mode": "dub", + "quality": "best", + "episodes": "12", + "download_dir": "/tmp/downloads", + } + ) + + def test_queue_watchlist_auto_download_can_queue_existing_backlog_after_initial_seed_refresh(self): + item = { + "show_id": "show-99", + "title": "Backlog Show", + "category": "watching", + "previous_last_checked": "2026-05-25T10:00:00+00:00", + "had_new_episodes": False, + "dub_episode_values": ["1", "2", "3"], + "sub_episode_values": [], + "auto_download_mode": "dub", + "auto_download_quality": "best", + "auto_download_name": "Backlog Show", + "auto_download_source_name": "Backlog Show Search", + "auto_download_series": "1", + "auto_download_offset": None, + "downloaded_dub_episodes": [], + "downloaded_sub_episodes": [], + } + download_queue = mock.Mock() + download_queue.covered_episodes.return_value = set() + download_queue.add.return_value = {"id": "job-backlog", "episodes": "1-3"} + runtime = { + "config": { + "auto_download_enabled": True, + "auto_download_mode": "dub", + "auto_download_quality": "best", + "download_dir": "/tmp/downloads", + }, + "download_queue": download_queue, + } + with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( + APP, + "search_anime", + side_effect=AssertionError("search should not be required"), + ): + result = APP.queue_watchlist_auto_download(item, refresh_source="manual") + + self.assertTrue(result["queued"]) + self.assertEqual(result["episodes"], ["1-3"]) + download_queue.add.assert_called_once_with( + { + "show_id": "show-99", + "query": "Backlog Show Search", + "title": "Backlog Show", + "anime_name": "Backlog Show", + "media_type": "tv", + "season": "1", + "episode_offset": None, + "mode": "dub", + "quality": "best", + "episodes": "1-3", + "download_dir": "/tmp/downloads", + } + ) + + def test_queue_watchlist_auto_download_can_queue_backlog_on_first_manual_refresh(self): + item = { + "show_id": "show-first-refresh", + "title": "First Refresh Show", + "category": "watching", + "previous_last_checked": None, + "had_new_episodes": False, + "dub_episode_values": ["1", "2", "3"], + "sub_episode_values": [], + "auto_download_mode": "dub", + "auto_download_quality": "best", + "auto_download_name": "First Refresh Show", + "auto_download_source_name": "First Refresh Source", + "auto_download_series": "1", + "auto_download_offset": None, + "downloaded_dub_episodes": [], + "downloaded_sub_episodes": [], + } + download_queue = mock.Mock() + download_queue.covered_episodes.return_value = set() + download_queue.add.return_value = {"id": "job-first-refresh", "episodes": "1-3"} + runtime = { + "config": { + "auto_download_enabled": True, + "auto_download_mode": "dub", + "auto_download_quality": "best", + "download_dir": "/tmp/downloads", + }, + "download_queue": download_queue, + } + with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( + APP, + "search_anime", + side_effect=AssertionError("search should not be required"), + ): + result = APP.queue_watchlist_auto_download(item, refresh_source="manual") + + self.assertTrue(result["queued"]) + self.assertEqual(result["episodes"], ["1-3"]) + download_queue.add.assert_called_once_with( + { + "show_id": "show-first-refresh", + "query": "First Refresh Source", + "title": "First Refresh Show", + "anime_name": "First Refresh Show", + "media_type": "tv", + "season": "1", + "episode_offset": None, + "mode": "dub", + "quality": "best", + "episodes": "1-3", + "download_dir": "/tmp/downloads", + } + ) + + def test_queue_watchlist_auto_download_passes_episode_offset_to_jobs(self): + item = { + "show_id": "show-77", + "title": "Split Season Show", + "category": "watching", + "previous_last_checked": "2026-05-25T10:00:00+00:00", + "had_new_episodes": False, + "dub_episode_values": ["1", "2"], + "sub_episode_values": [], + "auto_download_mode": "dub", + "auto_download_quality": "best", + "auto_download_name": "Split Season Show", + "auto_download_source_name": "Split Season Search", + "auto_download_series": "2", + "auto_download_offset": "13", + "downloaded_dub_episodes": [], + "downloaded_sub_episodes": [], + } + download_queue = mock.Mock() + download_queue.covered_episodes.return_value = set() + download_queue.add.return_value = {"id": "job-split", "episodes": "1-2"} + runtime = { + "config": { + "auto_download_enabled": True, + "auto_download_mode": "dub", + "auto_download_quality": "best", + "download_dir": "/tmp/downloads", + }, + "download_queue": download_queue, + } + with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( + APP, + "search_anime", + side_effect=AssertionError("search should not be required"), + ): + result = APP.queue_watchlist_auto_download(item, refresh_source="manual") + + self.assertTrue(result["queued"]) + download_queue.add.assert_called_once_with( + { + "show_id": "show-77", + "query": "Split Season Search", + "title": "Split Season Show", + "anime_name": "Split Season Show", + "media_type": "tv", + "season": "2", + "episode_offset": "13", + "mode": "dub", + "quality": "best", + "episodes": "1-2", + "download_dir": "/tmp/downloads", + } + ) + +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: + shutdown_runtime.return_value = True + 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: + shutdown_runtime.return_value = True + 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_reset_runtime_refuses_to_clear_globals_when_shutdown_does_not_finish(self): + APP.initialize_runtime(start_workers=False) + try: + current_config = APP.CONFIG + current_watchlist = APP.WATCHLIST + current_queue = APP.DOWNLOAD_QUEUE + current_refresh = APP.WATCHLIST_REFRESH + current_auto_refresh = APP.WATCHLIST_AUTO_REFRESH + current_jellyfin_sync = APP.WATCHLIST_JELLYFIN_SYNC + with mock.patch.object(APP, "shutdown_runtime", return_value=False): + with self.assertRaises(RuntimeError): + APP.reset_runtime(wait=True) + + self.assertIs(APP.CONFIG, current_config) + self.assertIs(APP.WATCHLIST, current_watchlist) + self.assertIs(APP.DOWNLOAD_QUEUE, current_queue) + self.assertIs(APP.WATCHLIST_REFRESH, current_refresh) + self.assertIs(APP.WATCHLIST_AUTO_REFRESH, current_auto_refresh) + self.assertIs(APP.WATCHLIST_JELLYFIN_SYNC, current_jellyfin_sync) + finally: + APP.reset_runtime(wait=True) + APP.initialize_runtime(start_workers=False) + + def test_save_runtime_config_replaces_runtime_config(self): + original = APP.CONFIG + original_scheduler = APP.WATCHLIST_AUTO_REFRESH + try: + APP.CONFIG = { + "mode": "sub", + "quality": "best", + "download_dir": "/tmp/old", + "watchlist_auto_refresh_enabled": False, + "watchlist_auto_refresh_minutes": 60, + "watchlist_refresh_delay_seconds": 5, + "auto_download_enabled": False, + "auto_download_mode": "dub", + "auto_download_quality": "best", + "discord_webhook_url": "", + "discord_webhook_events": [], + } + APP.WATCHLIST_AUTO_REFRESH = mock.Mock() + saved = APP.save_runtime_config( + { + "mode": "dub", + "quality": "720", + "download_dir": "/tmp/new", + "watchlist_auto_refresh_enabled": True, + "watchlist_auto_refresh_minutes": 15, + "watchlist_refresh_delay_seconds": 8, + "auto_download_enabled": True, + "auto_download_mode": "sub", + "auto_download_quality": "720", + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": "/tmp/jellyfin-tv", + "jellyfin_movie_dir": "/tmp/jellyfin-movies", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", + "discord_webhook_events": ["runtime_error"], + } + ) + self.assertEqual(saved["mode"], "dub") + self.assertEqual(APP.CONFIG["mode"], "dub") + self.assertEqual(APP.CONFIG["quality"], "720") + self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"]) + self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15) + self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8) + self.assertTrue(APP.CONFIG["auto_download_enabled"]) + self.assertEqual(APP.CONFIG["auto_download_mode"], "sub") + self.assertEqual(APP.CONFIG["auto_download_quality"], "720") + self.assertTrue(APP.CONFIG["jellyfin_sync_enabled"]) + self.assertEqual(APP.CONFIG["jellyfin_tv_dir"], "/tmp/jellyfin-tv") + self.assertEqual(APP.CONFIG["jellyfin_movie_dir"], "/tmp/jellyfin-movies") + self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token") + self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"]) + APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with() + finally: + APP.CONFIG = original + APP.WATCHLIST_AUTO_REFRESH = original_scheduler + + 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) + self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH) + self.assertIsNone(APP.WATCHLIST_JELLYFIN_SYNC) + 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) + self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH.worker) + self.assertIsNone(APP.WATCHLIST_JELLYFIN_SYNC.worker) + finally: + APP.reset_runtime(wait=True) + APP.initialize_runtime(start_workers=False) + + def test_config_post_accepts_watchlist_schedule_fields(self): + body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","anipy_cli_fallback_enabled":true,"watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}' + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"]) + self.assertEqual(handler.json_payload["download_methods"], ["ani-cli", "anipy-cli"]) + self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"]) + self.assertEqual(handler.json_payload["watchlist_auto_refresh_minutes"], 25) + self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9) + self.assertTrue(handler.json_payload["auto_download_enabled"]) + self.assertEqual(handler.json_payload["auto_download_mode"], "dub") + self.assertEqual(handler.json_payload["auto_download_quality"], "720") + self.assertTrue(handler.json_payload["jellyfin_sync_enabled"]) + self.assertEqual(handler.json_payload["jellyfin_tv_dir"], "/tmp/jellyfin-tv") + self.assertEqual(handler.json_payload["jellyfin_movie_dir"], "/tmp/jellyfin-movies") + + def test_config_post_accepts_download_methods(self): + body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","download_methods":["ani-cli","animdl"]}' + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["download_methods"], ["ani-cli", "animdl"]) + self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"]) + + def test_version_route_includes_cli_versions(self): + handler = DummyHandler("/api/version") + APP.Handler.do_GET(handler) + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["name"], APP.APP_NAME) + self.assertEqual(handler.json_payload["version"], APP.VERSION) + self.assertIn("ani_cli_version", handler.json_payload) + self.assertIn("anipy_cli_version", handler.json_payload) + self.assertIn("animdl_version", handler.json_payload) + + def test_ani_cli_version_probe_uses_download_player(self): + http_handler.installed_ani_cli_version.cache_clear() + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + return mock.Mock(stdout="ani-cli 4.9.0\n", stderr="") + + try: + with mock.patch.object(http_handler.subprocess, "run", side_effect=fake_run): + self.assertEqual(http_handler.installed_ani_cli_version(), "ani-cli 4.9.0") + finally: + http_handler.installed_ani_cli_version.cache_clear() + + self.assertEqual(calls[0][0], [http_handler.ANI_CLI, "--version"]) + self.assertEqual(calls[0][1]["env"]["ANI_CLI_PLAYER"], "download") + + def test_dependencies_route_reports_fallback_tool_status(self): + handler = DummyHandler("/api/dependencies") + APP.Handler.do_GET(handler) + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertIn("curl-impersonate", handler.json_payload) + self.assertIn("anipy-cli", handler.json_payload) + self.assertIn("animdl", handler.json_payload) + + def test_dependency_status_checks_docker_fallback_tool_paths(self): + with mock.patch.object(http_handler, "cli_executable_exists", return_value=True), mock.patch.object( + http_handler, "cli_executable_exists_any", return_value=True + ) as exists_any: + status = http_handler.dependency_status() + + self.assertTrue(status["animdl"]) + exists_any.assert_any_call( + "curl_firefox135", + "curl_chrome136", + "curl_chrome116", + "curl_ff117", + ) + exists_any.assert_any_call( + http_handler.ANIMDL, + "animdl", + "/usr/local/bin/animdl", + "/usr/bin/animdl", + ) + exists_any.assert_any_call( + http_handler.ANIPY_CLI, + "anipy-cli", + "/usr/local/bin/anipy-cli", + "/usr/bin/anipy-cli", + ) + + def test_clear_failed_route_removes_failed_jobs(self): + with APP.DOWNLOAD_QUEUE._connect() as conn: + conn.execute("DELETE FROM jobs") + job = APP.DOWNLOAD_QUEUE.add( + { + "query": "Failed Route Show", + "title": "Failed Route Show", + "anime_name": "Failed Route Show", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": "/tmp/example", + "season": "1", + } + ) + with APP.DOWNLOAD_QUEUE.lock: + stored = APP.DOWNLOAD_QUEUE._find(job["id"]) + stored["status"] = "failed" + APP.DOWNLOAD_QUEUE._save_job_locked(stored) + + handler = DummyHandler("/api/queue/clear-failed", body=b"{}", content_length=2) + APP.Handler.do_POST(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["count"], 1) + self.assertEqual(APP.DOWNLOAD_QUEUE.list()["total"], 0) + + def test_changelog_route_returns_project_changelog_content(self): + handler = DummyHandler("/api/changelog") + APP.Handler.do_GET(handler) + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertIn("content", handler.json_payload) + self.assertIn("# Changelog", handler.json_payload["content"]) + + def test_loaded_runtime_version_matches_version_file(self): + expected_version = (ROOT / "VERSION").read_text(encoding="utf-8").strip() + self.assertEqual(APP.VERSION, expected_version) + + def test_filesystem_browse_route_lists_directories(self): + with tempfile.TemporaryDirectory() as temp_root: + Path(temp_root, "folder-a").mkdir() + Path(temp_root, "folder-b").mkdir() + Path(temp_root, "file.txt").write_text("example", encoding="utf-8") + handler = DummyHandler(f"/api/fs/browse?path={APP.quote(temp_root)}&mode=dir") + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["path"], temp_root) + self.assertEqual([entry["name"] for entry in handler.json_payload["entries"]], ["folder-a", "folder-b"]) + self.assertTrue(all(entry["is_dir"] for entry in handler.json_payload["entries"])) + + def test_remote_filesystem_browse_rejects_paths_outside_configured_roots(self): + with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root: + APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"}) + encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") + handler = DummyHandler(f"/api/fs/browse?path={APP.quote(blocked_root)}&mode=dir") + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {encoded}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN) + self.assertIn("allowed remote browse roots", handler.error_message.lower()) + + def test_remote_filesystem_browse_defaults_blank_path_to_allowed_root(self): + with tempfile.TemporaryDirectory() as allowed_root: + Path(allowed_root, "folder-a").mkdir() + APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"}) + encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") + handler = DummyHandler("/api/fs/browse?path=&mode=dir") + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {encoded}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["path"], allowed_root) + self.assertEqual(handler.json_payload["home_path"], allowed_root) + self.assertEqual(handler.json_payload["root_paths"][0], allowed_root) + self.assertIn(allowed_root, handler.json_payload["root_paths"]) + + def test_remote_path_roots_include_explicit_allowlist(self): + with tempfile.TemporaryDirectory() as base_root, tempfile.TemporaryDirectory() as extra_root: + APP.save_runtime_config({"download_dir": base_root, "mode": "sub", "quality": "best"}) + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_REMOTE_PATH_ROOTS": extra_root, + }, + clear=False, + ): + roots = APP.app_support.configured_remote_path_roots(APP.get_config_snapshot()) + + self.assertIn(Path(base_root).resolve(), roots) + self.assertIn(Path(extra_root).resolve(), roots) + + def test_remote_filesystem_browse_returns_all_allowed_root_paths(self): + with tempfile.TemporaryDirectory() as base_root, tempfile.TemporaryDirectory() as extra_root: + APP.save_runtime_config({"download_dir": base_root, "mode": "sub", "quality": "best"}) + encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") + handler = DummyHandler("/api/fs/browse?path=&mode=dir") + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {encoded}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + "ANI_CLI_WEB_REMOTE_PATH_ROOTS": extra_root, + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["root_paths"][:2], [extra_root, base_root]) + self.assertIn(extra_root, handler.json_payload["root_paths"]) + self.assertIn(base_root, handler.json_payload["root_paths"]) + + def test_config_post_accepts_discord_webhook_fields(self): + body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.com/api/webhooks/123456/test-token","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}' + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token") + self.assertEqual( + handler.json_payload["discord_webhook_events"], + ["runtime_error", "watchlist_refresh_failed"], + ) + + def test_config_post_rejects_non_discord_webhook_host(self): + body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://example.com/not-discord"}' + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("official discord webhook host", handler.error_message.lower()) + + def test_config_post_partial_update_preserves_existing_paths(self): + with tempfile.TemporaryDirectory() as existing_root: + APP.save_runtime_config( + { + "download_dir": existing_root, + "mode": "sub", + "quality": "best", + "jellyfin_tv_dir": existing_root, + } + ) + body = b'{"mode":"dub"}' + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["download_dir"], existing_root) + self.assertEqual(handler.json_payload["jellyfin_tv_dir"], existing_root) + self.assertEqual(handler.json_payload["mode"], "dub") + + def test_remote_config_post_rejects_download_dir_outside_allowed_roots(self): + with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root: + APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"}) + body = json.dumps( + { + "download_dir": blocked_root, + "mode": "sub", + "quality": "best", + } + ).encode("utf-8") + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {base64.b64encode(b'demo:secret-pass').decode('ascii')}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN) + self.assertIn("remote download directory", handler.error_message.lower()) + + def test_remote_config_post_accepts_download_dir_inside_explicit_allowlist(self): + with tempfile.TemporaryDirectory() as current_root, tempfile.TemporaryDirectory() as allowed_root: + APP.save_runtime_config({"download_dir": current_root, "mode": "sub", "quality": "best"}) + body = json.dumps( + { + "download_dir": allowed_root, + "mode": "sub", + "quality": "best", + } + ).encode("utf-8") + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {base64.b64encode(b'demo:secret-pass').decode('ascii')}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + "ANI_CLI_WEB_REMOTE_PATH_ROOTS": allowed_root, + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["download_dir"], allowed_root) + + def test_config_routes_hide_auth_fields_and_preserve_saved_credentials(self): + APP.save_runtime_config( + { + "download_dir": "/tmp/example", + "mode": "sub", + "quality": "best", + "auth_username": "demo", + "auth_password": "secret-pass", + } + ) + + post_body = b'{"download_dir":"/tmp/updated","mode":"dub","quality":"720"}' + post_handler = DummyHandler("/api/config", body=post_body, content_length=len(post_body)) + APP.Handler.do_POST(post_handler) + + self.assertEqual(post_handler.json_status, HTTPStatus.OK) + self.assertNotIn("auth_username", post_handler.json_payload) + self.assertNotIn("auth_password", post_handler.json_payload) + self.assertEqual(APP.get_config_snapshot()["auth_username"], "demo") + self.assertEqual(APP.get_config_snapshot()["auth_password"], "secret-pass") + + get_handler = DummyHandler("/api/config") + APP.Handler.do_GET(get_handler) + self.assertEqual(get_handler.json_status, HTTPStatus.OK) + self.assertNotIn("auth_username", get_handler.json_payload) + self.assertNotIn("auth_password", get_handler.json_payload) + + def test_config_webhook_test_route_uses_unsaved_payload(self): + body = b'{"discord_webhook_url":"https://discord.com/api/webhooks/123456/test-token","discord_webhook_events":["runtime_error"]}' + handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body)) + test_webhook = mock.Mock(return_value={"message": "ok"}) + handler.handler_context = mock.Mock(test_discord_webhook_config=test_webhook) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["message"], "ok") + test_webhook.assert_called_once_with( + {"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["runtime_error"]} + ) + + def test_config_webhook_test_route_rejects_non_discord_webhook_url(self): + body = b'{"discord_webhook_url":"https://example.com/not-discord","discord_webhook_events":["runtime_error"]}' + handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("official discord webhook host", handler.error_message.lower()) + + 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: + APP.Handler.body_json(handler) + self.assertEqual(ctx.exception.status, HTTPStatus.REQUEST_ENTITY_TOO_LARGE) + + def test_body_json_requires_application_json_content_type(self): + handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=2) + handler.headers["Content-Type"] = "text/plain" + with self.assertRaises(ValueError) as ctx: + APP.Handler.body_json(handler) + self.assertIn("content-type", str(ctx.exception).lower()) + + def test_body_json_rejects_non_utf8_payload(self): + handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"\xff", content_length=1) + with self.assertRaises(ValueError) as ctx: + APP.Handler.body_json(handler) + self.assertIn("utf-8", str(ctx.exception).lower()) + + def test_config_post_rejects_non_object_json_body(self): + body = b"[]" + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("json object", handler.error_message.lower()) + + def test_remove_post_missing_show_id_returns_bad_request(self): + handler = DummyHandler("/api/watchlist/remove", body=b"{}", content_length=2) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("show_id", handler.error_message) + + def test_upload_thumbnail_post_missing_required_fields_returns_bad_request(self): + body = b'{"show_id":"show-1"}' + handler = DummyHandler("/api/watchlist/upload-thumbnail", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("data_url", handler.error_message) + + def test_watchlist_add_duplicate_returns_ok_instead_of_created(self): + body = b'{"show_id":"show-1","title":"Show 1","category":"watching"}' + handler = DummyHandler("/api/watchlist/add", body=body, content_length=len(body)) + result = {"message": "Already tracking Show 1.", "item": {"show_id": "show-1", "title": "Show 1"}, "created": False} + with mock.patch.object(APP.WATCHLIST, "add", return_value=result): + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_payload, result) + self.assertEqual(handler.json_status, HTTPStatus.OK) + + def test_watchlist_add_rejects_non_object_json_body(self): + body = b"[]" + handler = DummyHandler("/api/watchlist/add", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("json object", handler.error_message.lower()) + + def test_ensure_thumbnails_rejects_non_array_show_ids(self): + body = b'{"show_ids":"show-1"}' + handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("show_ids", handler.error_message) + + def test_ensure_thumbnails_parses_string_false_force(self): + body = b'{"show_ids":["show-1"],"force":"false"}' + handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body)) + with mock.patch.object(APP.WATCHLIST, "ensure_thumbnails", return_value={"items": []}) as ensure_thumbnails: + APP.Handler.do_POST(handler) + ensure_thumbnails.assert_called_once_with(["show-1"], limit=6, force=False) + self.assertEqual(handler.json_status, HTTPStatus.OK) + + def test_ensure_thumbnails_rejects_invalid_force_value(self): + body = b'{"show_ids":["show-1"],"force":"maybe"}' + handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("force", handler.error_message) + + def test_thumbnail_route_serves_cached_file_without_fetching(self): + thumb_path = APP.THUMBNAIL_ROOT / "show-1.jpg" + thumb_path.parent.mkdir(parents=True, exist_ok=True) + thumb_path.write_bytes(b"jpg") + + handler = DummyHandler("/api/watchlist/thumb/show-1") + with mock.patch.object(APP.WATCHLIST, "get", return_value={"thumbnail_path": "show-1.jpg"}) as get_item, mock.patch.object( + APP.WATCHLIST, "ensure_thumbnail", side_effect=AssertionError("thumbnail fetch should not run") + ): + APP.Handler.do_GET(handler) + + get_item.assert_called_once_with("show-1") + self.assertEqual(handler.file_path, thumb_path) + self.assertIsNone(handler.error_status) + + def test_watchlist_refresh_status_route_returns_json(self): + handler = DummyHandler("/api/watchlist/refresh-status") + payload = {"job": {"id": "refresh-1", "status": "running"}, "running": True} + with mock.patch.object(APP.WATCHLIST_REFRESH, "status", return_value=payload): + APP.Handler.do_GET(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.OK) + + def test_watchlist_homepage_route_returns_summary_counts(self): + handler = DummyHandler("/api/watchlist/homepage") + payload = {"watchlist": 1, "planned": 0, "finished": 2, "dropped": 0, "total": 3} + handler.handler_context = mock.Mock(get_watchlist_homepage_summary=mock.Mock(return_value=payload)) + + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.OK) + + def test_update_all_post_returns_accepted_for_new_job(self): + handler = DummyHandler("/api/watchlist/update-all", body=b"{}", content_length=2) + payload = {"message": "Watchlist refresh started.", "job": {"id": "refresh-1"}, "created": True} + with mock.patch.object(APP.WATCHLIST_REFRESH, "start", return_value=payload): + APP.Handler.do_POST(handler) + 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_watchlist_download_all_post_returns_created_job(self): + body = b'{"show_id":"show-1","mode":"sub"}' + handler = DummyHandler("/api/watchlist/download-all", body=body, content_length=len(body)) + payload = {"message": "Queued Show 1 for sub download.", "job": {"id": "job-1"}, "item": {"show_id": "show-1"}} + handler.handler_context = mock.Mock(download_watchlist_item=mock.Mock(return_value=payload)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.CREATED) + + def test_watchlist_auto_download_settings_post_returns_updated_item(self): + body = b'{"show_id":"show-1","mode":"dub","quality":"best","name":"Example Name","source_name":"Example Source","series":"2","episode_offset":"13"}' + handler = DummyHandler("/api/watchlist/update-auto-download", body=body, content_length=len(body)) + payload = {"message": "Saved auto-download settings for Show 1.", "item": {"show_id": "show-1"}} + handler.handler_context = mock.Mock(update_watchlist_auto_download=mock.Mock(return_value=payload)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.OK) + handler.handler_context.update_watchlist_auto_download.assert_called_once_with( + "show-1", "dub", "best", "Example Name", "Example Source", "2", "13" + ) + + def test_watchlist_media_type_post_returns_updated_item(self): + body = b'{"show_id":"show-1","media_type":"movie"}' + handler = DummyHandler("/api/watchlist/update-media-type", body=body, content_length=len(body)) + payload = {"message": "Saved Show 1 as Movie.", "item": {"show_id": "show-1", "media_type": "movie"}} + handler.handler_context = mock.Mock(update_watchlist_media_type=mock.Mock(return_value=payload)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.OK) + + def test_jellyfin_sync_status_route_returns_json(self): + handler = DummyHandler("/api/config/jellyfin/sync-status") + payload = {"running": True, "job": {"id": "job-1", "status": "running"}} + handler.handler_context = mock.Mock(get_jellyfin_sync_status=mock.Mock(return_value=payload)) + APP.Handler.do_GET(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.OK) + + def test_jellyfin_sync_post_returns_job_payload(self): + body = b'{"download_dir":"/tmp/example","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}' + handler = DummyHandler("/api/config/jellyfin/sync", body=body, content_length=len(body)) + payload = {"message": "Jellyfin handoff started.", "created": True, "job": {"id": "job-1", "status": "pending"}} + handler.handler_context = mock.Mock( + get_config_snapshot=mock.Mock(return_value={}), + start_jellyfin_sync=mock.Mock(return_value=payload), + http_error=APP.HttpError, + ) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.ACCEPTED) + + def test_jellyfin_sync_post_merges_partial_payload_with_saved_config(self): + body = b'{"jellyfin_sync_enabled":true}' + handler = DummyHandler("/api/config/jellyfin/sync", body=body, content_length=len(body)) + payload = {"message": "Jellyfin handoff already queued.", "created": False, "job": {"id": "job-1", "status": "pending"}} + handler.handler_context = mock.Mock( + get_config_snapshot=mock.Mock( + return_value={ + "download_dir": "/tmp/example", + "mode": "sub", + "quality": "best", + "jellyfin_sync_enabled": False, + "jellyfin_tv_dir": "/tmp/jellyfin-tv", + "jellyfin_movie_dir": "/tmp/jellyfin-movies", + } + ), + start_jellyfin_sync=mock.Mock(return_value=payload), + ) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_status, HTTPStatus.ACCEPTED) + handler.handler_context.start_jellyfin_sync.assert_called_once_with( + { + "download_dir": "/tmp/example", + "mode": "sub", + "quality": "best", + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": "/tmp/jellyfin-tv", + "jellyfin_movie_dir": "/tmp/jellyfin-movies", + } + ) + + def test_watchlist_reconcile_post_merges_partial_payload_with_saved_config(self): + body = b'{"download_dir":"/tmp/override"}' + handler = DummyHandler("/api/config/watchlist/reconcile-downloads", body=body, content_length=len(body)) + payload = {"message": "ok", "total": 2, "changed": 1, "items": []} + handler.handler_context = mock.Mock( + get_config_snapshot=mock.Mock( + return_value={ + "download_dir": "/tmp/example", + "mode": "sub", + "quality": "best", + } + ), + reconcile_watchlist_downloaded_files=mock.Mock(return_value=payload), + ) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_status, HTTPStatus.OK) + handler.handler_context.reconcile_watchlist_downloaded_files.assert_called_once_with( + { + "download_dir": "/tmp/override", + "mode": "sub", + "quality": "best", + } + ) + + def test_watchlist_export_route_returns_json_attachment(self): + handler = DummyHandler("/api/config/watchlist/export") + payload = {"format": "ani-cli-web-watchlist-backup", "tables": {"watchlist": {"rows": []}}} + handler.handler_context = mock.Mock( + ensure_runtime=mock.Mock(), + runtime_state=mock.Mock(return_value={}), + export_watchlist_backup=mock.Mock(return_value=payload), + ) + + APP.Handler.do_GET(handler) + + self.assertEqual(handler.response_status, HTTPStatus.OK) + headers = dict(handler.response_headers) + self.assertEqual(headers.get("Content-Type"), "application/json") + self.assertIn("attachment", headers.get("Content-Disposition", "")) + self.assertEqual(json.loads(handler.wfile.getvalue().decode("utf-8")), payload) + + def test_watchlist_import_route_returns_import_summary(self): + body = b'{"format":"ani-cli-web-watchlist-backup","format_version":1,"tables":{"watchlist":{"rows":[]}}}' + handler = DummyHandler("/api/config/watchlist/import", body=body, content_length=len(body)) + payload = {"message": "Imported 0 watchlist entries from backup.", "imported": 0, "removals": 0} + handler.handler_context = mock.Mock(import_watchlist_backup=mock.Mock(return_value=payload)) + + APP.Handler.do_POST(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload, payload) + handler.handler_context.import_watchlist_backup.assert_called_once() + + 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, "send_discord_webhook_event") as send_webhook, mock.patch.object( + http_handler.traceback, "print_exc" + ) as print_exc: + APP.Handler.exception(handler, RuntimeError("boom")) + + print_exc.assert_not_called() + send_webhook.assert_called_once() + 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_log_message_skips_queue_poll_requests(self): + handler = object.__new__(APP.Handler) + handler.path = "/api/queue?page=1&per_page=10" + handler.command = "GET" + handler.address_string = lambda: "127.0.0.1" + + with mock.patch("builtins.print") as print_mock: + APP.Handler.log_message(handler, '"GET /api/queue?page=1&per_page=10 HTTP/1.1" %s -', 200) + + print_mock.assert_not_called() + + def test_log_message_keeps_non_poll_requests(self): + handler = object.__new__(APP.Handler) + handler.path = "/api/config" + handler.command = "GET" + handler.address_string = lambda: "127.0.0.1" + + with mock.patch("builtins.print") as print_mock: + APP.Handler.log_message(handler, '"GET /api/config HTTP/1.1" %s -', 200) + + print_mock.assert_called_once() + + def test_page_links_marks_active_page(self): + markup = APP.render_page_links("config") + self.assertIn('class="page-link active" href="/config"', markup) + self.assertIn('class="page-link" href="/queue"', markup) + self.assertIn('class="page-link" href="/watchlist"', markup) + + def test_sidebar_brand_can_attach_optional_badge_id(self): + markup = APP.render_sidebar_brand("Shared note", "Search", badge_id="serverBadge") + self.assertIn('id="serverBadge"', markup) + self.assertIn("Shared note", markup) + + def test_queue_page_queue_loader_guards_against_stale_responses(self): + self.assertIn("queueRequestId", APP.QUEUE_HTML) + self.assertIn("if (requestId !== state.queueRequestId) return data;", APP.QUEUE_HTML) + + def test_queue_page_renders_queue_logs_newest_first(self): + self.assertIn('[...(job.log || [])].slice(-28).reverse().join("\\n")', APP.QUEUE_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.QUEUE_HTML) + self.assertIn("async function api(path, options = {})", APP.CONFIG_HTML) + self.assertIn("async function api(path, options = {})", APP.WATCHLIST_HTML) + + def test_pages_include_favicon_link(self): + expected = '' + self.assertIn(expected, APP.INDEX_HTML) + self.assertIn(expected, APP.QUEUE_HTML) + self.assertIn(expected, APP.CONFIG_HTML) + self.assertIn(expected, APP.WATCHLIST_HTML) + + def test_config_page_places_dependencies_before_settings_grid(self): + self.assertIn('
', APP.CONFIG_HTML) + self.assertIn('
', APP.CONFIG_HTML) + self.assertLess(APP.CONFIG_HTML.index('
'), APP.CONFIG_HTML.index('
')) + self.assertIn("@media (max-width: 1080px)", APP.CONFIG_HTML) + self.assertIn("justify-items: stretch;", APP.CONFIG_HTML) + self.assertIn('id="aniCliVersionLine"', APP.CONFIG_HTML) + self.assertIn('id="anipyCliVersionLine"', APP.CONFIG_HTML) + self.assertIn('id="animdlVersionLine"', APP.CONFIG_HTML) + self.assertIn('ani-cli ${data.ani_cli_version || "Unavailable"}', APP.CONFIG_HTML) + self.assertIn('anipy-cli ${data.anipy_cli_version || "Unavailable"}', APP.CONFIG_HTML) + self.assertIn('animdl ${data.animdl_version || "Unavailable"}', APP.CONFIG_HTML) + self.assertIn('class="download-method" type="checkbox" value="animdl"', APP.CONFIG_HTML) + self.assertIn('id="openChangelogBtn"', APP.CONFIG_HTML) + self.assertIn('id="changelogOverlay"', APP.CONFIG_HTML) + self.assertIn('const data = await api("/api/changelog");', APP.CONFIG_HTML) + self.assertIn('changelogContent.textContent = data.content || "Changelog unavailable.";', APP.CONFIG_HTML) + + def test_config_page_includes_host_path_browser_controls(self): + self.assertIn('class="ghost browse-path-btn"', APP.CONFIG_HTML) + self.assertIn('id="pathBrowserOverlay"', APP.CONFIG_HTML) + self.assertIn('/api/fs/browse?path=${encodeURIComponent(path || state.pathBrowser.path || "")}', APP.CONFIG_HTML) + self.assertIn('rootGroup.className = "browser-roots";', APP.CONFIG_HTML) + self.assertIn('const active = data.path === rootPath || String(data.path || "").startsWith(`${rootPath}/`);', APP.CONFIG_HTML) + + def test_search_page_sends_watchlist_auto_download_series(self): + self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML) + + def test_queue_page_includes_clear_failed_bulk_action(self): + self.assertIn('id="clearFailedBtn"', APP.QUEUE_HTML) + self.assertIn('/api/queue/clear-failed', APP.QUEUE_HTML) + self.assertIn('Removed failed jobs', APP.QUEUE_HTML) + + def test_queue_page_formats_jellyfin_handoff_jobs(self): + self.assertIn('job.job_type === "jellyfin_handoff"', APP.QUEUE_HTML) + self.assertIn('const progress = `${job.completed || 0}/${job.total || 0} entries`;', APP.QUEUE_HTML) + self.assertIn('const moved = `${job.moved || 0} moved`;', APP.QUEUE_HTML) + + def test_search_page_queues_selected_title_with_result_index(self): + self.assertIn('query: state.selected.title,', APP.INDEX_HTML) + self.assertIn('result_index: state.selected.index,', APP.INDEX_HTML) + + def test_search_page_renders_poster_results_under_selection_panel(self): + self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML) + self.assertIn("Select results", APP.INDEX_HTML) + self.assertIn("resultThumbnailUrl(title)", APP.INDEX_HTML) + self.assertIn('/api/search/thumb?title=', APP.INDEX_HTML) + self.assertIn("result-subtitle", APP.INDEX_HTML) + self.assertIn("Also known as:", APP.INDEX_HTML) + + def test_search_anime_includes_alternative_titles(self): + payload = { + "shows": { + "edges": [ + { + "_id": "show-1", + "name": "Example Show", + "availableEpisodes": {"sub": 12}, + } + ] + } + } + with mock.patch.object(APP, "graph_request", return_value=payload), mock.patch.object( + APP, "extract_anidb_alternative_titles_for_title", + return_value=[{"label": "English", "value": "Example Show English"}], + ): + results = APP.search_anime("Example Show", "sub") + + self.assertEqual(results[0]["alternative_titles"], [{"label": "English", "value": "Example Show English"}]) + + def test_pages_share_serial_polling_helper(self): + self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML) + self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML) + self.assertIn("startSerialPoll(() => pollVisibleJobs(), 2500)", APP.QUEUE_HTML) + self.assertIn("startSerialPoll(async () => {", APP.WATCHLIST_HTML) + self.assertIn("window.clearTimeout(timer);", APP.QUEUE_HTML) + self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.QUEUE_HTML) + self.assertIn('window.addEventListener("beforeunload", stop, { once: true });', 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_page_highlights_fully_ready_cards(self): + self.assertIn(".anime-card.fully-ready", APP.WATCHLIST_HTML) + self.assertIn("const isFullyReady = item.sub_complete && item.dub_complete;", APP.WATCHLIST_HTML) + self.assertIn("card.className = watchlistCardClassName(item);", APP.WATCHLIST_HTML) + + def test_watchlist_page_download_all_replaces_open_action(self): + self.assertIn("Download all", APP.WATCHLIST_HTML) + self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML) + self.assertIn('class="download-picker"', APP.WATCHLIST_HTML) + self.assertIn('downloadPicker.classList.toggle("visible")', APP.WATCHLIST_HTML) + self.assertIn('downloadWatchlistItem(item, "sub"', APP.WATCHLIST_HTML) + self.assertIn('downloadWatchlistItem(item, "dub"', APP.WATCHLIST_HTML) + self.assertIn("grid-template-columns: repeat(2, minmax(0, 1fr));", APP.WATCHLIST_HTML) + self.assertLess(APP.WATCHLIST_HTML.index(''), APP.WATCHLIST_HTML.index('')) + self.assertLess(APP.WATCHLIST_HTML.index(''), APP.WATCHLIST_HTML.index('')) + + def test_watchlist_page_renders_alternative_titles_toggle(self): + self.assertIn("Alternative titles", APP.WATCHLIST_HTML) + self.assertIn('class="ghost alt-titles-toggle"', APP.WATCHLIST_HTML) + self.assertIn('id="altTitlesOverlay"', APP.WATCHLIST_HTML) + self.assertIn("openAltTitlesOverlay(item)", APP.WATCHLIST_HTML) + self.assertIn("closeAltTitlesOverlay()", APP.WATCHLIST_HTML) + self.assertIn("AniDB English alternative titles were not available for this show.", APP.WATCHLIST_HTML) + + def test_watchlist_page_renders_auto_download_editor(self): + self.assertIn("Auto-download", APP.WATCHLIST_HTML) + self.assertIn('id="autoDownloadOverlay"', APP.WATCHLIST_HTML) + self.assertIn('api("/api/watchlist/update-auto-download"', APP.WATCHLIST_HTML) + self.assertIn("openAutoDownloadOverlay(item)", APP.WATCHLIST_HTML) + self.assertIn("closeAutoDownloadOverlay()", APP.WATCHLIST_HTML) + self.assertIn("Save auto-download", APP.WATCHLIST_HTML) + self.assertIn("Manual add does not trigger it", APP.WATCHLIST_HTML) + self.assertIn("Source name", APP.WATCHLIST_HTML) + self.assertIn('id="autoDownloadSourceName"', APP.WATCHLIST_HTML) + self.assertIn("Name source", APP.WATCHLIST_HTML) + self.assertIn('class="wide-half">Name source', APP.WATCHLIST_HTML) + self.assertIn('class="wide-half">Name', APP.WATCHLIST_HTML) + self.assertIn("Custom", APP.WATCHLIST_HTML) + self.assertIn("Series", APP.WATCHLIST_HTML) + self.assertIn("Episode offset", 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() diff --git a/title_matching.py b/title_matching.py new file mode 100644 index 0000000..5635683 --- /dev/null +++ b/title_matching.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 + +"""Shared anime title normalization and matching helpers.""" + +import re +import unicodedata + + +def normalize_title_key(value): + text = unicodedata.normalize("NFKD", str(value or "")) + text = "".join(char for char in text if not unicodedata.combining(char)) + text = text.lower() + text = re.sub(r"\((19|20)\d{2}\)", " ", text) + text = re.sub(r"[^a-z0-9]+", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def normalize_identity_title_key(value): + text = normalize_title_key(value) + if not text: + return "" + text = re.sub(r"\b(\d+)(st|nd|rd|th)\s+season\b", r"season \1", text) + return re.sub(r"\s+", " ", text).strip() + + +def base_title_variants(value): + text = str(value or "").strip() + variants = [] + if text: + variants.append(text) + patterns = [ + r"\s+\((19|20)\d{2}\)\s*$", + r"\s+season\s+\d+\s*$", + r"\s+part\s+\d+\s*$", + r"\s+cour\s+\d+\s*$", + r"\s+\d+(st|nd|rd|th)\s+season\s*$", + ] + changed = True + current = text + while current and changed: + changed = False + for pattern in patterns: + trimmed = re.sub(pattern, "", current, flags=re.IGNORECASE).strip(" -:") + if trimmed and trimmed != current: + current = trimmed + if current not in variants: + variants.append(current) + changed = True + return variants + + +def title_match_variants(value): + variants = set() + for raw_value in base_title_variants(value): + normalized = normalize_title_key(raw_value) + if normalized: + variants.add(normalized) + stripped = re.sub(r"\b(19|20)\d{2}\b", " ", normalized) + stripped = re.sub(r"\s+", " ", stripped).strip() + if stripped: + variants.add(stripped) + return variants + + +def identity_title_variants(value): + variants = set() + normalized = normalize_identity_title_key(value) + if normalized: + variants.add(normalized) + stripped = re.sub(r"\b(19|20)\d{2}\b", " ", normalized) + stripped = re.sub(r"\s+", " ", stripped).strip() + if stripped: + variants.add(stripped) + return variants + + +def title_lookup_queries(value): + queries = [] + for raw_value in base_title_variants(value): + text = str(raw_value or "").strip() + if text and text not in queries: + queries.append(text) + return queries diff --git a/watchlist_identity.py b/watchlist_identity.py new file mode 100644 index 0000000..dc346d3 --- /dev/null +++ b/watchlist_identity.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 + +"""Helpers for resolving watchlist identities from queued download jobs.""" + +import re + + +def _resolution_result(show_id, title, reason, return_reason): + if return_reason: + return show_id, title, reason + return show_id, title + + +def _normalized_variants(title, normalize_title_key_fn, base_title_variants_fn): + del base_title_variants_fn + variants = set() + normalized_title = normalize_title_key_fn(title) + if normalized_title: + variants.add(normalized_title) + stripped = re.sub(r"\b(19|20)\d{2}\b", " ", normalized_title) + stripped = re.sub(r"\s+", " ", stripped).strip() + if stripped: + variants.add(stripped) + return variants + + +def titles_confidently_match(expected_title, candidate_title, normalize_title_key_fn, base_title_variants_fn): + expected_variants = _normalized_variants(expected_title, normalize_title_key_fn, base_title_variants_fn) + candidate_variants = _normalized_variants(candidate_title, normalize_title_key_fn, base_title_variants_fn) + if not expected_variants or not candidate_variants: + return False + return bool(expected_variants & candidate_variants) + + +def resolve_job_watchlist_identity( + job, + *, + search_fn, + normalize_title_key_fn, + base_title_variants_fn, + default_mode, + return_reason=False, +): + show_id = str(job.get("show_id") or "").strip() + title = str(job.get("title") or job.get("anime_name") or "").strip() + if show_id: + return _resolution_result(show_id, title, "", return_reason) + raw_index = job.get("result_index") + if raw_index in (None, ""): + index = None + else: + try: + index = max(1, int(raw_index)) + except (TypeError, ValueError): + return _resolution_result("", title, "invalid queued result index", return_reason) + query = str(job.get("query") or title).strip() + if not query or not title: + return _resolution_result("", title, "missing queued search query or title", return_reason) + try: + results = search_fn(query, job.get("mode") or default_mode) + except Exception as exc: + return _resolution_result("", title, f"search fallback failed: {exc}", return_reason) + matches = [] + for position, result in enumerate(results, start=1): + candidate_title = str(result.get("title") or "").strip() + if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn): + continue + resolved_show_id = str(result.get("id") or "").strip() + if not resolved_show_id: + continue + matches.append((position, result, candidate_title, resolved_show_id)) + if not matches: + candidate_title = str((results[0].get("title") if results else "") or "").strip() + return _resolution_result("", title, f"title mismatch with fallback search result: {candidate_title or 'unknown'}", return_reason) + if index is not None: + indexed = next((match for match in matches if match[0] == index), None) + if indexed is not None: + _position, result, candidate_title, resolved_show_id = indexed + return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason) + if len(matches) != 1: + return _resolution_result("", title, "ambiguous fallback search results", return_reason) + _position, result, candidate_title, resolved_show_id = matches[0] + if not resolved_show_id: + return _resolution_result("", title, "fallback search result did not include a show_id", return_reason) + return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason) diff --git a/watchlist_page.py b/watchlist_page.py new file mode 100644 index 0000000..2feaa03 --- /dev/null +++ b/watchlist_page.py @@ -0,0 +1,1650 @@ +#!/usr/bin/env python3 + +"""Watchlist page template for Kaizoku.""" + +from template_helpers import BROWSER_API_HELPER, BROWSER_POLL_HELPER, render_page_links, render_sidebar_brand + + +WATCHLIST_HTML = r""" + + + + + Kaizoku - watchlist + + + + +
+ + +
+
+
+
+

Tracked shows

+

Compact cards with local AnimeSchedule cover caching when artwork is available.

+
+ +
+
+
+
+ 0 + Tracked shows +
+
+ 0 + Sub episodes +
+
+ 0 + Dub episodes +
+
+
+ +
+
+
+

Watchlist library

+

Compact poster grid for faster scanning.

+

+
+
+
+
+
+
+
+
+ + + + + + + + +""" diff --git a/web_templates.py b/web_templates.py new file mode 100644 index 0000000..3500bc3 --- /dev/null +++ b/web_templates.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + +"""Aggregated template exports for Kaizoku.""" + +from config_page import CONFIG_HTML +from queue_page import QUEUE_HTML +from search_page import INDEX_HTML +from template_helpers import PAGE_LINKS, render_page_links, render_sidebar_brand +from watchlist_page import WATCHLIST_HTML + +__all__ = [ + "CONFIG_HTML", + "INDEX_HTML", + "PAGE_LINKS", + "QUEUE_HTML", + "WATCHLIST_HTML", + "render_page_links", + "render_sidebar_brand", +]