Replace legacy branding with Kaizoku
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
.git
|
.git
|
||||||
.ani-cli-web
|
|
||||||
.kaizoku
|
.kaizoku
|
||||||
downloads
|
downloads
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
.ani-cli-web/
|
|
||||||
.kaizoku/
|
.kaizoku/
|
||||||
downloads/
|
downloads/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
+31
-25
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.52.0 - 2026-08-09
|
||||||
|
|
||||||
|
- Removed remaining legacy app branding from the UI, runtime settings, cookies, Docker files, ignores, README, and active tests.
|
||||||
|
- Standardized exposed environment variables on the `KAIZOKU_*` namespace.
|
||||||
|
- Removed obsolete downloader fallback names from active config, dependency reporting, and queue tests.
|
||||||
|
|
||||||
## 0.51.9 - 2026-08-09
|
## 0.51.9 - 2026-08-09
|
||||||
|
|
||||||
- Closed the Search result floating window after successful Add to queue or Add to watchlist actions.
|
- Closed the Search result floating window after successful Add to queue or Add to watchlist actions.
|
||||||
@@ -87,7 +93,7 @@
|
|||||||
## 0.49.7 - 2026-07-21
|
## 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.
|
- 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.
|
- Added an agent note to preserve existing Kaizoku configuration and watchlist data during future updates.
|
||||||
|
|
||||||
## 0.49.6 - 2026-07-21
|
## 0.49.6 - 2026-07-21
|
||||||
|
|
||||||
@@ -103,13 +109,13 @@
|
|||||||
|
|
||||||
## 0.49.4 - 2026-07-19
|
## 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.
|
- Added optional download job stdout mirroring with `KAIZOKU_JOB_STDOUT`, enabled by default in Docker and Docker Compose so container log collectors can capture downloader output.
|
||||||
|
|
||||||
## 0.49.3 - 2026-07-19
|
## 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.
|
- Changed Docker builds to copy the current local `Kaizoku` 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.
|
- 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.
|
- Removed the unused `KAIZOKU` compose build argument and updated Docker documentation to match the local-checkout build flow.
|
||||||
|
|
||||||
## 0.49.2 - 2026-07-19
|
## 0.49.2 - 2026-07-19
|
||||||
|
|
||||||
@@ -190,7 +196,7 @@
|
|||||||
|
|
||||||
## 0.45.12 - 2026-05-26
|
## 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.
|
- Changed `Kaizoku` 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.
|
- Added regression coverage to ensure the loaded runtime version matches the checked-in `VERSION` file.
|
||||||
|
|
||||||
## 0.45.11 - 2026-05-26
|
## 0.45.11 - 2026-05-26
|
||||||
@@ -275,7 +281,7 @@
|
|||||||
## 0.41.0 - 2026-05-25
|
## 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.
|
- 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.
|
- Added `KAIZOKU_AUTH_USERNAME` and `KAIZOKU_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
|
## 0.40.6 - 2026-05-25
|
||||||
|
|
||||||
@@ -413,23 +419,23 @@
|
|||||||
|
|
||||||
## 0.30.3 - 2026-05-17
|
## 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.
|
- 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 `./Kaizoku` launcher file being executable inside the container.
|
||||||
|
|
||||||
## 0.30.2 - 2026-05-17
|
## 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`.
|
- Added container runtime support for `USER_UID` and `USER_GID`, letting `Kaizoku` 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.
|
- Updated the Docker and Compose documentation to show how to pass host UID/GID values into the container.
|
||||||
|
|
||||||
## 0.30.1 - 2026-05-17
|
## 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.
|
- Added a `docker-compose.yaml` file that builds the bundled Docker image, mounts `./downloads` and `./.Kaizoku`, 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.
|
- Documented the Compose workflow alongside the existing Docker build and `docker run` setup.
|
||||||
|
|
||||||
## 0.30.0 - 2026-05-17
|
## 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 a Docker image build that clones `ani-cli` and `Kaizoku` from configurable git remotes, installs `ani-cli` system-wide, and starts `Kaizoku` from `/app`.
|
||||||
- Added container startup support for `UPDATE_ON_START=true`, which runs `sudo ani-cli --update` before launching the web 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.
|
- Documented the Docker build arguments, runtime mounts for `./downloads` and `./.Kaizoku`, and the default container networking and storage layout.
|
||||||
|
|
||||||
## 0.29.5 - 2026-05-16
|
## 0.29.5 - 2026-05-16
|
||||||
|
|
||||||
@@ -456,7 +462,7 @@
|
|||||||
## 0.29.1 - 2026-05-16
|
## 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.
|
- 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/`.
|
- Cleaned up per-job staging directories after canceled and failed download runs, preventing partial artifacts from piling up under `.Kaizoku/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.
|
- 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.
|
- Expanded regression coverage for queue startup failures, failed-download staging cleanup, and stale thumbnail source fallback behavior.
|
||||||
|
|
||||||
@@ -516,7 +522,7 @@
|
|||||||
## 0.22.0 - 2026-05-15
|
## 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.
|
- 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.
|
- Fixed deferred worker startup to keep respecting `KAIZOKU_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.
|
- 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.
|
- 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.
|
- Centralized the shared browser `api()` fetch helper used by the Search, Config, and Watchlist pages.
|
||||||
@@ -532,7 +538,7 @@
|
|||||||
|
|
||||||
- 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.
|
- 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.
|
- 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.
|
- Ensured runtime-backed HTTP requests start background workers when allowed, while still respecting `KAIZOKU_DISABLE_WORKER` in tests and intentionally worker-free runs.
|
||||||
- Expanded regression coverage for the new non-blocking watchlist add/download-sync flow.
|
- Expanded regression coverage for the new non-blocking watchlist add/download-sync flow.
|
||||||
|
|
||||||
## 0.19.0 - 2026-05-15
|
## 0.19.0 - 2026-05-15
|
||||||
@@ -558,7 +564,7 @@
|
|||||||
|
|
||||||
## 0.16.0 - 2026-05-15
|
## 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.
|
- Hardened non-local access so `KAIZOKU_ALLOW_REMOTE=1` now also requires `KAIZOKU_REMOTE_TOKEN`, accepted through `Authorization: Bearer ...` or `X-Kaizoku-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.
|
- 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.
|
- 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.
|
- Resolve missing queue `show_id` values when jobs are queued, making completed-download watchlist sync stable even if upstream search ordering changes later.
|
||||||
@@ -606,14 +612,14 @@
|
|||||||
|
|
||||||
- Fixed running-job cancellation so the worker now consistently finishes canceled downloads as `canceled` instead of drifting into failed or completed final states.
|
- 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.
|
- 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.
|
- Added loopback-only request protection by default, with `KAIZOKU_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.
|
- 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.
|
- Added focused regression tests for cancellation propagation, full watchlist refresh coverage, local-access helpers, and shared template helpers.
|
||||||
|
|
||||||
## 0.9.0 - 2026-05-13
|
## 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.
|
- Reworked Search, Config, and Watchlist with a 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.
|
- Kept the existing Kaizoku behavior and routing intact while unifying the refreshed design language across the whole web UI.
|
||||||
|
|
||||||
## 0.8.16 - 2026-05-13
|
## 0.8.16 - 2026-05-13
|
||||||
|
|
||||||
@@ -655,12 +661,12 @@
|
|||||||
|
|
||||||
## 0.8.8 - 2026-05-11
|
## 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.
|
- Moved `config.json` into the project-local `.Kaizoku/` 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.
|
- 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
|
## 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.
|
- Moved all project runtime state that previously lived under `~/.local/state/kaizoku/` into the project-local `.Kaizoku/` 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.
|
- 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
|
## 0.8.6 - 2026-05-11
|
||||||
@@ -680,18 +686,18 @@
|
|||||||
|
|
||||||
## 0.8.3 - 2026-05-11
|
## 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.
|
- Migrated any legacy watchlist thumbnails from the old state-directory cache into the new project-local `.Kaizoku/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.
|
- 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.
|
- 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
|
## 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.
|
- Moved watchlist thumbnail storage into the project-local `.Kaizoku/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.
|
- 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
|
## 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 a `--debug` runtime flag for `Kaizoku` and `./Kaizoku` 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.
|
- 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
|
## 0.8.0 - 2026-05-11
|
||||||
@@ -779,7 +785,7 @@
|
|||||||
## 0.2.2 - 2026-05-11
|
## 0.2.2 - 2026-05-11
|
||||||
|
|
||||||
- Fixed a broken watchlist merge that caused `app.py` to fail during startup with an `IndentationError`.
|
- 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.
|
- Restored the web server entrypoint so `./Kaizoku` 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.
|
- 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
|
## 0.2.1 - 2026-05-11
|
||||||
@@ -803,6 +809,6 @@
|
|||||||
|
|
||||||
## 0.1.0 - 2026-05-11
|
## 0.1.0 - 2026-05-11
|
||||||
|
|
||||||
- Split `ani-cli-web` into a standalone project that uses system-wide `ani-cli`.
|
- Split `Kaizoku` into a standalone project that uses system-wide `ani-cli`.
|
||||||
- Added project hygiene files: `.gitignore`, `VERSION`, and this changelog.
|
- Added project hygiene files: `.gitignore`, `VERSION`, and this changelog.
|
||||||
- Documented runtime state handling and standalone project layout.
|
- Documented runtime state handling and standalone project layout.
|
||||||
|
|||||||
+4
-4
@@ -2,10 +2,10 @@ FROM python:3.12-slim-bookworm
|
|||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
PATH=/usr/local/bin:/usr/bin:/bin \
|
PATH=/usr/local/bin:/usr/bin:/bin \
|
||||||
ANI_CLI_DOWNLOAD_DIR=/downloads \
|
KAIZOKU_DOWNLOAD_DIR=/downloads \
|
||||||
ANI_CLI_WEB_HOST=0.0.0.0 \
|
KAIZOKU_HOST=0.0.0.0 \
|
||||||
ANI_CLI_WEB_PORT=8421 \
|
KAIZOKU_PORT=8421 \
|
||||||
ANI_CLI_WEB_JOB_STDOUT=true \
|
KAIZOKU_JOB_STDOUT=true \
|
||||||
UPDATE_ON_START=false \
|
UPDATE_ON_START=false \
|
||||||
USER_UID= \
|
USER_UID= \
|
||||||
USER_GID=
|
USER_GID=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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.
|
Kaizoku is a local web app for searching, tracking, and downloading anime from Anikoto, AniNeko, and AnimePahe.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -47,15 +47,14 @@ http://127.0.0.1:8421/
|
|||||||
|
|
||||||
Useful environment variables:
|
Useful environment variables:
|
||||||
|
|
||||||
- `ANI_CLI_WEB_HOST=0.0.0.0`
|
- `KAIZOKU_HOST=0.0.0.0`
|
||||||
- `ANI_CLI_WEB_PORT=8421`
|
- `KAIZOKU_PORT=8421`
|
||||||
- `ANI_CLI_WEB_ALLOW_REMOTE=1`
|
- `KAIZOKU_ALLOW_REMOTE=1`
|
||||||
- `ANI_CLI_WEB_AUTH_USERNAME=admin`
|
- `KAIZOKU_AUTH_USERNAME=admin`
|
||||||
- `ANI_CLI_WEB_AUTH_PASSWORD=choose-a-long-random-password`
|
- `KAIZOKU_AUTH_PASSWORD=choose-a-long-random-password`
|
||||||
- `ANI_CLI_WEB_JOB_STDOUT=true`
|
- `KAIZOKU_JOB_STDOUT=true`
|
||||||
- `ANI_CLI_WEB_STATE_ROOT=/path/to/state`
|
- `KAIZOKU_STATE_ROOT=/path/to/state`
|
||||||
|
- `KAIZOKU_DOWNLOAD_DIR=/downloads`
|
||||||
The `ANI_CLI_WEB_*` names are kept as compatibility aliases while the app transitions from the previous project.
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ import app_support
|
|||||||
import provider_bridge
|
import provider_bridge
|
||||||
from app_support import (
|
from app_support import (
|
||||||
AGENT,
|
AGENT,
|
||||||
ALLANIME_API,
|
|
||||||
ALLANIME_REFERER,
|
|
||||||
ALLMANGA_SITE_BASE,
|
|
||||||
ANIDB_ANIME_PAGE,
|
ANIDB_ANIME_PAGE,
|
||||||
ANIDB_TITLES_CACHE,
|
ANIDB_TITLES_CACHE,
|
||||||
ANIDB_TITLES_PATH,
|
ANIDB_TITLES_PATH,
|
||||||
@@ -37,6 +34,9 @@ from app_support import (
|
|||||||
CONFIG_PATH,
|
CONFIG_PATH,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
MAX_JSON_BODY_BYTES,
|
MAX_JSON_BODY_BYTES,
|
||||||
|
METADATA_API,
|
||||||
|
METADATA_REFERER,
|
||||||
|
METADATA_SITE_BASE,
|
||||||
MODE_CHOICES,
|
MODE_CHOICES,
|
||||||
QUALITY_CHOICES,
|
QUALITY_CHOICES,
|
||||||
send_discord_webhook_event,
|
send_discord_webhook_event,
|
||||||
@@ -104,11 +104,11 @@ WATCHLIST_BACKUP_FORMAT_VERSION = 1
|
|||||||
def graph_request(query, variables, timeout=25):
|
def graph_request(query, variables, timeout=25):
|
||||||
payload = json.dumps({"variables": variables, "query": query}).encode("utf-8")
|
payload = json.dumps({"variables": variables, "query": query}).encode("utf-8")
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{ALLANIME_API}/api",
|
f"{METADATA_API}/api",
|
||||||
data=payload,
|
data=payload,
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Referer": ALLANIME_REFERER,
|
"Referer": METADATA_REFERER,
|
||||||
"User-Agent": AGENT,
|
"User-Agent": AGENT,
|
||||||
},
|
},
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -117,17 +117,17 @@ def graph_request(query, variables, timeout=25):
|
|||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
raw = response.read().decode("utf-8")
|
raw = response.read().decode("utf-8")
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise RuntimeError(f"Could not reach AllAnime: {exc}") from exc
|
raise RuntimeError(f"Could not reach metadata service: {exc}") from exc
|
||||||
except TimeoutError as exc:
|
except TimeoutError as exc:
|
||||||
raise RuntimeError("AllAnime request timed out") from exc
|
raise RuntimeError("Metadata request timed out") from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise RuntimeError("AllAnime returned an unreadable response") from exc
|
raise RuntimeError("Metadata service returned an unreadable response") from exc
|
||||||
|
|
||||||
if data.get("errors"):
|
if data.get("errors"):
|
||||||
message = data["errors"][0].get("message", "AllAnime returned an error")
|
message = data["errors"][0].get("message", "Metadata service returned an error")
|
||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
return data.get("data", {})
|
return data.get("data", {})
|
||||||
|
|
||||||
@@ -163,8 +163,8 @@ def search_thumbnail_cache_key(title):
|
|||||||
def anime_source_url(show_id, title):
|
def anime_source_url(show_id, title):
|
||||||
normalized_show_id = str(show_id or "").strip()
|
normalized_show_id = str(show_id or "").strip()
|
||||||
if not normalized_show_id:
|
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"{METADATA_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='')}"
|
return f"{METADATA_SITE_BASE}/bangumi/{quote(normalized_show_id, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
def infer_image_extension(url, content_type):
|
def infer_image_extension(url, content_type):
|
||||||
@@ -2318,7 +2318,7 @@ class WatchlistStore:
|
|||||||
def import_backup(self, backup):
|
def import_backup(self, backup):
|
||||||
if not isinstance(backup, dict):
|
if not isinstance(backup, dict):
|
||||||
raise ValueError("Watchlist backup must be a JSON object.")
|
raise ValueError("Watchlist backup must be a JSON object.")
|
||||||
if backup.get("format") not in {"kaizoku-watchlist-backup", "ani-cli-web-watchlist-backup"}:
|
if backup.get("format") != "kaizoku-watchlist-backup":
|
||||||
raise ValueError("Backup format is not recognized.")
|
raise ValueError("Backup format is not recognized.")
|
||||||
try:
|
try:
|
||||||
format_version = int(backup.get("format_version") or 0)
|
format_version = int(backup.get("format_version") or 0)
|
||||||
@@ -3120,8 +3120,6 @@ def save_runtime_config(config):
|
|||||||
current = get_config_snapshot(fallback=DEFAULT_CONFIG)
|
current = get_config_snapshot(fallback=DEFAULT_CONFIG)
|
||||||
merged = dict(current)
|
merged = dict(current)
|
||||||
if isinstance(config, dict):
|
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)
|
merged.update(config)
|
||||||
normalized = normalize_config(merged)
|
normalized = normalize_config(merged)
|
||||||
previous_fingerprint = remote_access_session_fingerprint(current)
|
previous_fingerprint = remote_access_session_fingerprint(current)
|
||||||
|
|||||||
+27
-58
@@ -38,16 +38,13 @@ def configured_cli_command(env_name, executable_name):
|
|||||||
|
|
||||||
|
|
||||||
KAIZOKU_DOWNLOADER = configured_cli_command("KAIZOKU_DOWNLOADER_BIN", "provider_downloader.py")
|
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"
|
APP_NAME = "Kaizoku"
|
||||||
VERSION_FILE = PROJECT_ROOT / "VERSION"
|
VERSION_FILE = PROJECT_ROOT / "VERSION"
|
||||||
CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md"
|
CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md"
|
||||||
ALLANIME_BASE = "allanime.day"
|
METADATA_API_HOST = "allanime.day"
|
||||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
METADATA_API = f"https://api.{METADATA_API_HOST}"
|
||||||
ALLANIME_REFERER = "https://allmanga.to"
|
METADATA_REFERER = "https://allmanga.to"
|
||||||
ALLMANGA_SITE_BASE = "https://allmanga.to"
|
METADATA_SITE_BASE = "https://allmanga.to"
|
||||||
ANIMESCHEDULE_API = "https://animeschedule.net/api/v3"
|
ANIMESCHEDULE_API = "https://animeschedule.net/api/v3"
|
||||||
ANIMESCHEDULE_IMAGE_BASE = "https://img.animeschedule.net/production/assets/public/img/"
|
ANIMESCHEDULE_IMAGE_BASE = "https://img.animeschedule.net/production/assets/public/img/"
|
||||||
ANIDB_TITLES_URL = "https://anidb.net/api/anime-titles.xml.gz"
|
ANIDB_TITLES_URL = "https://anidb.net/api/anime-titles.xml.gz"
|
||||||
@@ -125,16 +122,24 @@ def env_flag(name, default=False):
|
|||||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def env_value(name, default="", aliases=()):
|
||||||
|
for key in (name, *aliases):
|
||||||
|
raw = os.environ.get(key)
|
||||||
|
if raw is not None and str(raw).strip():
|
||||||
|
return str(raw).strip()
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def debug_enabled():
|
def debug_enabled():
|
||||||
return DEBUG_MODE or env_flag("ANI_CLI_WEB_DEBUG")
|
return DEBUG_MODE or env_flag("KAIZOKU_DEBUG")
|
||||||
|
|
||||||
|
|
||||||
def remote_access_allowed():
|
def remote_access_allowed():
|
||||||
return env_flag("ANI_CLI_WEB_ALLOW_REMOTE")
|
return env_flag("KAIZOKU_ALLOW_REMOTE")
|
||||||
|
|
||||||
|
|
||||||
def remote_access_username(config=None):
|
def remote_access_username(config=None):
|
||||||
configured = str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip()
|
configured = env_value("KAIZOKU_AUTH_USERNAME")
|
||||||
if configured:
|
if configured:
|
||||||
return configured
|
return configured
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
@@ -143,7 +148,7 @@ def remote_access_username(config=None):
|
|||||||
|
|
||||||
|
|
||||||
def remote_access_password(config=None):
|
def remote_access_password(config=None):
|
||||||
configured = str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip()
|
configured = env_value("KAIZOKU_AUTH_PASSWORD")
|
||||||
if configured:
|
if configured:
|
||||||
return configured
|
return configured
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
@@ -270,11 +275,11 @@ def clear_remote_access_sessions(config=None):
|
|||||||
|
|
||||||
|
|
||||||
def background_workers_enabled():
|
def background_workers_enabled():
|
||||||
return not env_flag("ANI_CLI_WEB_DISABLE_WORKER")
|
return not env_flag("KAIZOKU_DISABLE_WORKER")
|
||||||
|
|
||||||
|
|
||||||
def job_stdout_enabled():
|
def job_stdout_enabled():
|
||||||
return env_flag("ANI_CLI_WEB_JOB_STDOUT")
|
return env_flag("KAIZOKU_JOB_STDOUT")
|
||||||
|
|
||||||
|
|
||||||
def client_address_is_local(host):
|
def client_address_is_local(host):
|
||||||
@@ -302,8 +307,9 @@ def debug_log(event, **fields):
|
|||||||
|
|
||||||
|
|
||||||
def default_download_dir():
|
def default_download_dir():
|
||||||
if os.environ.get("ANI_CLI_DOWNLOAD_DIR"):
|
configured = env_value("KAIZOKU_DOWNLOAD_DIR")
|
||||||
return os.environ["ANI_CLI_DOWNLOAD_DIR"]
|
if configured:
|
||||||
|
return configured
|
||||||
home_downloads = Path.home() / "Downloads" / "Anime"
|
home_downloads = Path.home() / "Downloads" / "Anime"
|
||||||
if os.access(home_downloads.parent if home_downloads.parent.exists() else Path.home(), os.W_OK):
|
if os.access(home_downloads.parent if home_downloads.parent.exists() else Path.home(), os.W_OK):
|
||||||
return str(home_downloads)
|
return str(home_downloads)
|
||||||
@@ -312,9 +318,8 @@ def default_download_dir():
|
|||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"download_dir": default_download_dir(),
|
"download_dir": default_download_dir(),
|
||||||
"mode": os.environ.get("ANI_CLI_MODE", "sub"),
|
"mode": env_value("KAIZOKU_MODE", "sub"),
|
||||||
"quality": os.environ.get("ANI_CLI_QUALITY", "best"),
|
"quality": env_value("KAIZOKU_QUALITY", "best"),
|
||||||
"anipy_cli_fallback_enabled": False,
|
|
||||||
"download_methods": ["kaizoku"],
|
"download_methods": ["kaizoku"],
|
||||||
"provider": "anikoto",
|
"provider": "anikoto",
|
||||||
"watchlist_auto_refresh_enabled": False,
|
"watchlist_auto_refresh_enabled": False,
|
||||||
@@ -328,8 +333,8 @@ DEFAULT_CONFIG = {
|
|||||||
"jellyfin_movie_dir": "",
|
"jellyfin_movie_dir": "",
|
||||||
"discord_webhook_url": "",
|
"discord_webhook_url": "",
|
||||||
"discord_webhook_events": [],
|
"discord_webhook_events": [],
|
||||||
"auth_username": str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip(),
|
"auth_username": env_value("KAIZOKU_AUTH_USERNAME"),
|
||||||
"auth_password": str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip(),
|
"auth_password": env_value("KAIZOKU_AUTH_PASSWORD"),
|
||||||
}
|
}
|
||||||
PRIVATE_CONFIG_KEYS = {"auth_username", "auth_password"}
|
PRIVATE_CONFIG_KEYS = {"auth_username", "auth_password"}
|
||||||
|
|
||||||
@@ -339,7 +344,7 @@ def now_iso():
|
|||||||
|
|
||||||
|
|
||||||
def project_app_root():
|
def project_app_root():
|
||||||
configured = str(os.environ.get("ANI_CLI_WEB_STATE_ROOT") or "").strip()
|
configured = env_value("KAIZOKU_STATE_ROOT")
|
||||||
if configured:
|
if configured:
|
||||||
return Path(configured).expanduser()
|
return Path(configured).expanduser()
|
||||||
return PROJECT_ROOT / ".kaizoku"
|
return PROJECT_ROOT / ".kaizoku"
|
||||||
@@ -473,7 +478,6 @@ def normalize_config(data):
|
|||||||
if quality.endswith("p") and quality[:-1].isdigit():
|
if quality.endswith("p") and quality[:-1].isdigit():
|
||||||
quality = quality[:-1]
|
quality = quality[:-1]
|
||||||
download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"])
|
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")
|
download_methods = config.get("download_methods")
|
||||||
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
|
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
|
||||||
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
|
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
|
||||||
@@ -491,10 +495,6 @@ def normalize_config(data):
|
|||||||
auth_username = str(config.get("auth_username") or "").strip()
|
auth_username = str(config.get("auth_username") or "").strip()
|
||||||
auth_password = str(config.get("auth_password") 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):
|
if isinstance(download_methods, str):
|
||||||
method_values = [part.strip().lower() for part in re.split(r"[\s,]+", download_methods) if part.strip()]
|
method_values = [part.strip().lower() for part in re.split(r"[\s,]+", download_methods) if part.strip()]
|
||||||
elif isinstance(download_methods, list):
|
elif isinstance(download_methods, list):
|
||||||
@@ -541,7 +541,6 @@ def normalize_config(data):
|
|||||||
config["quality"] = quality if quality in QUALITY_CHOICES else "best"
|
config["quality"] = quality if quality in QUALITY_CHOICES else "best"
|
||||||
config["download_dir"] = str(Path(download_dir).expanduser())
|
config["download_dir"] = str(Path(download_dir).expanduser())
|
||||||
config["download_methods"] = ["kaizoku"]
|
config["download_methods"] = ["kaizoku"]
|
||||||
config["anipy_cli_fallback_enabled"] = False
|
|
||||||
provider = str(config.get("provider") or DEFAULT_CONFIG.get("provider") or "anikoto").strip().lower()
|
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["provider"] = provider if provider in PROVIDER_CHOICES else "anikoto"
|
||||||
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
|
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
|
||||||
@@ -577,7 +576,7 @@ def sanitize_public_config(config):
|
|||||||
|
|
||||||
|
|
||||||
def _env_remote_path_roots():
|
def _env_remote_path_roots():
|
||||||
raw = str(os.environ.get("ANI_CLI_WEB_REMOTE_PATH_ROOTS") or "").strip()
|
raw = env_value("KAIZOKU_REMOTE_PATH_ROOTS")
|
||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
roots = []
|
roots = []
|
||||||
@@ -1122,26 +1121,6 @@ def extract_episode_from_filename(path):
|
|||||||
return match.group(1) if match else None
|
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):
|
def unique_destination(path):
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return path
|
return path
|
||||||
@@ -1170,15 +1149,11 @@ def finalize_library_files(job):
|
|||||||
|
|
||||||
moved = []
|
moved = []
|
||||||
fallback_episode = 1
|
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:
|
for source in files:
|
||||||
if media_type == "movie":
|
if media_type == "movie":
|
||||||
final_name = f"{library_name}{source.suffix}"
|
final_name = f"{library_name}{source.suffix}"
|
||||||
else:
|
else:
|
||||||
ep = None
|
ep = None
|
||||||
if backend in {"anipy-cli", "animdl"} and requested_episode_values:
|
|
||||||
ep = requested_episode_values.pop(0)
|
|
||||||
if ep is None:
|
if ep is None:
|
||||||
ep = extract_episode_from_filename(source)
|
ep = extract_episode_from_filename(source)
|
||||||
if ep is None:
|
if ep is None:
|
||||||
@@ -1255,12 +1230,6 @@ def normalize_result_index(value):
|
|||||||
return index
|
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):
|
def command_for_job(job, backend="kaizoku", download_path=None):
|
||||||
show_id = str(job.get("show_id") or job.get("query") or "").strip()
|
show_id = str(job.get("show_id") or job.get("query") or "").strip()
|
||||||
provider = str(job.get("provider") or "").strip().lower()
|
provider = str(job.get("provider") or "").strip().lower()
|
||||||
|
|||||||
+1
-1
@@ -446,7 +446,7 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<aside>
|
<aside>
|
||||||
""" + render_sidebar_brand(
|
""" + render_sidebar_brand(
|
||||||
"Tsuki-inspired settings view for saved defaults and runtime checks.",
|
"Saved defaults, access settings, and runtime checks.",
|
||||||
"Config",
|
"Config",
|
||||||
) + render_page_links("config") + r"""
|
) + render_page_links("config") + r"""
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -7,17 +7,17 @@ services:
|
|||||||
container_name: kaizoku
|
container_name: kaizoku
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${ANI_CLI_WEB_PORT:-8421}:8421"
|
- "${KAIZOKU_PORT:-8421}:8421"
|
||||||
environment:
|
environment:
|
||||||
USER_UID: ${USER_UID:-}
|
USER_UID: ${USER_UID:-}
|
||||||
USER_GID: ${USER_GID:-}
|
USER_GID: ${USER_GID:-}
|
||||||
ANI_CLI_WEB_HOST: 0.0.0.0
|
KAIZOKU_HOST: 0.0.0.0
|
||||||
ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421}
|
KAIZOKU_PORT: ${KAIZOKU_PORT:-8421}
|
||||||
ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false}
|
KAIZOKU_ALLOW_REMOTE: ${KAIZOKU_ALLOW_REMOTE:-false}
|
||||||
ANI_CLI_WEB_AUTH_USERNAME: ${ANI_CLI_WEB_AUTH_USERNAME:-}
|
KAIZOKU_AUTH_USERNAME: ${KAIZOKU_AUTH_USERNAME:-}
|
||||||
ANI_CLI_WEB_AUTH_PASSWORD: ${ANI_CLI_WEB_AUTH_PASSWORD:-}
|
KAIZOKU_AUTH_PASSWORD: ${KAIZOKU_AUTH_PASSWORD:-}
|
||||||
ANI_CLI_WEB_JOB_STDOUT: ${ANI_CLI_WEB_JOB_STDOUT:-true}
|
KAIZOKU_JOB_STDOUT: ${KAIZOKU_JOB_STDOUT:-true}
|
||||||
ANI_CLI_DOWNLOAD_DIR: /downloads
|
KAIZOKU_DOWNLOAD_DIR: /downloads
|
||||||
volumes:
|
volumes:
|
||||||
- ./downloads:/downloads
|
- ./downloads:/downloads
|
||||||
- ./.kaizoku:/app/.kaizoku
|
- ./.kaizoku:/app/.kaizoku
|
||||||
|
|||||||
+8
-26
@@ -11,7 +11,6 @@ import traceback
|
|||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from binascii import Error as BinasciiError
|
from binascii import Error as BinasciiError
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from http.server import BaseHTTPRequestHandler
|
from http.server import BaseHTTPRequestHandler
|
||||||
@@ -33,6 +32,7 @@ from app_support import (
|
|||||||
configured_remote_path_roots,
|
configured_remote_path_roots,
|
||||||
debug_enabled,
|
debug_enabled,
|
||||||
debug_log,
|
debug_log,
|
||||||
|
env_value,
|
||||||
load_project_text_file,
|
load_project_text_file,
|
||||||
cli_executable_exists_any,
|
cli_executable_exists_any,
|
||||||
cli_executable_exists,
|
cli_executable_exists,
|
||||||
@@ -219,24 +219,9 @@ def queue_clear_failed(runtime):
|
|||||||
return result
|
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "Kaizoku/0.1"
|
server_version = "Kaizoku/0.1"
|
||||||
remote_session_cookie_name = "ani_cli_web_session"
|
remote_session_cookie_name = "kaizoku_session"
|
||||||
remote_session_cookie_max_age_seconds = 30 * 24 * 60 * 60
|
remote_session_cookie_max_age_seconds = 30 * 24 * 60 * 60
|
||||||
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status", "/api/config/jellyfin/sync-status"}
|
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status", "/api/config/jellyfin/sync-status"}
|
||||||
|
|
||||||
@@ -549,11 +534,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if not remote_access_allowed():
|
if not remote_access_allowed():
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
"Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients."
|
"Remote access is disabled. Set KAIZOKU_ALLOW_REMOTE=1 to allow non-local clients."
|
||||||
)
|
)
|
||||||
if not remote_access_credentials_configured():
|
if not remote_access_credentials_configured():
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
"Remote access requires ANI_CLI_WEB_AUTH_USERNAME and ANI_CLI_WEB_AUTH_PASSWORD for non-local clients."
|
"Remote access requires KAIZOKU_AUTH_USERNAME and KAIZOKU_AUTH_PASSWORD for non-local clients."
|
||||||
)
|
)
|
||||||
if not Handler._remote_credentials_valid(self):
|
if not Handler._remote_credentials_valid(self):
|
||||||
raise PermissionError("Remote username or password missing or invalid.")
|
raise PermissionError("Remote username or password missing or invalid.")
|
||||||
@@ -613,9 +598,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
{
|
{
|
||||||
"name": APP_NAME,
|
"name": APP_NAME,
|
||||||
"version": VERSION,
|
"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),
|
"providers": list(provider_bridge.PROVIDERS),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1054,15 +1036,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
def server_host():
|
def server_host():
|
||||||
return os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
return env_value("KAIZOKU_HOST", "127.0.0.1") or "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
def server_port():
|
def server_port():
|
||||||
raw = str(os.environ.get("ANI_CLI_WEB_PORT", "8421")).strip() or "8421"
|
raw = env_value("KAIZOKU_PORT", "8421") or "8421"
|
||||||
try:
|
try:
|
||||||
port = int(raw, 10)
|
port = int(raw, 10)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("ANI_CLI_WEB_PORT must be a valid integer") from exc
|
raise ValueError("KAIZOKU_PORT must be a valid integer") from exc
|
||||||
if not 1 <= port <= 65535:
|
if not 1 <= port <= 65535:
|
||||||
raise ValueError("ANI_CLI_WEB_PORT must be between 1 and 65535")
|
raise ValueError("KAIZOKU_PORT must be between 1 and 65535")
|
||||||
return port
|
return port
|
||||||
|
|||||||
+1
-1
@@ -438,7 +438,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<aside>
|
<aside>
|
||||||
""" + render_sidebar_brand(
|
""" + render_sidebar_brand(
|
||||||
"Tsuki-inspired control center for anime search and download flow.",
|
"Provider search and download flow for your anime library.",
|
||||||
"Search",
|
"Search",
|
||||||
badge_id="serverBadge",
|
badge_id="serverBadge",
|
||||||
) + render_page_links("search") + r"""
|
) + render_page_links("search") + r"""
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ def render_sidebar_brand(note, badge, badge_id=""):
|
|||||||
return (
|
return (
|
||||||
' <div class="topline">\n'
|
' <div class="topline">\n'
|
||||||
' <div class="brand-wrap">\n'
|
' <div class="brand-wrap">\n'
|
||||||
' <h1>ani-cli <span class="brand-word">web</span></h1>\n'
|
' <h1>Kaizoku</h1>\n'
|
||||||
f" <p class=\"shell-note\">{note}</p>\n"
|
f" <p class=\"shell-note\">{note}</p>\n"
|
||||||
" </div>\n"
|
" </div>\n"
|
||||||
f" <span class=\"badge\"{badge_attr}>{badge}</span>\n"
|
f" <span class=\"badge\"{badge_attr}>{badge}</span>\n"
|
||||||
|
|||||||
+119
-307
@@ -22,12 +22,13 @@ if str(ROOT) not in sys.path:
|
|||||||
|
|
||||||
import queue_jobs
|
import queue_jobs
|
||||||
import http_handler
|
import http_handler
|
||||||
|
import provider_bridge
|
||||||
import provider_downloader
|
import provider_downloader
|
||||||
|
|
||||||
os.environ.setdefault("ANI_CLI_WEB_DISABLE_WORKER", "1")
|
os.environ.setdefault("KAIZOKU_DISABLE_WORKER", "1")
|
||||||
os.environ["ANI_CLI_WEB_STATE_ROOT"] = TEMP_STATE.name
|
os.environ["KAIZOKU_STATE_ROOT"] = TEMP_STATE.name
|
||||||
|
|
||||||
SPEC = importlib.util.spec_from_file_location("ani_cli_web_app", MODULE_PATH)
|
SPEC = importlib.util.spec_from_file_location("kaizoku_app", MODULE_PATH)
|
||||||
APP = importlib.util.module_from_spec(SPEC)
|
APP = importlib.util.module_from_spec(SPEC)
|
||||||
assert SPEC.loader is not None
|
assert SPEC.loader is not None
|
||||||
SPEC.loader.exec_module(APP)
|
SPEC.loader.exec_module(APP)
|
||||||
@@ -119,7 +120,7 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
handler = DummyHandler("/api/config")
|
handler = DummyHandler("/api/config")
|
||||||
handler.client_address = ("192.168.1.25", 8421)
|
handler.client_address = ("192.168.1.25", 8421)
|
||||||
|
|
||||||
with mock.patch.dict(os.environ, {"ANI_CLI_WEB_ALLOW_REMOTE": "1"}, clear=False):
|
with mock.patch.dict(os.environ, {"KAIZOKU_ALLOW_REMOTE": "1"}, clear=False):
|
||||||
with self.assertRaises(PermissionError) as ctx:
|
with self.assertRaises(PermissionError) as ctx:
|
||||||
APP.Handler.ensure_client_access(handler)
|
APP.Handler.ensure_client_access(handler)
|
||||||
|
|
||||||
@@ -134,9 +135,9 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -148,14 +149,14 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
session_value = http_handler.remote_access_session_value(
|
session_value = http_handler.remote_access_session_value(
|
||||||
{"auth_username": "demo", "auth_password": "secret-pass"}
|
{"auth_username": "demo", "auth_password": "secret-pass"}
|
||||||
)
|
)
|
||||||
handler.headers["Cookie"] = f"ani_cli_web_session={session_value}"
|
handler.headers["Cookie"] = f"kaizoku_session={session_value}"
|
||||||
|
|
||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -169,9 +170,9 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -199,9 +200,9 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -220,9 +221,9 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -231,7 +232,7 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
headers = dict(handler.response_headers)
|
headers = dict(handler.response_headers)
|
||||||
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
|
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
|
||||||
self.assertEqual(headers.get("Location"), "/watchlist")
|
self.assertEqual(headers.get("Location"), "/watchlist")
|
||||||
self.assertIn("ani_cli_web_session=", headers.get("Set-Cookie", ""))
|
self.assertIn("kaizoku_session=", headers.get("Set-Cookie", ""))
|
||||||
|
|
||||||
def test_remote_session_cookie_rejects_missing_origin_on_post(self):
|
def test_remote_session_cookie_rejects_missing_origin_on_post(self):
|
||||||
body = b'{"show_id":"show-1"}'
|
body = b'{"show_id":"show-1"}'
|
||||||
@@ -239,7 +240,7 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
handler.client_address = ("192.168.1.25", 8421)
|
handler.client_address = ("192.168.1.25", 8421)
|
||||||
handler.headers["Host"] = "anime.example:8421"
|
handler.headers["Host"] = "anime.example:8421"
|
||||||
handler.headers["Cookie"] = (
|
handler.headers["Cookie"] = (
|
||||||
"ani_cli_web_session="
|
"kaizoku_session="
|
||||||
+ http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"})
|
+ 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)
|
handler.handler_context = mock.Mock(remove_from_watchlist=mock.Mock(return_value={"ok": True}), http_error=APP.HttpError)
|
||||||
@@ -247,9 +248,9 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -265,7 +266,7 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
handler.headers["Host"] = "anime.example:8421"
|
handler.headers["Host"] = "anime.example:8421"
|
||||||
handler.headers["Origin"] = "https://elsewhere.example"
|
handler.headers["Origin"] = "https://elsewhere.example"
|
||||||
handler.headers["Cookie"] = (
|
handler.headers["Cookie"] = (
|
||||||
"ani_cli_web_session="
|
"kaizoku_session="
|
||||||
+ http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"})
|
+ 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)
|
handler.handler_context = mock.Mock(remove_from_watchlist=mock.Mock(return_value={"ok": True}), http_error=APP.HttpError)
|
||||||
@@ -273,9 +274,9 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -291,7 +292,7 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
handler.headers["Host"] = "anime.example:8421"
|
handler.headers["Host"] = "anime.example:8421"
|
||||||
handler.headers["Origin"] = "https://anime.example:8421"
|
handler.headers["Origin"] = "https://anime.example:8421"
|
||||||
handler.headers["Cookie"] = (
|
handler.headers["Cookie"] = (
|
||||||
"ani_cli_web_session="
|
"kaizoku_session="
|
||||||
+ http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"})
|
+ http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"})
|
||||||
)
|
)
|
||||||
remove_from_watchlist = mock.Mock(return_value={"ok": True})
|
remove_from_watchlist = mock.Mock(return_value={"ok": True})
|
||||||
@@ -300,9 +301,9 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -331,7 +332,7 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
handler.client_address = ("127.0.0.1", 8421)
|
handler.client_address = ("127.0.0.1", 8421)
|
||||||
handler.headers["X-Forwarded-For"] = "203.0.113.10"
|
handler.headers["X-Forwarded-For"] = "203.0.113.10"
|
||||||
|
|
||||||
with mock.patch.dict(os.environ, {"ANI_CLI_WEB_ALLOW_REMOTE": "1"}, clear=False):
|
with mock.patch.dict(os.environ, {"KAIZOKU_ALLOW_REMOTE": "1"}, clear=False):
|
||||||
with self.assertRaises(PermissionError) as ctx:
|
with self.assertRaises(PermissionError) as ctx:
|
||||||
APP.Handler.ensure_client_access(handler)
|
APP.Handler.ensure_client_access(handler)
|
||||||
|
|
||||||
@@ -392,13 +393,13 @@ class DownloadQueueCancelTests(unittest.TestCase):
|
|||||||
queue = object.__new__(APP.DownloadQueue)
|
queue = object.__new__(APP.DownloadQueue)
|
||||||
queue.lock = threading.RLock()
|
queue.lock = threading.RLock()
|
||||||
queue._save_job_locked = lambda _job: None
|
queue._save_job_locked = lambda _job: None
|
||||||
job = {"id": "job-1", "download_backend": "animdl", "log": [], "updated_at": APP.now_iso()}
|
job = {"id": "job-1", "download_backend": "kaizoku", "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:
|
with mock.patch.dict(os.environ, {"KAIZOKU_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")
|
APP.DownloadQueue._append_log(queue, job, "\x1b[32mDownloading episode 1\x1b[0m\n")
|
||||||
|
|
||||||
print_mock.assert_called_once_with(
|
print_mock.assert_called_once_with(
|
||||||
"[download:job-1:animdl] Downloading episode 1",
|
"[download:job-1:kaizoku] Downloading episode 1",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(job["log"], ["Downloading episode 1"])
|
self.assertEqual(job["log"], ["Downloading episode 1"])
|
||||||
@@ -848,66 +849,6 @@ class QueueApiTests(unittest.TestCase):
|
|||||||
self.assertTrue(Path(moved[0]).exists())
|
self.assertTrue(Path(moved[0]).exists())
|
||||||
self.assertTrue(Path(moved[1]).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):
|
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
|
||||||
queue = object.__new__(APP.DownloadQueue)
|
queue = object.__new__(APP.DownloadQueue)
|
||||||
queue.lock = threading.RLock()
|
queue.lock = threading.RLock()
|
||||||
@@ -937,7 +878,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
||||||
staging_dir = queue_jobs.job_staging_dir(job)
|
staging_dir = queue_jobs.job_staging_dir(job)
|
||||||
|
|
||||||
with mock.patch.object(queue_jobs.subprocess, "Popen", side_effect=FileNotFoundError("ani-cli missing")):
|
with mock.patch.object(queue_jobs.subprocess, "Popen", side_effect=FileNotFoundError("kaizoku downloader missing")):
|
||||||
queue._run_job(job)
|
queue._run_job(job)
|
||||||
|
|
||||||
stored = queue._find(job["id"])
|
stored = queue._find(job["id"])
|
||||||
@@ -1090,58 +1031,13 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
self.assertIn("Watchlist sync skipped for detached job.", stored["log"])
|
self.assertIn("Watchlist sync skipped for detached job.", stored["log"])
|
||||||
self.assertNotIn("Watchlist download state synced.", stored["log"])
|
self.assertNotIn("Watchlist download state synced.", stored["log"])
|
||||||
|
|
||||||
def test_failed_ani_cli_retries_with_anipy_when_enabled(self):
|
def test_selected_unavailable_kaizoku_fails_without_start_attempt(self):
|
||||||
queue = APP.DownloadQueue(
|
queue = APP.DownloadQueue(
|
||||||
lambda: {
|
lambda: {
|
||||||
"mode": "sub",
|
"mode": "sub",
|
||||||
"quality": "best",
|
"quality": "best",
|
||||||
"download_dir": "/tmp/example",
|
"download_dir": "/tmp/example",
|
||||||
"anipy_cli_fallback_enabled": True,
|
"download_methods": ["kaizoku"],
|
||||||
},
|
|
||||||
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,
|
start_worker=False,
|
||||||
)
|
)
|
||||||
@@ -1155,24 +1051,23 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
|
|
||||||
stored = queue._find(job["id"])
|
stored = queue._find(job["id"])
|
||||||
self.assertEqual(stored["status"], "failed")
|
self.assertEqual(stored["status"], "failed")
|
||||||
self.assertEqual(stored["download_backend"], "animdl")
|
self.assertEqual(stored["download_backend"], "kaizoku")
|
||||||
self.assertIn("No selected download methods are installed or executable: animdl.", stored["log"])
|
self.assertIn("No selected download methods are installed or executable: kaizoku.", stored["log"])
|
||||||
self.assertFalse(staging_dir.exists())
|
self.assertFalse(staging_dir.exists())
|
||||||
popen.assert_not_called()
|
popen.assert_not_called()
|
||||||
|
|
||||||
def test_selected_download_methods_retry_through_animdl(self):
|
def test_selected_download_methods_runs_kaizoku(self):
|
||||||
queue = APP.DownloadQueue(
|
queue = APP.DownloadQueue(
|
||||||
lambda: {
|
lambda: {
|
||||||
"mode": "sub",
|
"mode": "sub",
|
||||||
"quality": "best",
|
"quality": "best",
|
||||||
"download_dir": "/tmp/example",
|
"download_dir": "/tmp/example",
|
||||||
"download_methods": ["ani-cli", "anipy-cli", "animdl"],
|
"download_methods": ["kaizoku"],
|
||||||
},
|
},
|
||||||
start_worker=False,
|
start_worker=False,
|
||||||
)
|
)
|
||||||
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
||||||
commands = []
|
commands = []
|
||||||
envs = []
|
|
||||||
|
|
||||||
class FakeProc:
|
class FakeProc:
|
||||||
def __init__(self, pid, stdout, exit_code):
|
def __init__(self, pid, stdout, exit_code):
|
||||||
@@ -1183,15 +1078,10 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
def wait(self):
|
def wait(self):
|
||||||
return self._exit_code
|
return self._exit_code
|
||||||
|
|
||||||
processes = [
|
processes = [FakeProc(4321, ["kaizoku attempt\n"], 0)]
|
||||||
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):
|
def fake_popen(command, **kwargs):
|
||||||
commands.append(command)
|
commands.append(command)
|
||||||
envs.append(kwargs.get("env", {}))
|
|
||||||
return processes.pop(0)
|
return processes.pop(0)
|
||||||
|
|
||||||
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object(
|
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object(
|
||||||
@@ -1203,10 +1093,9 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
|
|
||||||
stored = queue._find(job["id"])
|
stored = queue._find(job["id"])
|
||||||
self.assertEqual(stored["status"], "done")
|
self.assertEqual(stored["status"], "done")
|
||||||
self.assertTrue(stored["fallback_used"])
|
self.assertFalse(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([command[0] for command in commands], ["python3"])
|
||||||
self.assertEqual(envs[0]["ANI_CLI_PLAYER"], "download")
|
self.assertIn("Download completed.", stored["log"])
|
||||||
self.assertIn("Fallback download completed with animdl.", stored["log"])
|
|
||||||
|
|
||||||
|
|
||||||
class WatchlistRefreshAllTests(unittest.TestCase):
|
class WatchlistRefreshAllTests(unittest.TestCase):
|
||||||
@@ -2051,7 +1940,7 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertNotIn("-S", APP.app_support.command_for_job(job))
|
self.assertNotIn("-S", APP.app_support.command_for_job(job))
|
||||||
|
|
||||||
def test_command_for_job_includes_result_switch_for_selected_search_result(self):
|
def test_command_for_job_ignores_result_index_for_provider_downloader(self):
|
||||||
command = APP.app_support.command_for_job(
|
command = APP.app_support.command_for_job(
|
||||||
{
|
{
|
||||||
"query": "Queue Show",
|
"query": "Queue Show",
|
||||||
@@ -2059,11 +1948,13 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
"episodes": "1-10",
|
"episodes": "1-10",
|
||||||
"mode": "dub",
|
"mode": "dub",
|
||||||
"result_index": 2,
|
"result_index": 2,
|
||||||
}
|
},
|
||||||
|
download_path="/tmp/kaizoku-staging",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("-S", command)
|
self.assertNotIn("-S", command)
|
||||||
self.assertEqual(command[command.index("-S") + 1], "2")
|
self.assertIn("--provider", command)
|
||||||
|
self.assertEqual(command[command.index("--provider") + 1], "anikoto")
|
||||||
|
|
||||||
def test_command_for_job_ignores_invalid_result_index(self):
|
def test_command_for_job_ignores_invalid_result_index(self):
|
||||||
command = APP.app_support.command_for_job(
|
command = APP.app_support.command_for_job(
|
||||||
@@ -2073,47 +1964,12 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
"episodes": "1-10",
|
"episodes": "1-10",
|
||||||
"mode": "dub",
|
"mode": "dub",
|
||||||
"result_index": "nope",
|
"result_index": "nope",
|
||||||
}
|
},
|
||||||
|
download_path="/tmp/kaizoku-staging",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertNotIn("-S", command)
|
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):
|
def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self):
|
||||||
item = {
|
item = {
|
||||||
"show_id": "show-42",
|
"show_id": "show-42",
|
||||||
@@ -2135,6 +1991,7 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
runtime["download_queue"].add.assert_called_once_with(
|
runtime["download_queue"].add.assert_called_once_with(
|
||||||
{
|
{
|
||||||
"show_id": "show-42",
|
"show_id": "show-42",
|
||||||
|
"provider": "anikoto",
|
||||||
"query": "Queue Show Source",
|
"query": "Queue Show Source",
|
||||||
"title": "Queue Show",
|
"title": "Queue Show",
|
||||||
"anime_name": "Queue Show Library",
|
"anime_name": "Queue Show Library",
|
||||||
@@ -2570,7 +2427,7 @@ class WatchlistBackupTests(unittest.TestCase):
|
|||||||
|
|
||||||
backup = APP.export_watchlist_backup()
|
backup = APP.export_watchlist_backup()
|
||||||
|
|
||||||
self.assertEqual(backup["format"], "ani-cli-web-watchlist-backup")
|
self.assertEqual(backup["format"], "kaizoku-watchlist-backup")
|
||||||
self.assertEqual(backup["format_version"], 1)
|
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]["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"]["rows"][0]["downloaded_dub_episodes_json"], APP.encode_episode_values(["1", "2"]))
|
||||||
@@ -2777,8 +2634,7 @@ class ConfigSnapshotTests(unittest.TestCase):
|
|||||||
self.assertFalse(config["watchlist_auto_refresh_enabled"])
|
self.assertFalse(config["watchlist_auto_refresh_enabled"])
|
||||||
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
|
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
|
||||||
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
|
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
|
||||||
self.assertFalse(config["anipy_cli_fallback_enabled"])
|
self.assertEqual(config["download_methods"], ["kaizoku"])
|
||||||
self.assertEqual(config["download_methods"], ["ani-cli"])
|
|
||||||
self.assertFalse(config["auto_download_enabled"])
|
self.assertFalse(config["auto_download_enabled"])
|
||||||
self.assertEqual(config["auto_download_mode"], "dub")
|
self.assertEqual(config["auto_download_mode"], "dub")
|
||||||
self.assertEqual(config["auto_download_quality"], "best")
|
self.assertEqual(config["auto_download_quality"], "best")
|
||||||
@@ -2791,7 +2647,6 @@ class ConfigSnapshotTests(unittest.TestCase):
|
|||||||
def test_normalize_config_parses_watchlist_schedule_fields(self):
|
def test_normalize_config_parses_watchlist_schedule_fields(self):
|
||||||
config = APP.normalize_config(
|
config = APP.normalize_config(
|
||||||
{
|
{
|
||||||
"anipy_cli_fallback_enabled": "true",
|
|
||||||
"watchlist_auto_refresh_enabled": "true",
|
"watchlist_auto_refresh_enabled": "true",
|
||||||
"watchlist_auto_refresh_minutes": "15",
|
"watchlist_auto_refresh_minutes": "15",
|
||||||
"watchlist_refresh_delay_seconds": "7",
|
"watchlist_refresh_delay_seconds": "7",
|
||||||
@@ -2803,8 +2658,7 @@ class ConfigSnapshotTests(unittest.TestCase):
|
|||||||
"jellyfin_movie_dir": "~/jellyfin/movies",
|
"jellyfin_movie_dir": "~/jellyfin/movies",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.assertTrue(config["anipy_cli_fallback_enabled"])
|
self.assertEqual(config["download_methods"], ["kaizoku"])
|
||||||
self.assertEqual(config["download_methods"], ["ani-cli", "anipy-cli"])
|
|
||||||
self.assertTrue(config["watchlist_auto_refresh_enabled"])
|
self.assertTrue(config["watchlist_auto_refresh_enabled"])
|
||||||
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
|
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
|
||||||
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
|
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
|
||||||
@@ -2818,12 +2672,11 @@ class ConfigSnapshotTests(unittest.TestCase):
|
|||||||
def test_normalize_config_filters_download_methods(self):
|
def test_normalize_config_filters_download_methods(self):
|
||||||
config = APP.normalize_config(
|
config = APP.normalize_config(
|
||||||
{
|
{
|
||||||
"download_methods": ["animdl", "bad-method", "anipy-cli", "animdl"],
|
"download_methods": ["kaizoku", "bad-method", "kaizoku"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(config["download_methods"], ["animdl", "anipy-cli"])
|
self.assertEqual(config["download_methods"], ["kaizoku"])
|
||||||
self.assertTrue(config["anipy_cli_fallback_enabled"])
|
|
||||||
|
|
||||||
def test_normalize_config_filters_discord_webhook_events(self):
|
def test_normalize_config_filters_discord_webhook_events(self):
|
||||||
config = APP.normalize_config(
|
config = APP.normalize_config(
|
||||||
@@ -3000,7 +2853,7 @@ class DiscordWebhookFormattingTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(result["sent"])
|
self.assertTrue(result["sent"])
|
||||||
self.assertEqual(payloads[0]["content"], "ani-cli-web moved a finished library into Jellyfin.")
|
self.assertEqual(payloads[0]["content"], "Kaizoku moved a finished library into Jellyfin.")
|
||||||
self.assertIn("/srv/jellyfin/Jelly Show/Season 01/Jelly Show - S01E01.mp4", payloads[0]["embeds"][1]["description"])
|
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):
|
def test_tick_resets_timer_when_schedule_changes(self):
|
||||||
@@ -3092,6 +2945,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
|
|||||||
download_queue.add.assert_called_once_with(
|
download_queue.add.assert_called_once_with(
|
||||||
{
|
{
|
||||||
"show_id": "show-42",
|
"show_id": "show-42",
|
||||||
|
"provider": "anikoto",
|
||||||
"query": "Queue Show Source",
|
"query": "Queue Show Source",
|
||||||
"title": "Queue Show",
|
"title": "Queue Show",
|
||||||
"anime_name": "Queue Show- Alt-Name",
|
"anime_name": "Queue Show- Alt-Name",
|
||||||
@@ -3147,6 +3001,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
|
|||||||
download_queue.add.assert_called_once_with(
|
download_queue.add.assert_called_once_with(
|
||||||
{
|
{
|
||||||
"show_id": "show-99",
|
"show_id": "show-99",
|
||||||
|
"provider": "anikoto",
|
||||||
"query": "Backlog Show Search",
|
"query": "Backlog Show Search",
|
||||||
"title": "Backlog Show",
|
"title": "Backlog Show",
|
||||||
"anime_name": "Backlog Show",
|
"anime_name": "Backlog Show",
|
||||||
@@ -3202,6 +3057,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
|
|||||||
download_queue.add.assert_called_once_with(
|
download_queue.add.assert_called_once_with(
|
||||||
{
|
{
|
||||||
"show_id": "show-first-refresh",
|
"show_id": "show-first-refresh",
|
||||||
|
"provider": "anikoto",
|
||||||
"query": "First Refresh Source",
|
"query": "First Refresh Source",
|
||||||
"title": "First Refresh Show",
|
"title": "First Refresh Show",
|
||||||
"anime_name": "First Refresh Show",
|
"anime_name": "First Refresh Show",
|
||||||
@@ -3256,6 +3112,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
|
|||||||
download_queue.add.assert_called_once_with(
|
download_queue.add.assert_called_once_with(
|
||||||
{
|
{
|
||||||
"show_id": "show-77",
|
"show_id": "show-77",
|
||||||
|
"provider": "anikoto",
|
||||||
"query": "Split Season Search",
|
"query": "Split Season Search",
|
||||||
"title": "Split Season Show",
|
"title": "Split Season Show",
|
||||||
"anime_name": "Split Season Show",
|
"anime_name": "Split Season Show",
|
||||||
@@ -3394,7 +3251,7 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
handler = DummyHandler("/api/watchlist/refresh-status")
|
handler = DummyHandler("/api/watchlist/refresh-status")
|
||||||
APP.reset_runtime(wait=True)
|
APP.reset_runtime(wait=True)
|
||||||
try:
|
try:
|
||||||
with mock.patch.dict(os.environ, {"ANI_CLI_WEB_DISABLE_WORKER": "1"}, clear=False):
|
with mock.patch.dict(os.environ, {"KAIZOKU_DISABLE_WORKER": "1"}, clear=False):
|
||||||
APP.Handler.do_GET(handler)
|
APP.Handler.do_GET(handler)
|
||||||
self.assertFalse(APP.WATCHLIST.worker_enabled)
|
self.assertFalse(APP.WATCHLIST.worker_enabled)
|
||||||
self.assertIsNone(APP.WATCHLIST.refresh_worker)
|
self.assertIsNone(APP.WATCHLIST.refresh_worker)
|
||||||
@@ -3407,12 +3264,11 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
APP.initialize_runtime(start_workers=False)
|
APP.initialize_runtime(start_workers=False)
|
||||||
|
|
||||||
def test_config_post_accepts_watchlist_schedule_fields(self):
|
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"}'
|
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","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))
|
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
||||||
APP.Handler.do_POST(handler)
|
APP.Handler.do_POST(handler)
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||||
self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"])
|
self.assertEqual(handler.json_payload["download_methods"], ["kaizoku"])
|
||||||
self.assertEqual(handler.json_payload["download_methods"], ["ani-cli", "anipy-cli"])
|
|
||||||
self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"])
|
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_auto_refresh_minutes"], 25)
|
||||||
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9)
|
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9)
|
||||||
@@ -3424,73 +3280,42 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
self.assertEqual(handler.json_payload["jellyfin_movie_dir"], "/tmp/jellyfin-movies")
|
self.assertEqual(handler.json_payload["jellyfin_movie_dir"], "/tmp/jellyfin-movies")
|
||||||
|
|
||||||
def test_config_post_accepts_download_methods(self):
|
def test_config_post_accepts_download_methods(self):
|
||||||
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","download_methods":["ani-cli","animdl"]}'
|
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","download_methods":["kaizoku"]}'
|
||||||
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
||||||
APP.Handler.do_POST(handler)
|
APP.Handler.do_POST(handler)
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||||
self.assertEqual(handler.json_payload["download_methods"], ["ani-cli", "animdl"])
|
self.assertEqual(handler.json_payload["download_methods"], ["kaizoku"])
|
||||||
self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"])
|
|
||||||
|
|
||||||
def test_version_route_includes_cli_versions(self):
|
def test_version_route_reports_kaizoku_runtime(self):
|
||||||
handler = DummyHandler("/api/version")
|
handler = DummyHandler("/api/version")
|
||||||
APP.Handler.do_GET(handler)
|
APP.Handler.do_GET(handler)
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||||
self.assertEqual(handler.json_payload["name"], APP.APP_NAME)
|
self.assertEqual(handler.json_payload["name"], APP.APP_NAME)
|
||||||
self.assertEqual(handler.json_payload["version"], APP.VERSION)
|
self.assertEqual(handler.json_payload["version"], APP.VERSION)
|
||||||
self.assertIn("ani_cli_version", handler.json_payload)
|
self.assertEqual(handler.json_payload["providers"], list(provider_bridge.PROVIDERS))
|
||||||
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):
|
def test_dependencies_route_reports_provider_runtime_status(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")
|
handler = DummyHandler("/api/dependencies")
|
||||||
APP.Handler.do_GET(handler)
|
APP.Handler.do_GET(handler)
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||||
self.assertIn("curl-impersonate", handler.json_payload)
|
self.assertIn("node", handler.json_payload)
|
||||||
self.assertIn("anipy-cli", handler.json_payload)
|
self.assertIn("python3", handler.json_payload)
|
||||||
self.assertIn("animdl", handler.json_payload)
|
self.assertIn("ffmpeg", handler.json_payload)
|
||||||
|
self.assertIn("provider-bridge", handler.json_payload)
|
||||||
|
|
||||||
def test_dependency_status_checks_docker_fallback_tool_paths(self):
|
def test_dependency_status_checks_provider_runtime(self):
|
||||||
with mock.patch.object(http_handler, "cli_executable_exists", return_value=True), mock.patch.object(
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
http_handler, "cli_executable_exists_any", return_value=True
|
bridge_path = Path(temp_dir) / "bridge.js"
|
||||||
) as exists_any:
|
bridge_path.write_text("", encoding="utf-8")
|
||||||
status = http_handler.dependency_status()
|
with mock.patch.object(http_handler.shutil, "which", return_value="/usr/bin/tool"), mock.patch.object(
|
||||||
|
http_handler.provider_bridge, "BRIDGE", bridge_path
|
||||||
|
):
|
||||||
|
status = http_handler.dependency_status()
|
||||||
|
|
||||||
self.assertTrue(status["animdl"])
|
self.assertTrue(status["node"])
|
||||||
exists_any.assert_any_call(
|
self.assertTrue(status["python3"])
|
||||||
"curl_firefox135",
|
self.assertTrue(status["ffmpeg"])
|
||||||
"curl_chrome136",
|
self.assertTrue(status["provider-bridge"])
|
||||||
"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):
|
def test_clear_failed_route_removes_failed_jobs(self):
|
||||||
with APP.DOWNLOAD_QUEUE._connect() as conn:
|
with APP.DOWNLOAD_QUEUE._connect() as conn:
|
||||||
@@ -3554,9 +3379,9 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -3577,9 +3402,9 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -3597,7 +3422,7 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_REMOTE_PATH_ROOTS": extra_root,
|
"KAIZOKU_REMOTE_PATH_ROOTS": extra_root,
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -3617,10 +3442,10 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
"ANI_CLI_WEB_REMOTE_PATH_ROOTS": extra_root,
|
"KAIZOKU_REMOTE_PATH_ROOTS": extra_root,
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -3685,9 +3510,9 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -3713,10 +3538,10 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"ANI_CLI_WEB_ALLOW_REMOTE": "1",
|
"KAIZOKU_ALLOW_REMOTE": "1",
|
||||||
"ANI_CLI_WEB_AUTH_USERNAME": "demo",
|
"KAIZOKU_AUTH_USERNAME": "demo",
|
||||||
"ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass",
|
"KAIZOKU_AUTH_PASSWORD": "secret-pass",
|
||||||
"ANI_CLI_WEB_REMOTE_PATH_ROOTS": allowed_root,
|
"KAIZOKU_REMOTE_PATH_ROOTS": allowed_root,
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
@@ -4005,7 +3830,7 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_watchlist_export_route_returns_json_attachment(self):
|
def test_watchlist_export_route_returns_json_attachment(self):
|
||||||
handler = DummyHandler("/api/config/watchlist/export")
|
handler = DummyHandler("/api/config/watchlist/export")
|
||||||
payload = {"format": "ani-cli-web-watchlist-backup", "tables": {"watchlist": {"rows": []}}}
|
payload = {"format": "kaizoku-watchlist-backup", "tables": {"watchlist": {"rows": []}}}
|
||||||
handler.handler_context = mock.Mock(
|
handler.handler_context = mock.Mock(
|
||||||
ensure_runtime=mock.Mock(),
|
ensure_runtime=mock.Mock(),
|
||||||
runtime_state=mock.Mock(return_value={}),
|
runtime_state=mock.Mock(return_value={}),
|
||||||
@@ -4021,7 +3846,7 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
self.assertEqual(json.loads(handler.wfile.getvalue().decode("utf-8")), payload)
|
self.assertEqual(json.loads(handler.wfile.getvalue().decode("utf-8")), payload)
|
||||||
|
|
||||||
def test_watchlist_import_route_returns_import_summary(self):
|
def test_watchlist_import_route_returns_import_summary(self):
|
||||||
body = b'{"format":"ani-cli-web-watchlist-backup","format_version":1,"tables":{"watchlist":{"rows":[]}}}'
|
body = b'{"format":"kaizoku-watchlist-backup","format_version":1,"tables":{"watchlist":{"rows":[]}}}'
|
||||||
handler = DummyHandler("/api/config/watchlist/import", body=body, content_length=len(body))
|
handler = DummyHandler("/api/config/watchlist/import", body=body, content_length=len(body))
|
||||||
payload = {"message": "Imported 0 watchlist entries from backup.", "imported": 0, "removals": 0}
|
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))
|
handler.handler_context = mock.Mock(import_watchlist_backup=mock.Mock(return_value=payload))
|
||||||
@@ -4108,13 +3933,8 @@ class TemplateHelperTests(unittest.TestCase):
|
|||||||
self.assertLess(APP.CONFIG_HTML.index('<section class="deps" id="deps"></section>'), APP.CONFIG_HTML.index('<div class="settings-grid">'))
|
self.assertLess(APP.CONFIG_HTML.index('<section class="deps" id="deps"></section>'), APP.CONFIG_HTML.index('<div class="settings-grid">'))
|
||||||
self.assertIn("@media (max-width: 1080px)", APP.CONFIG_HTML)
|
self.assertIn("@media (max-width: 1080px)", APP.CONFIG_HTML)
|
||||||
self.assertIn("justify-items: stretch;", APP.CONFIG_HTML)
|
self.assertIn("justify-items: stretch;", APP.CONFIG_HTML)
|
||||||
self.assertIn('id="aniCliVersionLine"', APP.CONFIG_HTML)
|
self.assertIn('id="versionLine"', APP.CONFIG_HTML)
|
||||||
self.assertIn('id="anipyCliVersionLine"', APP.CONFIG_HTML)
|
self.assertIn('id="providerLine"', 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="openChangelogBtn"', APP.CONFIG_HTML)
|
||||||
self.assertIn('id="changelogOverlay"', APP.CONFIG_HTML)
|
self.assertIn('id="changelogOverlay"', APP.CONFIG_HTML)
|
||||||
self.assertIn('const data = await api("/api/changelog");', APP.CONFIG_HTML)
|
self.assertIn('const data = await api("/api/changelog");', APP.CONFIG_HTML)
|
||||||
@@ -4158,24 +3978,16 @@ class TemplateHelperTests(unittest.TestCase):
|
|||||||
def test_search_page_renders_poster_results_under_selection_panel(self):
|
def test_search_page_renders_poster_results_under_selection_panel(self):
|
||||||
self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML)
|
self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML)
|
||||||
self.assertIn("Select results", APP.INDEX_HTML)
|
self.assertIn("Select results", APP.INDEX_HTML)
|
||||||
self.assertIn("resultThumbnailUrl(title)", APP.INDEX_HTML)
|
self.assertIn("resultThumbnailUrl(item)", APP.INDEX_HTML)
|
||||||
self.assertIn('/api/search/thumb?title=', APP.INDEX_HTML)
|
self.assertIn('/api/search/thumb?title=', APP.INDEX_HTML)
|
||||||
self.assertIn("result-subtitle", APP.INDEX_HTML)
|
self.assertIn("result-subtitle", APP.INDEX_HTML)
|
||||||
self.assertIn("Also known as:", APP.INDEX_HTML)
|
self.assertIn("Also known as:", APP.INDEX_HTML)
|
||||||
|
|
||||||
def test_search_anime_includes_alternative_titles(self):
|
def test_search_anime_includes_alternative_titles(self):
|
||||||
payload = {
|
payload = {"results": [{"id": "show-1", "title": "Example Show", "provider": "anikoto"}]}
|
||||||
"shows": {
|
with mock.patch.object(provider_bridge, "search", return_value=payload), mock.patch.object(
|
||||||
"edges": [
|
provider_bridge, "episodes", return_value=[{"number": "1", "langs": ["sub"]}],
|
||||||
{
|
), mock.patch.object(
|
||||||
"_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",
|
APP, "extract_anidb_alternative_titles_for_title",
|
||||||
return_value=[{"label": "English", "value": "Example Show English"}],
|
return_value=[{"label": "English", "value": "Example Show English"}],
|
||||||
):
|
):
|
||||||
|
|||||||
+1
-1
@@ -701,7 +701,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<aside>
|
<aside>
|
||||||
""" + render_sidebar_brand(
|
""" + render_sidebar_brand(
|
||||||
"Tsuki-inspired watch tracking with softer cards and poster-first scanning.",
|
"Watch tracking, refreshes, and automatic downloads.",
|
||||||
"Watchlist",
|
"Watchlist",
|
||||||
) + render_page_links("watchlist") + r"""
|
) + render_page_links("watchlist") + r"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user