Replace legacy branding with Kaizoku

This commit is contained in:
Codex
2026-08-09 13:42:51 +02:00
parent 95c9eca99f
commit fa7a922d13
15 changed files with 223 additions and 459 deletions
-1
View File
@@ -1,5 +1,4 @@
.git .git
.ani-cli-web
.kaizoku .kaizoku
downloads downloads
node_modules node_modules
-1
View File
@@ -1,4 +1,3 @@
.ani-cli-web/
.kaizoku/ .kaizoku/
downloads/ downloads/
node_modules/ node_modules/
+31 -25
View File
@@ -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
View File
@@ -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=
+9 -10
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.51.9 0.52.0
+12 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"""