Compare commits

..
21 Commits
Author SHA1 Message Date
Codex edb565d855 AGENTS.md updated 2026-08-01 17:40:42 +02:00
Codex 32fc0f2829 Pass selected ani-cli result to queue jobs 2026-08-01 17:39:36 +02:00
Codex 141dcd9c70 Add curl-impersonate for ani-cli v5 2026-08-01 17:21:16 +02:00
Codex 8c62060d4a Fix ani-cli version probe player mode 2026-07-26 14:29:46 +02:00
Codex 629cf8dcb6 Force ani-cli headless download mode 2026-07-21 12:00:33 +02:00
Codex ce8ca1d4fb Ignore local backups and note data preservation 2026-07-21 11:41:49 +02:00
Codex 6ecebcd389 Add watchlist backup import export 2026-07-21 10:03:45 +02:00
Codex 0e40c9bda9 Add watchlist filesystem reconciliation 2026-07-21 09:41:24 +02:00
Dymas ef2df14aab Mirror download job logs to stdout 2026-07-19 15:27:41 +02:00
Dymas 3f4baf21e8 Fix Docker animdl packaging 2026-07-19 15:10:41 +02:00
Dymas 363de302a8 Create system animdl wrapper in Docker image 2026-07-19 14:47:17 +02:00
Dymas 348d97488a Fix fallback dependency detection 2026-07-19 14:40:03 +02:00
Dymas 02226ad5c9 Fix animdl discovery in Docker runtime 2026-07-19 14:31:16 +02:00
Dymas dbc1dc8e3a Install animdl systemwide in Docker image 2026-07-19 14:25:04 +02:00
Dymas 6b3745c7e9 Add selectable animdl download fallback 2026-07-19 14:17:13 +02:00
Dymas 6bec0e068a Add botan to Docker image 2026-07-17 22:25:56 +02:00
Dymas 604265c44f Show Jellyfin handoff jobs in queue 2026-07-17 12:30:23 +02:00
Dymas ba408877b5 Add website favicon support 2026-07-09 18:15:52 +02:00
Dymas 34c77ed9bb Reuse failed queue jobs for duplicate retries 2026-07-09 17:38:24 +02:00
Dymas 292bbedd6b Add clear failed queue action
Add a Queue page bulk action and API route to remove all failed jobs, cover it with regression tests, and update the version, changelog, and README for the new queue workflow.
2026-07-09 17:20:55 +02:00
Dymas 0257924785 Merge branch 'anipy_backup' 2026-07-09 17:17:45 +02:00
18 changed files with 1705 additions and 120 deletions
+1
View File
@@ -10,3 +10,4 @@ __pycache__
venv venv
env env
*.log *.log
_backup_/
+1
View File
@@ -9,3 +9,4 @@ __pycache__/
venv/ venv/
env/ env/
*.log *.log
_backup_/
+2 -1
View File
@@ -3,4 +3,5 @@
- when needed you may install any tools using pip if the project reqires it - when needed you may install any tools using pip if the project reqires it
- you can freely acces any LAN URL and github.com URL. - you can freely acces any LAN URL and github.com URL.
- always update README.md to reflect current project. - always update README.md to reflect current project.
- always commit changes with commentary - always commit changes with commentary and push
- always make sure that update wont wipe ani data in a previosly running instance like configuration or watchlist content
+80
View File
@@ -1,5 +1,85 @@
# Changelog # Changelog
## 0.50.1 - 2026-08-01
- Passed the selected Search result number through queued `ani-cli` jobs as `-S`, so downloads avoid the interactive series picker when multiple similar titles are returned.
- Preserved the selected result index in queued jobs for more reliable watchlist identity sync after ambiguous downloads.
## 0.50.0 - 2026-08-01
- Added `curl-impersonate` to Docker images using the maintained `lexiforest/curl-impersonate` image so `ani-cli` v5 can use browser-like curl wrappers before falling back to plain `curl`.
- Exposed `curl-impersonate` availability in the Config page dependency status.
- Updated Docker/runtime documentation for the new `ani-cli` v5 Cloudflare workaround.
## 0.49.9 - 2026-07-26
- Changed the Config page `ani-cli` version probe to run with `ANI_CLI_PLAYER=download`, avoiding false missing-player failures on headless installs without `mpv` or `vlc`.
- Added regression coverage for the player-safe `ani-cli --version` environment.
## 0.49.8 - 2026-07-21
- Forced queued `ani-cli` attempts into explicit download-player mode with `ANI_CLI_PLAYER=download`, preventing headless Docker jobs from failing the upstream player check when `mpv` or `vlc` are not installed.
- Expanded fallback regression coverage for the `ani-cli` download-mode environment.
## 0.49.7 - 2026-07-21
- Ignored the local `_backup_/` folder in git and Docker build contexts so temporary backups do not get committed or copied into images.
- Added an agent note to preserve existing ani-cli-web configuration and watchlist data during future updates.
## 0.49.6 - 2026-07-21
- Added Config page watchlist export and import actions for full watchlist database backups.
- Backup files include raw `watchlist` rows plus `watchlist_removals`, preserving tracked shows, downloaded state, metadata, auto-download settings, thumbnails, and removed-show history.
- Expanded regression coverage for backup export/import behavior and the new HTTP routes.
## 0.49.5 - 2026-07-21
- Added a manual Config page action that reconciles watchlist downloaded episode flags against the current filesystem library.
- The new sync removes downloaded flags for missing files, adds flags for episodes already present on disk for the watchlist's active download mode, and updates movie entries when their library folder exists again.
- Expanded regression coverage for the new filesystem reconciliation flow and Config route wiring.
## 0.49.4 - 2026-07-19
- Added optional download job stdout mirroring with `ANI_CLI_WEB_JOB_STDOUT`, enabled by default in Docker and Docker Compose so container log collectors can capture downloader output.
## 0.49.3 - 2026-07-19
- Changed Docker builds to copy the current local `ani-cli-web` checkout into the image instead of cloning the configured remote branch, so rebuilt images include local Docker/runtime fixes immediately.
- Installed Docker `anipy-cli` and `animdl` in separate virtualenvs, with `animdl` pinned under Python 3.11 so its older native dependency pins resolve and remain executable.
- Removed the unused `ANI_CLI_WEB` compose build argument and updated Docker documentation to match the local-checkout build flow.
## 0.49.2 - 2026-07-19
- Fixed the Config page dependency check so Docker-installed fallback tools are detected from `/usr/local/bin` and `/usr/bin`, matching the runtime command lookup.
- Set the Docker image `PATH` explicitly to include system binary directories before the app starts.
## 0.49.1 - 2026-07-19
- Fixed Docker fallback tool discovery by exposing `anipy-cli` and `animdl` from fixed system paths and checking common system install locations at runtime.
- Changed queue jobs to fail immediately with a clear message when every selected download method is unavailable, instead of trying to launch an unavailable command.
## 0.49.0 - 2026-07-19
- Added `animdl` as a third queue download backend and Docker-installed fallback tool.
- Replaced the single `anipy-cli` fallback toggle with selectable download methods for `ani-cli`, `anipy-cli`, and `animdl`; queue jobs try the checked methods in that order.
- Exposed `animdl` availability and version in the Config page runtime checks.
## 0.48.3 - 2026-07-17
- Added Jellyfin handoff jobs to the Queue page so manual copy jobs appear alongside downloads with live progress, moved-file counts, and recent handoff activity.
## 0.48.2 - 2026-07-09
- Added support for a checked-in `favicon.png`, serving it from the app and wiring it into the website pages so browsers show the custom favicon.
## 0.48.1 - 2026-07-09
- Changed queue inserts so adding a download that exactly matches an existing failed job now reuses that failed entry and resets it to pending instead of creating another duplicate failed row.
## 0.48.0 - 2026-07-09
- Added a `Clear failed` bulk action to the Queue page so failed jobs can be removed without touching pending, running, finished, or canceled downloads.
## 0.47.1 - 2026-07-09 ## 0.47.1 - 2026-07-09
- Fixed `anipy-cli` fallback renaming for queued TV downloads so retried jobs now keep the requested episode numbers from the queue, avoiding cases like a retried `S02E11` being renamed as `S02E01`. - Fixed `anipy-cli` fallback renaming for queued TV downloads so retried jobs now keep the requested episode numbers from the queue, avoiding cases like a retried `S02E11` being renamed as `S02E01`.
+38 -18
View File
@@ -1,20 +1,22 @@
FROM python:3.12-slim FROM lexiforest/curl-impersonate:latest AS curl-impersonate
FROM python:3.12-slim-bookworm
ARG ANI_CLI=https://github.com/pystardust/ani-cli.git ARG ANI_CLI=https://github.com/pystardust/ani-cli.git
ARG ANI_CLI_BRANCH=master ARG ANI_CLI_BRANCH=master
ARG ANI_CLI_WEB=https://gitea.coreplay.eu/Dymas/ani-cli-web.git
ARG ANI_CLI_WEB_BRANCH=main
ARG YT_DLP_RELEASE_URL=https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp ARG YT_DLP_RELEASE_URL=https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
ANI_CLI_REPO=${ANI_CLI} \ ANI_CLI_REPO=${ANI_CLI} \
ANI_CLI_BRANCH=${ANI_CLI_BRANCH} \ ANI_CLI_BRANCH=${ANI_CLI_BRANCH} \
ANI_CLI_WEB_REPO=${ANI_CLI_WEB} \ PATH=/usr/local/bin:/usr/bin:/bin \
ANI_CLI_WEB_BRANCH=${ANI_CLI_WEB_BRANCH} \
ANI_CLI_BIN=ani-cli \ ANI_CLI_BIN=ani-cli \
ANIPY_CLI_BIN=/usr/local/bin/anipy-cli \
ANIMDL_BIN=/usr/local/bin/animdl \
ANI_CLI_DOWNLOAD_DIR=/downloads \ ANI_CLI_DOWNLOAD_DIR=/downloads \
ANI_CLI_WEB_HOST=0.0.0.0 \ ANI_CLI_WEB_HOST=0.0.0.0 \
ANI_CLI_WEB_PORT=8421 \ ANI_CLI_WEB_PORT=8421 \
ANI_CLI_WEB_JOB_STDOUT=true \
UPDATE_ON_START=false \ UPDATE_ON_START=false \
USER_UID= \ USER_UID= \
USER_GID= USER_GID=
@@ -22,19 +24,32 @@ ENV DEBIAN_FRONTEND=noninteractive \
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
aria2 \ aria2 \
bash \
botan \
ca-certificates \ ca-certificates \
curl \ curl \
ffmpeg \ ffmpeg \
fzf \ fzf \
build-essential \
git \ git \
grep \ grep \
libxml2-dev \
libxslt1-dev \
libstdc++6 \
openssl \ openssl \
patch \ patch \
python3.11 \
python3.11-venv \
sed \ sed \
sudo \ sudo \
util-linux \ util-linux \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=curl-impersonate /usr/local /usr/local
RUN curl_firefox135 --version \
&& curl_chrome136 --version
RUN git clone --depth 1 --branch "${ANI_CLI_BRANCH}" "${ANI_CLI}" /tmp/ani-cli-src \ RUN git clone --depth 1 --branch "${ANI_CLI_BRANCH}" "${ANI_CLI}" /tmp/ani-cli-src \
&& install -m 0755 /tmp/ani-cli-src/ani-cli /usr/local/bin/ani-cli \ && install -m 0755 /tmp/ani-cli-src/ani-cli /usr/local/bin/ani-cli \
&& ani_cli_raw_repo="$(printf '%s' "${ANI_CLI}" | sed -E 's#^https://github.com/##; s#\\.git$##')" \ && ani_cli_raw_repo="$(printf '%s' "${ANI_CLI}" | sed -E 's#^https://github.com/##; s#\\.git$##')" \
@@ -48,20 +63,25 @@ RUN curl -fL "${YT_DLP_RELEASE_URL}" -o /usr/local/bin/yt-dlp \
&& chmod 0755 /usr/local/bin/yt-dlp \ && chmod 0755 /usr/local/bin/yt-dlp \
&& yt-dlp --version && yt-dlp --version
RUN python3 -m pip install --no-cache-dir anipy-cli \ RUN python3 -m venv /opt/anipy-cli \
&& anipy-cli --version && /opt/anipy-cli/bin/pip install --no-cache-dir --upgrade pip \
&& /opt/anipy-cli/bin/pip install --no-cache-dir anipy-cli \
&& python3.11 -m venv /opt/animdl \
&& /opt/animdl/bin/pip install --no-cache-dir --upgrade pip \
&& /opt/animdl/bin/pip install --no-cache-dir animdl==1.7.27 \
&& test -x /opt/anipy-cli/bin/anipy-cli \
&& test -x /opt/animdl/bin/animdl \
&& printf '%s\n' '#!/bin/sh' 'exec /opt/anipy-cli/bin/anipy-cli "$@"' > /usr/local/bin/anipy-cli \
&& printf '%s\n' '#!/bin/sh' 'exec /opt/animdl/bin/animdl "$@"' > /usr/local/bin/animdl \
&& chmod 0755 /usr/local/bin/anipy-cli /usr/local/bin/animdl \
&& ln -sf /usr/local/bin/anipy-cli /usr/bin/anipy-cli \
&& ln -sf /usr/local/bin/animdl /usr/bin/animdl \
&& /usr/local/bin/anipy-cli --version \
&& /usr/local/bin/animdl --version
RUN git clone --depth 1 --branch "${ANI_CLI_WEB_BRANCH}" "${ANI_CLI_WEB}" /app \ RUN mkdir -p /app
&& rm -rf /app/.git \
&& rm -f \ COPY app.py app_support.py config_page.py http_handler.py queue_jobs.py queue_page.py search_page.py template_helpers.py title_matching.py watchlist_identity.py watchlist_page.py web_templates.py VERSION CHANGELOG.md favicon.png /app/
/app/.dockerignore \
/app/.gitignore \
/app/Dockerfile \
/app/README.md \
/app/ani-cli-web \
/app/docker-compose.yaml \
/app/docker-entrypoint.sh \
/app/test_app.py
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+36 -22
View File
@@ -2,34 +2,35 @@
Local web UI for a system-wide `ani-cli` install. Local web UI for a system-wide `ani-cli` install.
Current version: `0.47.1` Current version: `0.50.1`
## What it does ## What it does
- Search anime in `sub` or `dub`, with poster-style results shown below the selection panel and the first English alternate title when available - Search anime in `sub` or `dub`, with poster-style results shown below the selection panel and the first English alternate title when available
- Queue downloads with folder, quality, media type, season, and episode controls - Queue downloads with folder, quality, media type, season, episode controls, and the selected upstream search result when a Search card is used
- Optionally retry failed `ani-cli` downloads with `anipy-cli` - Choose which download backends queue jobs may use: `ani-cli`, `anipy-cli`, and `animdl`
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories - Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
- Expose a small JSON summary endpoint for Homepage dashboard widgets - Expose a small JSON summary endpoint for Homepage dashboard widgets
- Refresh watchlist episode counts manually or on a schedule - Refresh watchlist episode counts manually or on a schedule
- Auto-download missing episodes for `Watching` entries after later refreshes, with a per-show `Source name` override for `ani-cli` search - Auto-download missing episodes for `Watching` entries after later refreshes, with a per-show `Source name` override for `ani-cli` search
- Move fully completed libraries into Jellyfin TV or movie folders with a tracked background handoff job - Move fully completed libraries into Jellyfin TV or movie folders with tracked background handoff jobs that also appear in the Queue page
- Send optional Discord webhook notifications - Send optional Discord webhook notifications
- Serve a checked-in `favicon.png` as the site favicon
## Pages ## Pages
- `/`: Search and queue downloads, with poster-card search results below the selection box and alternate English titles under the main name when available - `/`: Search and queue downloads, with poster-card search results below the selection box and alternate English titles under the main name when available
- `/queue`: Monitor jobs, inspect logs, retry failed jobs, remove finished jobs - `/queue`: Monitor downloads and Jellyfin handoff jobs, inspect logs, retry failed downloads, avoid duplicate re-queues for the same failed download, clear failed jobs, and remove finished jobs
- `/watchlist`: Track shows, refresh them, download all available episodes, and manage auto-download settings such as `Source name`, library `Name`, season, and episode offset - `/watchlist`: Track shows, refresh them, download all available episodes, and manage auto-download settings such as `Source name`, library `Name`, season, and episode offset
- `/config`: Save defaults, toggle `anipy-cli` fallback retries, schedule refreshes, configure Jellyfin and Discord, monitor Jellyfin handoff progress, and view runtime info and the changelog - `/config`: Save defaults, choose queue download methods and fallback retries, schedule refreshes, configure Jellyfin and Discord, export/import watchlist backups, manually reconcile watchlist download flags against the filesystem, monitor Jellyfin handoff progress, and view runtime info and the changelog
## Quick start ## Quick start
Requirements: Requirements:
- `ani-cli` installed and available in `PATH` - `ani-cli` installed and available in `PATH`
- Optional: `anipy-cli` installed and available in `PATH` if you want automatic fallback retries outside Docker - Optional: `anipy-cli` and/or `animdl` installed and available in `PATH` if you want automatic fallback retries outside Docker
- Common runtime tools used by `ani-cli`, especially `curl`, `ffmpeg`, `fzf`, `grep`, `openssl`, `sed`, `aria2c`, and `yt-dlp` - Common runtime tools used by `ani-cli`, especially `curl` or `curl-impersonate`, `ffmpeg`, `fzf`, `grep`, `openssl`, `sed`, `aria2c`, `botan`, and `yt-dlp`
Run locally: Run locally:
@@ -48,8 +49,10 @@ Useful flags and env:
- `./ani-cli-web --debug` - `./ani-cli-web --debug`
- `ANI_CLI_WEB_HOST=0.0.0.0` - `ANI_CLI_WEB_HOST=0.0.0.0`
- `ANI_CLI_WEB_PORT=8421` - `ANI_CLI_WEB_PORT=8421`
- `ANI_CLI_WEB_JOB_STDOUT=true`
- `ANI_CLI_BIN=/path/to/ani-cli` - `ANI_CLI_BIN=/path/to/ani-cli`
- `ANIPY_CLI_BIN=/path/to/anipy-cli` - `ANIPY_CLI_BIN=/path/to/anipy-cli`
- `ANIMDL_BIN=/path/to/animdl`
## Docker ## Docker
@@ -59,13 +62,7 @@ Build:
docker build -t ani-cli-web . docker build -t ani-cli-web .
``` ```
Build a specific `ani-cli-web` branch: Docker builds the app from the current local checkout.
```sh
docker build \
--build-arg ANI_CLI_WEB_BRANCH=anipy_backup \
-t ani-cli-web .
```
Run: Run:
@@ -92,8 +89,10 @@ Docker notes:
- App state is stored under `/app/.ani-cli-web` - App state is stored under `/app/.ani-cli-web`
- `USER_UID` and `USER_GID` help keep mounted files owned by your host user - `USER_UID` and `USER_GID` help keep mounted files owned by your host user
- `UPDATE_ON_START=true` runs `ani-cli --update` before startup - `UPDATE_ON_START=true` runs `ani-cli --update` before startup
- `ANI_CLI_WEB_BRANCH` lets the image clone a specific `ani-cli-web` branch at build time - `ANI_CLI_WEB_JOB_STDOUT=true` mirrors download job output to container stdout for log collectors; set it to `false` to keep job output only in the Queue page
- `anipy-cli` is installed systemwide inside the container, so fallback retries are available without extra container setup - `curl-impersonate` is installed inside the container and exposes the browser wrapper commands used by `ani-cli` v5, such as `curl_firefox135` and `curl_chrome136`, so provider requests can avoid Cloudflare blocks that affect plain `curl`
- `botan` is installed inside the container for providers or scripts that need Botan cryptography tooling
- `anipy-cli` and `animdl` are installed systemwide inside the container at `/usr/local/bin`, linked from `/usr/bin`, and included in the runtime `PATH`; `animdl` is isolated in a Python 3.11 virtualenv so its pinned dependencies stay executable
## Key behavior ## Key behavior
@@ -105,6 +104,7 @@ Docker notes:
- Successful downloads sync back into the watchlist - Successful downloads sync back into the watchlist
- TV entries move to `Finished` when the selected downloaded mode reaches the expected episode count - TV entries move to `Finished` when the selected downloaded mode reaches the expected episode count
- Movie entries can be treated as complete after download and routed to movie storage - Movie entries can be treated as complete after download and routed to movie storage
- Watchlist backups exported from Config include the full watchlist database rows and removed-show history for restore/import
### Auto-download ### Auto-download
@@ -115,9 +115,19 @@ Docker notes:
### Download fallback ### Download fallback
- Optional and configured from `/config` - Optional and configured from `/config`
- When enabled, a failed `ani-cli` queue job retries once with `anipy-cli` - Queue jobs can use any checked combination of `ani-cli`, `anipy-cli`, and `animdl`
- Fallback retries keep the queued episode numbers when the downloaded `anipy-cli` filenames are renumbered from `1` - Checked methods are tried in this order: `ani-cli`, `anipy-cli`, then `animdl`
- Docker images install `anipy-cli` automatically; non-Docker installs should provide it in `PATH` themselves - Selecting one method disables fallback retry; selecting multiple methods makes later methods automatic fallbacks
- Queue `ani-cli` downloads explicitly run with `ANI_CLI_PLAYER=download` so headless Docker installs do not need `mpv` or `vlc` just to save files
- Docker images include `curl-impersonate` wrappers before plain `curl` on `PATH`, matching the preference order used by `ani-cli` v5
- Queue jobs created from Search pass the selected result number to `ani-cli -S`, avoiding the interactive series picker when the provider returns multiple similar titles
- Fallback retries keep the queued episode numbers when fallback downloader filenames are renumbered from `1`
- Docker images install `anipy-cli` and `animdl` automatically; non-Docker installs should provide the selected tools in `PATH` themselves
### Queue retries
- Adding a download that exactly matches an existing failed queue job reuses that failed entry and sets it back to `pending`
- This avoids stacking multiple failed rows for the same episode when you queue the same job again
### Jellyfin handoff ### Jellyfin handoff
@@ -125,6 +135,7 @@ Docker notes:
- TV libraries move only after the show is finished and download-complete - TV libraries move only after the show is finished and download-complete
- Movie libraries move after download completion - Movie libraries move after download completion
- Manual `Run now` scans the watchlist in a background job and shows live progress for processed entries, moved files, and the current destination path - Manual `Run now` scans the watchlist in a background job and shows live progress for processed entries, moved files, and the current destination path
- Manual watchlist filesystem sync rechecks downloaded episode flags against the files currently present under the download library
- Recent Jellyfin handoff jobs survive page reloads and show their latest status after app restarts - Recent Jellyfin handoff jobs survive page reloads and show their latest status after app restarts
## Homepage widget API ## Homepage widget API
@@ -154,8 +165,8 @@ This endpoint is intentionally small so Homepage can map the returned values dir
The Config page runtime panel shows: The Config page runtime panel shows:
- `ani-cli-web` version loaded from `VERSION` - `ani-cli-web` version loaded from `VERSION`
- Installed `ani-cli` version - Installed `ani-cli` version, checked with download-player mode so headless installs do not need `mpv` or `vlc` for runtime info
- Installed `anipy-cli` version and dependency status - Installed `anipy-cli` and `animdl` versions and dependency status
- A `Changelog` button that opens a scrollable viewer backed by `CHANGELOG.md` - A `Changelog` button that opens a scrollable viewer backed by `CHANGELOG.md`
## Remote access ## Remote access
@@ -191,6 +202,8 @@ Main contents:
- `state.sqlite3` - `state.sqlite3`
- `thumbnails/` - `thumbnails/`
Local `_backup_/` folders are ignored by git and Docker builds so temporary backup copies stay outside release artifacts.
Downloaded files are organized like this: Downloaded files are organized like this:
```text ```text
@@ -204,6 +217,7 @@ Most users only need these environment variables:
- `ANI_CLI_BIN` - `ANI_CLI_BIN`
- `ANIPY_CLI_BIN` - `ANIPY_CLI_BIN`
- `ANIMDL_BIN`
- `ANI_CLI_WEB_HOST` - `ANI_CLI_WEB_HOST`
- `ANI_CLI_WEB_PORT` - `ANI_CLI_WEB_PORT`
- `ANI_CLI_WEB_ALLOW_REMOTE` - `ANI_CLI_WEB_ALLOW_REMOTE`
+1 -1
View File
@@ -1 +1 @@
0.47.1 0.50.1
+275
View File
@@ -93,6 +93,7 @@ DOWNLOAD_QUEUE = None
WATCHLIST_REFRESH = None WATCHLIST_REFRESH = None
WATCHLIST_AUTO_REFRESH = None WATCHLIST_AUTO_REFRESH = None
WATCHLIST_JELLYFIN_SYNC = None WATCHLIST_JELLYFIN_SYNC = None
WATCHLIST_BACKUP_FORMAT_VERSION = 1
def graph_request(query, variables, timeout=25): def graph_request(query, variables, timeout=25):
@@ -987,6 +988,14 @@ def watchlist_source_library_dir(item, config):
return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item) return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item)
def watchlist_source_season_dir(item, config):
source_root = watchlist_source_library_dir(item, config)
if normalize_media_type(item.get("media_type")) == "movie":
return source_root
season = normalize_season((item or {}).get("auto_download_series") or "1")
return source_root / f"Season {int(season):02d}"
def jellyfin_target_library_dir(item, config): def jellyfin_target_library_dir(item, config):
media_type = normalize_media_type(item.get("media_type")) media_type = normalize_media_type(item.get("media_type"))
key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir" key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
@@ -1043,6 +1052,62 @@ def watchlist_item_download_complete(item, mode=""):
return True, "ready" return True, "ready"
def normalize_downloaded_episode_value(value):
text = str(value or "").strip()
if not text:
return ""
match = re.match(r"^0*([0-9]+)(.*)$", text)
if not match:
return text
number = int(match.group(1) or "0", 10)
suffix = str(match.group(2) or "")
return f"{number}{suffix}" if number > 0 else text
def reverse_episode_offset(value, offset):
normalized_offset = normalize_episode_offset(offset)
text = str(value or "").strip()
if not normalized_offset or not text:
return normalize_downloaded_episode_value(text)
match = re.match(r"^0*([0-9]+)(.*)$", text)
if not match:
return normalize_downloaded_episode_value(text)
number = int(match.group(1) or "0", 10)
if number < 1:
return normalize_downloaded_episode_value(text)
logical_number = number - int(normalized_offset, 10) + 1
if logical_number < 1:
return ""
suffix = str(match.group(2) or "")
return f"{logical_number}{suffix}"
def watchlist_downloaded_episode_values_from_filesystem(item, config, mode):
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in MODE_CHOICES:
return []
season_dir = watchlist_source_season_dir(item, config)
if not season_dir.exists() or not season_dir.is_dir():
return []
season = normalize_season((item or {}).get("auto_download_series") or "1")
pattern = re.compile(rf"\bS{int(season):02d}E([0-9][0-9A-Za-z.\-]*)\b", re.IGNORECASE)
values = []
seen = set()
offset = (item or {}).get("auto_download_offset")
for path in sorted(season_dir.rglob("*")):
if not path.is_file():
continue
match = pattern.search(path.stem)
if not match:
continue
logical_value = reverse_episode_offset(match.group(1), offset)
normalized_value = normalize_downloaded_episode_value(logical_value)
if normalized_value and normalized_value not in seen:
seen.add(normalized_value)
values.append(normalized_value)
return values
def _move_tree_contents(source_dir, target_dir, progress_fn=None): def _move_tree_contents(source_dir, target_dir, progress_fn=None):
moved = [] moved = []
file_paths = [ file_paths = [
@@ -1365,6 +1430,9 @@ class WatchlistStore:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
def _table_columns_conn(self, conn, table_name):
return [str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall()]
def _exists_conn(self, conn, show_id): def _exists_conn(self, conn, show_id):
row = conn.execute( row = conn.execute(
"SELECT 1 FROM watchlist WHERE show_id = ? LIMIT 1", "SELECT 1 FROM watchlist WHERE show_id = ? LIMIT 1",
@@ -2095,6 +2163,192 @@ class WatchlistStore:
) )
return self.schedule_refresh(existing["show_id"], source=source) return self.schedule_refresh(existing["show_id"], source=source)
def reconcile_downloaded_filesystem(self, config=None):
active_config = normalize_config(config or self.config_getter() or {})
items = []
changed = 0
with self.lock, self._connect() as conn:
rows = conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
for row in rows:
existing_item = self._row_to_item(row)
media_type = normalize_media_type(existing_item.get("media_type"))
if media_type == "movie":
source_dir = watchlist_source_library_dir(existing_item, active_config)
has_files = source_dir.exists() and any(path.is_file() for path in source_dir.rglob("*"))
new_sub_values = []
new_dub_values = []
downloaded = has_files
else:
filesystem_values = watchlist_downloaded_episode_values_from_filesystem(existing_item, active_config, "dub")
if filesystem_values:
preferred_mode = watchlist_preferred_completion_mode(existing_item)
new_sub_values = list(existing_item.get("downloaded_sub_episodes") or [])
new_dub_values = list(existing_item.get("downloaded_dub_episodes") or [])
if preferred_mode == "sub":
new_sub_values = filesystem_values
else:
new_dub_values = filesystem_values
downloaded = bool(new_sub_values or new_dub_values)
else:
new_sub_values = []
new_dub_values = []
downloaded = False
old_sub_values = existing_item.get("downloaded_sub_episodes") or []
old_dub_values = existing_item.get("downloaded_dub_episodes") or []
sub_added = [value for value in new_sub_values if value not in old_sub_values]
sub_removed = [value for value in old_sub_values if value not in new_sub_values]
dub_added = [value for value in new_dub_values if value not in old_dub_values]
dub_removed = [value for value in old_dub_values if value not in new_dub_values]
downloaded_changed = bool(existing_item.get("downloaded")) != downloaded
row_changed = bool(sub_added or sub_removed or dub_added or dub_removed or downloaded_changed)
if row_changed:
conn.execute(
"""
UPDATE watchlist
SET downloaded = ?, downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ?, updated_at = ?
WHERE show_id = ?
""",
(
1 if downloaded else 0,
encode_episode_values(new_sub_values) if new_sub_values else None,
encode_episode_values(new_dub_values) if new_dub_values else None,
now_iso(),
existing_item["show_id"],
),
)
changed += 1
items.append(
{
"show_id": existing_item.get("show_id"),
"title": existing_item.get("title"),
"media_type": media_type,
"changed": row_changed,
"downloaded": downloaded,
"sub_added": sub_added,
"sub_removed": sub_removed,
"dub_added": dub_added,
"dub_removed": dub_removed,
}
)
return {
"message": f"Reconciled downloaded watchlist files for {len(items)} entr{'y' if len(items) == 1 else 'ies'}."
+ (f" Updated {changed}." if changed else " No changes were needed."),
"total": len(items),
"changed": changed,
"items": items,
}
def export_backup(self):
with self.lock, self._connect() as conn:
watchlist_columns = self._table_columns_conn(conn, "watchlist")
removal_columns = self._table_columns_conn(conn, "watchlist_removals")
watchlist_rows = [
{column: row[column] for column in watchlist_columns}
for row in conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
]
removal_rows = [
{column: row[column] for column in removal_columns}
for row in conn.execute("SELECT * FROM watchlist_removals ORDER BY removed_at DESC").fetchall()
]
return {
"format": "ani-cli-web-watchlist-backup",
"format_version": WATCHLIST_BACKUP_FORMAT_VERSION,
"app_version": VERSION,
"exported_at": now_iso(),
"tables": {
"watchlist": {
"columns": watchlist_columns,
"rows": watchlist_rows,
},
"watchlist_removals": {
"columns": removal_columns,
"rows": removal_rows,
},
},
}
def import_backup(self, backup):
if not isinstance(backup, dict):
raise ValueError("Watchlist backup must be a JSON object.")
if backup.get("format") != "ani-cli-web-watchlist-backup":
raise ValueError("Backup format is not recognized.")
try:
format_version = int(backup.get("format_version") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("Backup format version is invalid.") from exc
if format_version < 1 or format_version > WATCHLIST_BACKUP_FORMAT_VERSION:
raise ValueError("Backup format version is not supported by this app version.")
tables = backup.get("tables")
if not isinstance(tables, dict):
raise ValueError("Backup is missing table data.")
watchlist_rows = ((tables.get("watchlist") or {}).get("rows") if isinstance(tables.get("watchlist"), dict) else None)
removal_rows = (
(tables.get("watchlist_removals") or {}).get("rows")
if isinstance(tables.get("watchlist_removals"), dict)
else []
)
if not isinstance(watchlist_rows, list):
raise ValueError("Backup is missing watchlist rows.")
if not isinstance(removal_rows, list):
raise ValueError("Backup removal rows must be an array.")
now = now_iso()
with self.lock, self._connect() as conn:
watchlist_columns = self._table_columns_conn(conn, "watchlist")
removal_columns = self._table_columns_conn(conn, "watchlist_removals")
conn.execute("DELETE FROM watchlist")
conn.execute("DELETE FROM watchlist_removals")
imported_watchlist = 0
for row in watchlist_rows:
if not isinstance(row, dict):
raise ValueError("Backup watchlist rows must be JSON objects.")
show_id = str(row.get("show_id") or "").strip()
title = str(row.get("title") or "").strip()
if not show_id or not title:
raise ValueError("Backup watchlist rows must include show_id and title.")
values = {column: row.get(column) for column in watchlist_columns}
values["show_id"] = show_id
values["title"] = title
values["category"] = normalize_watchlist_category(values.get("category"))
values["downloaded"] = 1 if values.get("downloaded") else 0
values["status"] = str(values.get("status") or "tracked")
values["status_message"] = str(values.get("status_message") or "Imported from backup.")
values["created_at"] = str(values.get("created_at") or now)
values["updated_at"] = str(values.get("updated_at") or now)
values["sub_count"] = int(values.get("sub_count") or 0)
values["dub_count"] = int(values.get("dub_count") or 0)
values["expected_count"] = int(values.get("expected_count") or 0)
values["media_type"] = normalize_media_type(values.get("media_type"))
placeholders = ", ".join("?" for _column in watchlist_columns)
column_sql = ", ".join(watchlist_columns)
conn.execute(
f"INSERT INTO watchlist ({column_sql}) VALUES ({placeholders})",
[values.get(column) for column in watchlist_columns],
)
imported_watchlist += 1
imported_removals = 0
for row in removal_rows:
if not isinstance(row, dict):
raise ValueError("Backup removal rows must be JSON objects.")
show_id = str(row.get("show_id") or "").strip()
if not show_id:
continue
values = {column: row.get(column) for column in removal_columns}
values["show_id"] = show_id
values["title"] = str(values.get("title") or "").strip()
values["removed_at"] = str(values.get("removed_at") or now)
placeholders = ", ".join("?" for _column in removal_columns)
column_sql = ", ".join(removal_columns)
conn.execute(
f"INSERT INTO watchlist_removals ({column_sql}) VALUES ({placeholders})",
[values.get(column) for column in removal_columns],
)
imported_removals += 1
return {
"message": f"Imported {imported_watchlist} watchlist entr{'y' if imported_watchlist == 1 else 'ies'} from backup.",
"imported": imported_watchlist,
"removals": imported_removals,
}
def mark_downloaded(self, show_id, title="", mode="", episodes="", media_type=""): def mark_downloaded(self, show_id, title="", mode="", episodes="", media_type=""):
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:
@@ -2653,6 +2907,22 @@ def get_jellyfin_sync_status():
return WATCHLIST_JELLYFIN_SYNC.status() return WATCHLIST_JELLYFIN_SYNC.status()
def export_watchlist_backup():
runtime = ensure_runtime()
return runtime["watchlist"].export_backup()
def import_watchlist_backup(payload):
runtime = ensure_runtime()
return runtime["watchlist"].import_backup(payload)
def reconcile_watchlist_downloaded_files(config=None):
runtime = ensure_runtime()
active_config = normalize_config(config or runtime["config"] or {})
return runtime["watchlist"].reconcile_downloaded_filesystem(active_config)
def update_watchlist_category(show_id, category): def update_watchlist_category(show_id, category):
ensure_runtime() ensure_runtime()
item = WATCHLIST.update_category(show_id, category) item = WATCHLIST.update_category(show_id, category)
@@ -2766,6 +3036,8 @@ 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)
@@ -2941,6 +3213,7 @@ Handler = build_handler_class(
download_watchlist_item=download_watchlist_item, download_watchlist_item=download_watchlist_item,
ensure_runtime=ensure_runtime, ensure_runtime=ensure_runtime,
episode_list=episode_list, episode_list=episode_list,
export_watchlist_backup=export_watchlist_backup,
get_config_snapshot=get_config_snapshot, get_config_snapshot=get_config_snapshot,
get_watchlist_homepage_summary=get_watchlist_homepage_summary, get_watchlist_homepage_summary=get_watchlist_homepage_summary,
get_jellyfin_sync_status=get_jellyfin_sync_status, get_jellyfin_sync_status=get_jellyfin_sync_status,
@@ -2948,7 +3221,9 @@ Handler = build_handler_class(
get_watchlist_refresh_status=get_watchlist_refresh_status, get_watchlist_refresh_status=get_watchlist_refresh_status,
http_error=HttpError, http_error=HttpError,
index_html=INDEX_HTML, index_html=INDEX_HTML,
import_watchlist_backup=import_watchlist_backup,
queue_html=QUEUE_HTML, queue_html=QUEUE_HTML,
reconcile_watchlist_downloaded_files=reconcile_watchlist_downloaded_files,
remove_from_watchlist=remove_from_watchlist, remove_from_watchlist=remove_from_watchlist,
run_jellyfin_sync=run_jellyfin_sync, run_jellyfin_sync=run_jellyfin_sync,
runtime_state=runtime_state, runtime_state=runtime_state,
+77 -5
View File
@@ -22,8 +22,24 @@ from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
ANIPY_CLI = os.environ.get("ANIPY_CLI_BIN") or shutil.which("anipy-cli") or "anipy-cli"
def configured_cli_command(env_name, executable_name):
configured = str(os.environ.get(env_name) or "").strip()
if configured:
return configured
resolved = shutil.which(executable_name)
if resolved:
return resolved
for candidate in (Path("/usr/local/bin") / executable_name, Path("/usr/bin") / executable_name):
if candidate.exists() and os.access(candidate, os.X_OK):
return str(candidate)
return executable_name
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 = "ani-cli-web" APP_NAME = "ani-cli-web"
VERSION_FILE = PROJECT_ROOT / "VERSION" VERSION_FILE = PROJECT_ROOT / "VERSION"
CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md" CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md"
@@ -41,6 +57,7 @@ DEBUG_MODE = False
QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"} QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"}
MODE_CHOICES = {"sub", "dub"} MODE_CHOICES = {"sub", "dub"}
DOWNLOAD_METHOD_CHOICES = ("ani-cli", "anipy-cli", "animdl")
MEDIA_TYPE_CHOICES = {"tv", "movie"} MEDIA_TYPE_CHOICES = {"tv", "movie"}
WATCHLIST_CATEGORY_LABELS = { WATCHLIST_CATEGORY_LABELS = {
"watching": "Watching", "watching": "Watching",
@@ -254,6 +271,10 @@ def background_workers_enabled():
return not env_flag("ANI_CLI_WEB_DISABLE_WORKER") return not env_flag("ANI_CLI_WEB_DISABLE_WORKER")
def job_stdout_enabled():
return env_flag("ANI_CLI_WEB_JOB_STDOUT")
def client_address_is_local(host): def client_address_is_local(host):
text = str(host or "").strip() text = str(host or "").strip()
if not text: if not text:
@@ -292,6 +313,7 @@ DEFAULT_CONFIG = {
"mode": os.environ.get("ANI_CLI_MODE", "sub"), "mode": os.environ.get("ANI_CLI_MODE", "sub"),
"quality": os.environ.get("ANI_CLI_QUALITY", "best"), "quality": os.environ.get("ANI_CLI_QUALITY", "best"),
"anipy_cli_fallback_enabled": False, "anipy_cli_fallback_enabled": False,
"download_methods": ["ani-cli"],
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES, "watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
"watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS, "watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS,
@@ -438,6 +460,7 @@ def write_json(path, data):
def normalize_config(data): def normalize_config(data):
source_has_download_methods = isinstance(data, dict) and "download_methods" in data
config = dict(DEFAULT_CONFIG) config = dict(DEFAULT_CONFIG)
if isinstance(data, dict): if isinstance(data, dict):
config.update({key: data[key] for key in DEFAULT_CONFIG if key in data}) config.update({key: data[key] for key in DEFAULT_CONFIG if key in data})
@@ -448,6 +471,7 @@ def normalize_config(data):
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") anipy_cli_fallback_enabled = config.get("anipy_cli_fallback_enabled")
download_methods = config.get("download_methods")
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled") auto_refresh_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")
refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds") refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds")
@@ -468,6 +492,18 @@ def normalize_config(data):
anipy_cli_fallback_enabled = anipy_cli_fallback_enabled.strip().lower() in {"1", "true", "yes", "on"} anipy_cli_fallback_enabled = anipy_cli_fallback_enabled.strip().lower() in {"1", "true", "yes", "on"}
else: else:
anipy_cli_fallback_enabled = bool(anipy_cli_fallback_enabled) anipy_cli_fallback_enabled = bool(anipy_cli_fallback_enabled)
if isinstance(download_methods, str):
method_values = [part.strip().lower() for part in re.split(r"[\s,]+", download_methods) if part.strip()]
elif isinstance(download_methods, list):
method_values = [str(part or "").strip().lower() for part in download_methods]
else:
method_values = []
normalized_methods = []
for method in method_values:
if method in DOWNLOAD_METHOD_CHOICES and method not in normalized_methods:
normalized_methods.append(method)
if not normalized_methods or not source_has_download_methods:
normalized_methods = ["ani-cli", "anipy-cli"] if anipy_cli_fallback_enabled else ["ani-cli"]
if isinstance(auto_refresh_enabled, str): if isinstance(auto_refresh_enabled, str):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"} auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
else: else:
@@ -501,7 +537,8 @@ def normalize_config(data):
config["mode"] = mode if mode in MODE_CHOICES else "sub" config["mode"] = mode if mode in MODE_CHOICES else "sub"
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["anipy_cli_fallback_enabled"] = anipy_cli_fallback_enabled config["download_methods"] = normalized_methods
config["anipy_cli_fallback_enabled"] = len(normalized_methods) > 1
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes
config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds
@@ -976,6 +1013,10 @@ def cli_executable_exists(command):
return bool(shutil.which(text) or (Path(text).exists() and os.access(text, os.X_OK))) return bool(shutil.which(text) or (Path(text).exists() and os.access(text, os.X_OK)))
def cli_executable_exists_any(*commands):
return any(cli_executable_exists(command) for command in commands)
def printable_command(command): def printable_command(command):
return " ".join(sh_quote(part) for part in command) return " ".join(sh_quote(part) for part in command)
@@ -1131,7 +1172,7 @@ def finalize_library_files(job):
final_name = f"{library_name}{source.suffix}" final_name = f"{library_name}{source.suffix}"
else: else:
ep = None ep = None
if backend == "anipy-cli" and requested_episode_values: if backend in {"anipy-cli", "animdl"} and requested_episode_values:
ep = requested_episode_values.pop(0) 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)
@@ -1175,7 +1216,7 @@ def build_job(payload, config):
"season": normalize_season(payload.get("season")), "season": normalize_season(payload.get("season")),
"episode_offset": normalize_episode_offset(payload.get("episode_offset")), "episode_offset": normalize_episode_offset(payload.get("episode_offset")),
"query": query, "query": query,
"result_index": None, "result_index": normalize_result_index(payload.get("result_index")),
"mode": mode, "mode": mode,
"quality": quality, "quality": quality,
"episodes": validate_episode_spec(payload.get("episodes")), "episodes": validate_episode_spec(payload.get("episodes")),
@@ -1191,6 +1232,18 @@ def build_job(payload, config):
} }
def normalize_result_index(value):
if value in (None, ""):
return None
try:
index = int(str(value).strip())
except (TypeError, ValueError):
return None
if index < 1:
return None
return index
def anipy_search_spec(job): def anipy_search_spec(job):
query = re.sub(r"\s+", " ", str(job.get("query") or "").replace(":", " ")).strip() 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" mode = "dub" if str(job.get("mode") or "").strip().lower() == "dub" else "sub"
@@ -1211,6 +1264,22 @@ def command_for_job(job, backend="ani-cli", download_path=None):
if download_path: if download_path:
command.extend(["-l", str(download_path)]) command.extend(["-l", str(download_path)])
return command return command
if normalized_backend == "animdl":
query = str(job.get("query") or "").strip()
command = [
ANIMDL,
"download",
query,
"-r",
job["episodes"],
"-q",
job["quality"],
"--index",
"1",
]
if download_path:
command.extend(["-d", str(download_path)])
return command
command = [ command = [
ANI_CLI, ANI_CLI,
"-d", "-d",
@@ -1219,6 +1288,9 @@ def command_for_job(job, backend="ani-cli", download_path=None):
"-e", "-e",
job["episodes"], job["episodes"],
] ]
result_index = normalize_result_index(job.get("result_index"))
if result_index is not None:
command.extend(["-S", str(result_index)])
if job["mode"] == "dub": if job["mode"] == "dub":
command.append("--dub") command.append("--dub")
command.append(job["query"]) command.append(job["query"])
+124 -12
View File
@@ -21,6 +21,7 @@ CONFIG_HTML = r"""<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ani-cli web - config</title> <title>ani-cli web - config</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;
@@ -462,6 +463,7 @@ CONFIG_HTML = r"""<!doctype html>
<p id="versionLine">Loading version...</p> <p id="versionLine">Loading version...</p>
<p id="aniCliVersionLine">Detecting ani-cli version...</p> <p id="aniCliVersionLine">Detecting ani-cli version...</p>
<p id="anipyCliVersionLine">Detecting anipy-cli fallback...</p> <p id="anipyCliVersionLine">Detecting anipy-cli fallback...</p>
<p id="animdlVersionLine">Detecting animdl fallback...</p>
<p>Saved settings live in `.ani-cli-web/config.json` inside this project.</p> <p>Saved settings live in `.ani-cli-web/config.json` inside this project.</p>
</div> </div>
<button class="ghost wide-button" id="openChangelogBtn" type="button">Changelog</button> <button class="ghost wide-button" id="openChangelogBtn" type="button">Changelog</button>
@@ -511,18 +513,15 @@ CONFIG_HTML = r"""<!doctype html>
<div class="toolbar"> <div class="toolbar">
<div> <div>
<h2>Download fallback</h2> <h2>Download fallback</h2>
<p class="muted">If a queued `ani-cli` download fails, optionally retry it once with `anipy-cli`.</p> <p class="muted">Choose the download methods queue jobs may use, in fallback order.</p>
</div> </div>
</div> </div>
<div class="form-grid"> <div class="check-grid">
<label>Automatic retry with anipy-cli <label class="check-item"><input class="download-method" type="checkbox" value="ani-cli"> <span>ani-cli</span></label>
<select id="anipyCliFallbackEnabled"> <label class="check-item"><input class="download-method" type="checkbox" value="anipy-cli"> <span>anipy-cli</span></label>
<option value="false">Disabled</option> <label class="check-item"><input class="download-method" type="checkbox" value="animdl"> <span>animdl</span></label>
<option value="true">Enabled</option>
</select>
</label>
</div> </div>
<div class="field-hint">This only kicks in after an `ani-cli` failure. Install `anipy-cli` separately and keep it in `PATH` if you enable this option.</div> <div class="field-hint">Jobs try checked methods in this order: ani-cli, anipy-cli, animdl. With one method checked, fallback retry is disabled.</div>
</section> </section>
<section class="settings settings-main"> <section class="settings settings-main">
@@ -613,6 +612,34 @@ CONFIG_HTML = r"""<!doctype html>
<div class="muted" id="jellyfinSyncStatus">No recent Jellyfin handoff job.</div> <div class="muted" id="jellyfinSyncStatus">No recent Jellyfin handoff job.</div>
</section> </section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Watchlist filesystem sync</h2>
<p class="muted">Manually reconcile downloaded episode flags with the files currently present under the download library.</p>
</div>
<button id="reconcileWatchlistDownloadsBtn" type="button">Sync now</button>
</div>
<div class="field-hint">This scans the configured download library, removes downloaded episode flags for files that no longer exist, and adds flags for episodes already present on disk.</div>
<div class="muted" id="watchlistReconcileStatus">No watchlist filesystem sync run yet.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Watchlist backup</h2>
<p class="muted">Export or restore a complete watchlist database snapshot, including removed-show records.</p>
</div>
<div class="row">
<button id="exportWatchlistBtn" type="button">Export</button>
<button id="importWatchlistBtn" type="button">Import</button>
</div>
</div>
<input id="importWatchlistFile" type="file" accept="application/json,.json" hidden>
<div class="field-hint">Import replaces the current watchlist with the selected backup file.</div>
<div class="muted" id="watchlistBackupStatus">No watchlist backup action run yet.</div>
</section>
<section class="settings settings-main"> <section class="settings settings-main">
<div class="toolbar"> <div class="toolbar">
<div> <div>
@@ -686,6 +713,7 @@ CONFIG_HTML = r"""<!doctype html>
quality: "best", quality: "best",
download_dir: "", download_dir: "",
anipy_cli_fallback_enabled: false, anipy_cli_fallback_enabled: false,
download_methods: ["ani-cli"],
watchlist_auto_refresh_enabled: false, watchlist_auto_refresh_enabled: false,
watchlist_auto_refresh_minutes: 60, watchlist_auto_refresh_minutes: 60,
watchlist_refresh_delay_seconds: 5, watchlist_refresh_delay_seconds: 5,
@@ -706,7 +734,9 @@ CONFIG_HTML = r"""<!doctype html>
home_path: "" home_path: ""
}, },
jellyfinSyncRunning: false, jellyfinSyncRunning: false,
jellyfinSyncJobId: null jellyfinSyncJobId: null,
watchlistReconcileRunning: false,
watchlistImportRunning: false
}; };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -743,16 +773,39 @@ CONFIG_HTML = r"""<!doctype html>
el.textContent = job.message || "Jellyfin handoff idle."; el.textContent = job.message || "Jellyfin handoff idle.";
} }
function setWatchlistReconcileButton(running) {
$("reconcileWatchlistDownloadsBtn").disabled = !!running;
$("reconcileWatchlistDownloadsBtn").textContent = running ? "Syncing..." : "Sync now";
}
function setWatchlistReconcileStatus(text) {
$("watchlistReconcileStatus").textContent = text || "No watchlist filesystem sync run yet.";
}
function setWatchlistBackupStatus(text) {
$("watchlistBackupStatus").textContent = text || "No watchlist backup action run yet.";
}
function setWatchlistImportButton(running) {
$("importWatchlistBtn").disabled = !!running;
$("importWatchlistBtn").textContent = running ? "Importing..." : "Import";
}
function selectedWebhookEvents() { function selectedWebhookEvents() {
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value); return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
} }
function selectedDownloadMethods() {
const methods = [...document.querySelectorAll(".download-method:checked")].map((input) => input.value);
return methods.length ? methods : ["ani-cli"];
}
function formConfigPayload() { function formConfigPayload() {
return { return {
download_dir: $("downloadDir").value, download_dir: $("downloadDir").value,
mode: $("configMode").value, mode: $("configMode").value,
quality: $("configQuality").value, quality: $("configQuality").value,
anipy_cli_fallback_enabled: $("anipyCliFallbackEnabled").value === "true", download_methods: selectedDownloadMethods(),
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true", watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value, watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value, watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value,
@@ -772,7 +825,10 @@ CONFIG_HTML = r"""<!doctype html>
$("downloadDir").value = data.download_dir; $("downloadDir").value = data.download_dir;
$("configMode").value = data.mode; $("configMode").value = data.mode;
$("configQuality").value = data.quality; $("configQuality").value = data.quality;
$("anipyCliFallbackEnabled").value = String(Boolean(data.anipy_cli_fallback_enabled)); const methods = new Set(data.download_methods || ["ani-cli"]);
for (const input of document.querySelectorAll(".download-method")) {
input.checked = methods.has(input.value);
}
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled)); $("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes; $("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds; $("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
@@ -854,6 +910,57 @@ CONFIG_HTML = r"""<!doctype html>
} }
} }
async function reconcileWatchlistDownloads() {
state.watchlistReconcileRunning = true;
setWatchlistReconcileButton(true);
setWatchlistReconcileStatus("Scanning the download library...");
try {
const data = await api("/api/config/watchlist/reconcile-downloads", {
method: "POST",
body: JSON.stringify(formConfigPayload())
});
const changed = Number(data.changed || 0);
const total = Number(data.total || 0);
setWatchlistReconcileStatus(`${changed} updated out of ${total} watchlist entries.`);
setNotice(data.message || "Watchlist filesystem sync completed.");
} catch (error) {
setWatchlistReconcileStatus("Watchlist filesystem sync failed.");
setNotice(error.message);
} finally {
state.watchlistReconcileRunning = false;
setWatchlistReconcileButton(false);
}
}
function exportWatchlistBackup() {
setWatchlistBackupStatus("Preparing watchlist backup download...");
window.location.href = "/api/config/watchlist/export";
}
async function importWatchlistBackup(file) {
if (!file) return;
state.watchlistImportRunning = true;
setWatchlistImportButton(true);
setWatchlistBackupStatus("Reading backup file...");
try {
const payload = JSON.parse(await file.text());
setWatchlistBackupStatus("Importing backup...");
const data = await api("/api/config/watchlist/import", {
method: "POST",
body: JSON.stringify(payload)
});
setWatchlistBackupStatus(`${data.imported || 0} watchlist entries imported.`);
setNotice(data.message || "Watchlist backup imported.");
} catch (error) {
setWatchlistBackupStatus("Watchlist backup import failed.");
setNotice(error.message);
} finally {
$("importWatchlistFile").value = "";
state.watchlistImportRunning = false;
setWatchlistImportButton(false);
}
}
function closePathBrowser() { function closePathBrowser() {
state.pathBrowser = { state.pathBrowser = {
targetId: "", targetId: "",
@@ -983,6 +1090,7 @@ CONFIG_HTML = r"""<!doctype html>
$("versionLine").textContent = `${data.name} ${data.version}`; $("versionLine").textContent = `${data.name} ${data.version}`;
$("aniCliVersionLine").textContent = `ani-cli ${data.ani_cli_version || "Unavailable"}`; $("aniCliVersionLine").textContent = `ani-cli ${data.ani_cli_version || "Unavailable"}`;
$("anipyCliVersionLine").textContent = `anipy-cli ${data.anipy_cli_version || "Unavailable"}`; $("anipyCliVersionLine").textContent = `anipy-cli ${data.anipy_cli_version || "Unavailable"}`;
$("animdlVersionLine").textContent = `animdl ${data.animdl_version || "Unavailable"}`;
} }
async function openChangelog() { async function openChangelog() {
@@ -999,6 +1107,10 @@ CONFIG_HTML = r"""<!doctype html>
$("saveConfigBtn").addEventListener("click", saveConfig); $("saveConfigBtn").addEventListener("click", saveConfig);
$("testWebhookBtn").addEventListener("click", testWebhook); $("testWebhookBtn").addEventListener("click", testWebhook);
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync); $("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
$("reconcileWatchlistDownloadsBtn").addEventListener("click", reconcileWatchlistDownloads);
$("exportWatchlistBtn").addEventListener("click", exportWatchlistBackup);
$("importWatchlistBtn").addEventListener("click", () => $("importWatchlistFile").click());
$("importWatchlistFile").addEventListener("change", (event) => importWatchlistBackup((event.target.files || [])[0]));
$("openChangelogBtn").addEventListener("click", openChangelog); $("openChangelogBtn").addEventListener("click", openChangelog);
$("closeChangelogBtn").addEventListener("click", closeChangelog); $("closeChangelogBtn").addEventListener("click", closeChangelog);
$("pathBrowserCloseBtn").addEventListener("click", closePathBrowser); $("pathBrowserCloseBtn").addEventListener("click", closePathBrowser);
+1 -1
View File
@@ -6,7 +6,6 @@ services:
args: args:
ANI_CLI: ${ANI_CLI:-https://github.com/pystardust/ani-cli.git} ANI_CLI: ${ANI_CLI:-https://github.com/pystardust/ani-cli.git}
ANI_CLI_BRANCH: ${ANI_CLI_BRANCH:-master} ANI_CLI_BRANCH: ${ANI_CLI_BRANCH:-master}
ANI_CLI_WEB: ${ANI_CLI_WEB:-https://gitea.coreplay.eu/Dymas/ani-cli-web.git}
image: ani-cli-web:latest image: ani-cli-web:latest
container_name: ani-cli-web container_name: ani-cli-web
restart: unless-stopped restart: unless-stopped
@@ -21,6 +20,7 @@ services:
ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false} ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false}
ANI_CLI_WEB_AUTH_USERNAME: ${ANI_CLI_WEB_AUTH_USERNAME:-} ANI_CLI_WEB_AUTH_USERNAME: ${ANI_CLI_WEB_AUTH_USERNAME:-}
ANI_CLI_WEB_AUTH_PASSWORD: ${ANI_CLI_WEB_AUTH_PASSWORD:-} ANI_CLI_WEB_AUTH_PASSWORD: ${ANI_CLI_WEB_AUTH_PASSWORD:-}
ANI_CLI_WEB_JOB_STDOUT: ${ANI_CLI_WEB_JOB_STDOUT:-true}
ANI_CLI_DOWNLOAD_DIR: /downloads ANI_CLI_DOWNLOAD_DIR: /downloads
volumes: volumes:
- ./downloads:/downloads - ./downloads:/downloads
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+151 -5
View File
@@ -20,6 +20,7 @@ from pathlib import Path
from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
from app_support import ( from app_support import (
ANIMDL,
ANI_CLI, ANI_CLI,
ANIPY_CLI, ANIPY_CLI,
APP_NAME, APP_NAME,
@@ -27,12 +28,14 @@ from app_support import (
CLIENT_DISCONNECT_ERRORS, CLIENT_DISCONNECT_ERRORS,
MAX_JSON_BODY_BYTES, MAX_JSON_BODY_BYTES,
MODE_CHOICES, MODE_CHOICES,
PROJECT_ROOT,
VERSION, VERSION,
client_address_is_local, client_address_is_local,
configured_remote_path_roots, configured_remote_path_roots,
debug_enabled, debug_enabled,
debug_log, debug_log,
load_project_text_file, load_project_text_file,
cli_executable_exists_any,
cli_executable_exists, cli_executable_exists,
normalize_config, normalize_config,
path_is_within_roots, path_is_within_roots,
@@ -46,6 +49,8 @@ from app_support import (
validate_discord_webhook_url, validate_discord_webhook_url,
) )
FAVICON_PATH = PROJECT_ROOT / "favicon.png"
@dataclass(frozen=True) @dataclass(frozen=True)
class HandlerContext: class HandlerContext:
add_to_watchlist: object add_to_watchlist: object
@@ -53,6 +58,7 @@ class HandlerContext:
download_watchlist_item: object download_watchlist_item: object
ensure_runtime: object ensure_runtime: object
episode_list: object episode_list: object
export_watchlist_backup: object
get_config_snapshot: object get_config_snapshot: object
get_jellyfin_sync_status: object get_jellyfin_sync_status: object
get_watchlist_homepage_summary: object get_watchlist_homepage_summary: object
@@ -60,7 +66,9 @@ class HandlerContext:
get_watchlist_refresh_status: object get_watchlist_refresh_status: object
http_error: object http_error: object
index_html: str index_html: str
import_watchlist_backup: object
queue_html: str queue_html: str
reconcile_watchlist_downloaded_files: object
remove_from_watchlist: object remove_from_watchlist: object
run_jellyfin_sync: object run_jellyfin_sync: object
runtime_state: object runtime_state: object
@@ -89,8 +97,25 @@ def build_handler_class(context):
def dependency_status(): def dependency_status():
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"] checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
result = {name: bool(shutil.which(name)) for name in checks} result = {name: bool(shutil.which(name)) for name in checks}
result["curl-impersonate"] = cli_executable_exists_any(
"curl_firefox135",
"curl_chrome136",
"curl_chrome116",
"curl_ff117",
)
result["ani-cli"] = cli_executable_exists(ANI_CLI) result["ani-cli"] = cli_executable_exists(ANI_CLI)
result["anipy-cli"] = cli_executable_exists(ANIPY_CLI) result["anipy-cli"] = cli_executable_exists_any(
ANIPY_CLI,
"anipy-cli",
"/usr/local/bin/anipy-cli",
"/usr/bin/anipy-cli",
)
result["animdl"] = cli_executable_exists_any(
ANIMDL,
"animdl",
"/usr/local/bin/animdl",
"/usr/bin/animdl",
)
return result return result
@@ -153,6 +178,66 @@ def browse_filesystem(path_value="", mode="dir", allowed_roots=None):
} }
def queue_list_payload(runtime, page=1, per_page=10):
page = max(1, int(page or 1))
per_page = min(50, max(1, int(per_page or 10)))
download_queue = runtime["download_queue"]
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
download_payload = download_queue.list(page=1, per_page=50)
jobs = list(download_payload.get("jobs") or [])
for download_page in range(2, int(download_payload.get("pages") or 1) + 1):
jobs.extend(download_queue.list(page=download_page, per_page=50).get("jobs") or [])
if jellyfin_jobs is not None:
jobs.extend(jellyfin_jobs.list_queue_jobs(limit=200))
jobs.sort(key=lambda job: str(job.get("created_at") or ""), reverse=True)
total = len(jobs)
pages = max(1, (total + per_page - 1) // per_page)
offset = (page - 1) * per_page
return {
"jobs": jobs[offset : offset + per_page],
"page": page,
"per_page": per_page,
"total": total,
"pages": pages,
}
def queue_get_job(runtime, job_id):
try:
return runtime["download_queue"].get(job_id)
except KeyError:
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
if jellyfin_jobs is None:
raise
return jellyfin_jobs.get_queue_job(job_id)
def queue_remove_job(runtime, job_id):
try:
return runtime["download_queue"].remove(job_id)
except KeyError:
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
if jellyfin_jobs is None:
raise
return jellyfin_jobs.remove_queue_job(job_id)
def queue_clear_finished(runtime):
result = runtime["download_queue"].clear_finished()
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
if jellyfin_jobs is not None:
result["count"] = int(result.get("count") or 0) + int(jellyfin_jobs.clear_finished_queue_jobs().get("count") or 0)
return result
def queue_clear_failed(runtime):
result = runtime["download_queue"].clear_failed()
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
if jellyfin_jobs is not None:
result["count"] = int(result.get("count") or 0) + int(jellyfin_jobs.clear_failed_queue_jobs().get("count") or 0)
return result
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def installed_ani_cli_version(): def installed_ani_cli_version():
try: try:
@@ -160,6 +245,11 @@ def installed_ani_cli_version():
[ANI_CLI, "--version"], [ANI_CLI, "--version"],
check=True, check=True,
capture_output=True, capture_output=True,
env={
**os.environ.copy(),
"ANI_CLI_PLAYER": "download",
"TERM": os.environ.get("TERM", "xterm-256color"),
},
text=True, text=True,
timeout=6, timeout=6,
) )
@@ -185,6 +275,22 @@ def installed_anipy_cli_version():
return version or "Unavailable" return version or "Unavailable"
@lru_cache(maxsize=1)
def installed_animdl_version():
try:
completed = subprocess.run(
[ANIMDL, "--version"],
check=True,
capture_output=True,
text=True,
timeout=6,
)
except (OSError, subprocess.SubprocessError):
return "Unavailable"
version = str(completed.stdout or completed.stderr or "").strip()
return version or "Unavailable"
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "AniCliWeb/1.0" server_version = "AniCliWeb/1.0"
remote_session_cookie_name = "ani_cli_web_session" remote_session_cookie_name = "ani_cli_web_session"
@@ -382,6 +488,7 @@ class Handler(BaseHTTPRequestHandler):
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{APP_NAME} sign in</title> <title>{APP_NAME} sign in</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
<style> <style>
:root {{ :root {{
color-scheme: dark; color-scheme: dark;
@@ -545,6 +652,11 @@ class Handler(BaseHTTPRequestHandler):
self.ensure_client_access() self.ensure_client_access()
if parsed.path == "/": if parsed.path == "/":
self.html(Handler._context(self).index_html) self.html(Handler._context(self).index_html)
elif parsed.path in {"/favicon.png", "/favicon.ico"}:
if not FAVICON_PATH.exists():
self.error(HTTPStatus.NOT_FOUND, "Favicon not found")
return
self.file(FAVICON_PATH)
elif parsed.path == "/queue": elif parsed.path == "/queue":
self.html(Handler._context(self).queue_html) self.html(Handler._context(self).queue_html)
elif parsed.path == "/config": elif parsed.path == "/config":
@@ -560,6 +672,7 @@ class Handler(BaseHTTPRequestHandler):
"version": VERSION, "version": VERSION,
"ani_cli_version": installed_ani_cli_version(), "ani_cli_version": installed_ani_cli_version(),
"anipy_cli_version": installed_anipy_cli_version(), "anipy_cli_version": installed_anipy_cli_version(),
"animdl_version": installed_animdl_version(),
} }
) )
elif parsed.path == "/api/changelog": elif parsed.path == "/api/changelog":
@@ -610,7 +723,7 @@ class Handler(BaseHTTPRequestHandler):
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
page = (params.get("page") or ["1"])[0] page = (params.get("page") or ["1"])[0]
per_page = (params.get("per_page") or ["10"])[0] per_page = (params.get("per_page") or ["10"])[0]
self.json(runtime["download_queue"].list(page=page, per_page=per_page)) self.json(queue_list_payload(runtime, page=page, per_page=per_page))
elif parsed.path.startswith("/api/queue/"): elif parsed.path.startswith("/api/queue/"):
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
parts = parsed.path.strip("/").split("/") parts = parsed.path.strip("/").split("/")
@@ -618,7 +731,7 @@ class Handler(BaseHTTPRequestHandler):
self.error(HTTPStatus.NOT_FOUND, "Not found") self.error(HTTPStatus.NOT_FOUND, "Not found")
return return
_, _, job_id = parts _, _, job_id = parts
self.json(runtime["download_queue"].get(job_id)) self.json(queue_get_job(runtime, job_id))
elif parsed.path.startswith("/api/watchlist/thumb/"): elif parsed.path.startswith("/api/watchlist/thumb/"):
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip() show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
@@ -647,6 +760,12 @@ class Handler(BaseHTTPRequestHandler):
elif parsed.path == "/api/config/jellyfin/sync-status": elif parsed.path == "/api/config/jellyfin/sync-status":
Handler._runtime(self) Handler._runtime(self)
self.json(Handler._context(self).get_jellyfin_sync_status()) self.json(Handler._context(self).get_jellyfin_sync_status())
elif parsed.path == "/api/config/watchlist/export":
Handler._runtime(self)
self.json_attachment(
Handler._context(self).export_watchlist_backup(),
"ani-cli-web-watchlist-backup.json",
)
else: else:
self.error(HTTPStatus.NOT_FOUND, "Not found") self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc: except Exception as exc:
@@ -688,6 +807,17 @@ class Handler(BaseHTTPRequestHandler):
if str(merged.get(key) or "").strip(): if str(merged.get(key) or "").strip():
Handler.validate_remote_path(self, merged[key], label) Handler.validate_remote_path(self, merged[key], label)
self.json(Handler._context(self).start_jellyfin_sync(merged), HTTPStatus.ACCEPTED) self.json(Handler._context(self).start_jellyfin_sync(merged), HTTPStatus.ACCEPTED)
elif parsed.path == "/api/config/watchlist/reconcile-downloads":
payload = Handler.require_json_object(self, self.body_json())
current = Handler._context(self).get_config_snapshot()
merged = dict(current)
merged.update(payload)
if str(merged.get("download_dir") or "").strip():
Handler.validate_remote_path(self, merged["download_dir"], "download directory")
self.json(Handler._context(self).reconcile_watchlist_downloaded_files(merged))
elif parsed.path == "/api/config/watchlist/import":
payload = Handler.require_json_object(self, self.body_json())
self.json(Handler._context(self).import_watchlist_backup(payload))
elif parsed.path == "/api/config/webhook/test": elif parsed.path == "/api/config/webhook/test":
payload = Handler.require_json_object(self, self.body_json()) payload = Handler.require_json_object(self, self.body_json())
validate_discord_webhook_url(payload.get("discord_webhook_url")) validate_discord_webhook_url(payload.get("discord_webhook_url"))
@@ -703,7 +833,10 @@ class Handler(BaseHTTPRequestHandler):
self.json(runtime["download_queue"].retry_all_failed()) self.json(runtime["download_queue"].retry_all_failed())
elif parsed.path == "/api/queue/clear-finished": elif parsed.path == "/api/queue/clear-finished":
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
self.json(runtime["download_queue"].clear_finished()) self.json(queue_clear_finished(runtime))
elif parsed.path == "/api/queue/clear-failed":
runtime = Handler._runtime(self)
self.json(queue_clear_failed(runtime))
elif parsed.path.startswith("/api/queue/"): elif parsed.path.startswith("/api/queue/"):
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
parts = parsed.path.strip("/").split("/") parts = parsed.path.strip("/").split("/")
@@ -714,7 +847,7 @@ class Handler(BaseHTTPRequestHandler):
actions = { actions = {
"retry": runtime["download_queue"].retry, "retry": runtime["download_queue"].retry,
"cancel": runtime["download_queue"].cancel, "cancel": runtime["download_queue"].cancel,
"remove": runtime["download_queue"].remove, "remove": lambda job_id: queue_remove_job(runtime, job_id),
} }
if action not in actions: if action not in actions:
self.error(HTTPStatus.NOT_FOUND, "Not found") self.error(HTTPStatus.NOT_FOUND, "Not found")
@@ -903,6 +1036,19 @@ class Handler(BaseHTTPRequestHandler):
data = json.dumps(payload).encode("utf-8") data = json.dumps(payload).encode("utf-8")
self.write_response_bytes(data, status, {"Content-Type": "application/json"}) self.write_response_bytes(data, status, {"Content-Type": "application/json"})
def json_attachment(self, payload, filename, status=HTTPStatus.OK):
data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
safe_filename = str(filename or "watchlist-backup.json").replace('"', "")
self.write_response_bytes(
data,
status,
{
"Content-Type": "application/json",
"Content-Disposition": f'attachment; filename="{safe_filename}"',
"Cache-Control": "no-store",
},
)
def write_response_bytes(self, payload, status, headers): def write_response_bytes(self, payload, status, headers):
try: try:
self.send_response(status) self.send_response(status)
+215 -37
View File
@@ -13,7 +13,10 @@ import time
import re import re
from app_support import ( from app_support import (
ANIMDL,
ANIPY_CLI, ANIPY_CLI,
ANI_CLI,
DOWNLOAD_METHOD_CHOICES,
MAX_LOG_LINES, MAX_LOG_LINES,
PROJECT_ROOT, PROJECT_ROOT,
QUEUE_PATH, QUEUE_PATH,
@@ -26,6 +29,7 @@ from app_support import (
finalize_library_files, finalize_library_files,
job_output_dir, job_output_dir,
job_staging_dir, job_staging_dir,
job_stdout_enabled,
load_json, load_json,
normalize_config, normalize_config,
now_iso, now_iso,
@@ -37,6 +41,11 @@ from app_support import (
LOG_PERSIST_INTERVAL_SECONDS = 0.75 LOG_PERSIST_INTERVAL_SECONDS = 0.75
LOG_PERSIST_LINE_BATCH = 8 LOG_PERSIST_LINE_BATCH = 8
DOWNLOAD_BACKEND_COMMANDS = {
"ani-cli": ANI_CLI,
"anipy-cli": ANIPY_CLI,
"animdl": ANIMDL,
}
JOB_STRUCTURED_COLUMNS = ( JOB_STRUCTURED_COLUMNS = (
"show_id", "show_id",
"title", "title",
@@ -647,6 +656,74 @@ class JellyfinSyncJobs:
def _copy_job(self, job): def _copy_job(self, job):
return json.loads(json.dumps(job)) return json.loads(json.dumps(job))
def _queue_job_payload(self, job):
config = job.get("config") or {}
target_paths = [
str(config.get(key) or "").strip()
for key in ("jellyfin_tv_dir", "jellyfin_movie_dir")
if str(config.get(key) or "").strip()
]
log_lines = [str(job.get("message") or "")]
if job.get("current_file"):
log_lines.append(f"Moving: {job['current_file']}")
for item in job.get("items") or []:
title = str(item.get("title") or item.get("show_id") or "Unknown title")
outcome = "moved" if item.get("moved") else str(item.get("reason") or "skipped")
target = str(item.get("target") or "").strip()
suffix = f" -> {target}" if target else ""
log_lines.append(f"{title}: {outcome}{suffix}")
payload = self._copy_job(job)
payload.update(
{
"job_type": "jellyfin_handoff",
"title": "Jellyfin handoff",
"anime_name": "Jellyfin handoff",
"mode": "copy",
"quality": "library",
"episodes": f"{int(job.get('completed') or 0)}/{int(job.get('total') or 0)} entries",
"download_dir": str(config.get("download_dir") or ""),
"target_dir": ", ".join(target_paths),
"exit_code": 1 if job.get("status") == "failed" else None,
"log": [line for line in log_lines if line],
}
)
return payload
def list_queue_jobs(self, limit=50):
limit = min(200, max(1, int(limit or 50)))
with self.lock, self._connect() as conn:
rows = conn.execute(
"SELECT payload FROM jellyfin_sync_jobs ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
return [self._queue_job_payload(self._job_from_row(row)) for row in rows]
def get_queue_job(self, job_id):
with self.lock, self._connect() as conn:
row = conn.execute("SELECT payload FROM jellyfin_sync_jobs WHERE id = ?", (job_id,)).fetchone()
if row:
return self._queue_job_payload(self._job_from_row(row))
raise KeyError("Job not found")
def remove_queue_job(self, job_id):
with self.lock:
job = self.get_queue_job(job_id)
if job["status"] in {"pending", "running"}:
raise ValueError("Running Jellyfin handoff jobs cannot be removed")
with self._connect() as conn:
conn.execute("DELETE FROM jellyfin_sync_jobs WHERE id = ?", (job_id,))
return {"ok": True}
def clear_finished_queue_jobs(self):
with self.lock, self._connect() as conn:
cursor = conn.execute("DELETE FROM jellyfin_sync_jobs WHERE status IN ('done', 'interrupted')")
return {"ok": True, "count": cursor.rowcount}
def clear_failed_queue_jobs(self):
with self.lock, self._connect() as conn:
cursor = conn.execute("DELETE FROM jellyfin_sync_jobs WHERE status = 'failed'")
return {"ok": True, "count": cursor.rowcount}
def _find_locked(self, job_id): def _find_locked(self, job_id):
for job in self.jobs: for job in self.jobs:
if job["id"] == job_id: if job["id"] == job_id:
@@ -1136,6 +1213,51 @@ class DownloadQueue:
with self.lock: with self.lock:
return self._find(job_id) return self._find(job_id)
def _job_identity_fields(self, job):
return (
str(job.get("show_id") or "").strip(),
str(job.get("title") or "").strip(),
str(job.get("anime_name") or "").strip(),
str(job.get("media_type") or "").strip(),
str(job.get("season") or "").strip(),
str(job.get("episode_offset") or "").strip(),
str(job.get("query") or "").strip(),
job.get("result_index"),
str(job.get("mode") or "").strip().lower(),
str(job.get("quality") or "").strip().lower(),
str(job.get("episodes") or "").strip(),
str(job.get("download_dir") or "").strip(),
)
def _find_reusable_failed_job_locked(self, job):
expected = self._job_identity_fields(job)
with self._connect() as conn:
rows = conn.execute(
"""
SELECT *
FROM jobs
WHERE status = 'failed'
ORDER BY created_at DESC
"""
).fetchall()
for row in rows:
candidate = self._job_from_row(row)
if self._job_identity_fields(candidate) == expected:
return candidate
return None
def _reset_retryable_job_locked(self, job):
job["status"] = "pending"
job["exit_code"] = None
job["pid"] = None
job["started_at"] = None
job["finished_at"] = None
job["updated_at"] = now_iso()
job["cancel_requested"] = False
job["log"] = []
self._save_job_locked(job)
return job
def add(self, payload): def add(self, payload):
job = build_job(payload, self.config_getter()) job = build_job(payload, self.config_getter())
if self.job_prepare_fn is not None: if self.job_prepare_fn is not None:
@@ -1143,6 +1265,10 @@ class DownloadQueue:
if prepared is not None: if prepared is not None:
job = prepared job = prepared
with self.lock: with self.lock:
reusable = self._find_reusable_failed_job_locked(job)
if reusable is not None:
job = self._reset_retryable_job_locked(reusable)
else:
self._save_job_locked(job) self._save_job_locked(job)
self.wakeup.set() self.wakeup.set()
return job return job
@@ -1221,15 +1347,7 @@ class DownloadQueue:
raise ValueError("Running jobs cannot be retried") raise ValueError("Running jobs cannot be retried")
if job["status"] not in {"failed", "canceled"}: if job["status"] not in {"failed", "canceled"}:
raise ValueError("Only failed or canceled jobs can be retried") raise ValueError("Only failed or canceled jobs can be retried")
job["status"] = "pending" job = self._reset_retryable_job_locked(job)
job["exit_code"] = None
job["pid"] = None
job["started_at"] = None
job["finished_at"] = None
job["updated_at"] = now_iso()
job["cancel_requested"] = False
job["log"] = []
self._save_job_locked(job)
self.wakeup.set() self.wakeup.set()
return job return job
@@ -1291,6 +1409,11 @@ class DownloadQueue:
cursor = conn.execute("DELETE FROM jobs WHERE status IN ('done', 'canceled')") cursor = conn.execute("DELETE FROM jobs WHERE status IN ('done', 'canceled')")
return {"ok": True, "count": cursor.rowcount} return {"ok": True, "count": cursor.rowcount}
def clear_failed(self):
with self.lock, self._connect() as conn:
cursor = conn.execute("DELETE FROM jobs WHERE status = 'failed'")
return {"ok": True, "count": cursor.rowcount}
def _find(self, job_id): def _find(self, job_id):
with self._connect() as conn: with self._connect() as conn:
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
@@ -1312,6 +1435,7 @@ class DownloadQueue:
text = strip_control(line).strip() text = strip_control(line).strip()
if not text: if not text:
return return
self._stdout_log(job, text)
with self.lock: with self.lock:
job.setdefault("log", []).append(text) job.setdefault("log", []).append(text)
job["log"] = job["log"][-MAX_LOG_LINES:] job["log"] = job["log"][-MAX_LOG_LINES:]
@@ -1325,6 +1449,16 @@ class DownloadQueue:
if should_flush: if should_flush:
self._save_job_locked(job) self._save_job_locked(job)
def _stdout_log(self, job, line):
if not job_stdout_enabled():
return
text = strip_control(str(line)).strip()
if not text:
return
job_id = str((job or {}).get("id") or "-")
backend = str((job or {}).get("download_backend") or "download")
print(f"[download:{job_id}:{backend}] {text}", flush=True)
def _cleanup_staging_dir(self, job): def _cleanup_staging_dir(self, job):
staging_dir = job_staging_dir(job) staging_dir = job_staging_dir(job)
if not staging_dir.exists(): if not staging_dir.exists():
@@ -1376,34 +1510,47 @@ class DownloadQueue:
self._cleanup_staging_dir(job) self._cleanup_staging_dir(job)
def _command_attempts(self, job, staging_dir): def _command_attempts(self, job, staging_dir):
config = normalize_config(self.config_getter() or {}) def attempt_for(method):
attempts = [ if method == "ani-cli":
{ return {
"backend": "ani-cli", "backend": "ani-cli",
"command": command_for_job(job, backend="ani-cli"), "command": command_for_job(job, backend="ani-cli"),
"env": { "env": {
**os.environ.copy(), **os.environ.copy(),
"ANI_CLI_DOWNLOAD_DIR": str(staging_dir), "ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
"ANI_CLI_PLAYER": "download",
"ANI_CLI_MODE": job["mode"], "ANI_CLI_MODE": job["mode"],
"ANI_CLI_QUALITY": job["quality"], "ANI_CLI_QUALITY": job["quality"],
"TERM": os.environ.get("TERM", "xterm-256color"), "TERM": os.environ.get("TERM", "xterm-256color"),
}, },
} }
] return {
fallback_enabled = bool(config.get("anipy_cli_fallback_enabled")) "backend": method,
fallback_available = cli_executable_exists(ANIPY_CLI) "command": command_for_job(job, backend=method, download_path=staging_dir),
if fallback_enabled and fallback_available:
attempts.append(
{
"backend": "anipy-cli",
"command": command_for_job(job, backend="anipy-cli", download_path=staging_dir),
"env": { "env": {
**os.environ.copy(), **os.environ.copy(),
"TERM": os.environ.get("TERM", "xterm-256color"), "TERM": os.environ.get("TERM", "xterm-256color"),
}, },
} }
)
return attempts, fallback_enabled, fallback_available config = normalize_config(self.config_getter() or {})
selected_methods = [
method
for method in config.get("download_methods") or ["ani-cli"]
if method in DOWNLOAD_METHOD_CHOICES
]
if not selected_methods:
selected_methods = ["ani-cli"]
attempts = []
unavailable_methods = []
for method in selected_methods:
if not cli_executable_exists(DOWNLOAD_BACKEND_COMMANDS.get(method)):
unavailable_methods.append(method)
continue
attempts.append(attempt_for(method))
if not attempts and selected_methods == ["ani-cli"]:
attempts.append(attempt_for("ani-cli"))
return attempts, selected_methods, unavailable_methods
def _run(self): def _run(self):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@@ -1419,7 +1566,34 @@ class DownloadQueue:
target_dir = job_output_dir(job) target_dir = job_output_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True) staging_dir.mkdir(parents=True, exist_ok=True)
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
attempts, fallback_enabled, fallback_available = self._command_attempts(job, staging_dir) attempts, selected_methods, unavailable_methods = self._command_attempts(job, staging_dir)
if not attempts:
with self.lock:
job["status"] = "failed"
job["started_at"] = now_iso()
job["finished_at"] = job["started_at"]
job["updated_at"] = job["finished_at"]
job["exit_code"] = 1
job["pid"] = None
job["staging_dir"] = str(staging_dir)
job["target_dir"] = str(target_dir)
job["download_backend"] = selected_methods[0] if selected_methods else None
job["fallback_used"] = False
job["log"] = [
f"Staging in: {job['staging_dir']}",
f"Final folder: {job['target_dir']}",
f"Download methods: {', '.join(selected_methods)}",
(
"No selected download methods are installed or executable: "
f"{', '.join(unavailable_methods or selected_methods)}."
),
]
self.current_process = None
self.current_job_id = None
self.current_job = None
self._save_job_locked(job)
self._cleanup_staging_dir(job)
return
primary_command = attempts[0]["command"] primary_command = attempts[0]["command"]
with self.lock: with self.lock:
@@ -1429,17 +1603,21 @@ class DownloadQueue:
job["command"] = printable_command(primary_command) job["command"] = printable_command(primary_command)
job["staging_dir"] = str(staging_dir) job["staging_dir"] = str(staging_dir)
job["target_dir"] = str(target_dir) job["target_dir"] = str(target_dir)
job["download_backend"] = "ani-cli" job["download_backend"] = attempts[0]["backend"]
job["fallback_used"] = False job["fallback_used"] = False
job["log"] = [ job["log"] = [
f"Starting: {job['command']}", f"Starting: {job['command']}",
f"Staging in: {job['staging_dir']}", f"Staging in: {job['staging_dir']}",
f"Final folder: {job['target_dir']}", f"Final folder: {job['target_dir']}",
f"Download methods: {', '.join(selected_methods)}",
] ]
if fallback_enabled and fallback_available: if len(attempts) > 1:
job["log"].append("anipy-cli fallback is enabled and will retry automatically if ani-cli fails.") job["log"].append("Fallback retry is enabled for the selected download methods.")
elif fallback_enabled: if unavailable_methods:
job["log"].append("anipy-cli fallback is enabled, but anipy-cli is not installed or not executable.") job["log"].append(
"Skipped unavailable download method"
f"{'s' if len(unavailable_methods) != 1 else ''}: {', '.join(unavailable_methods)}."
)
job["cancel_requested"] = False job["cancel_requested"] = False
job["_dirty_log_lines"] = 0 job["_dirty_log_lines"] = 0
self._save_job_locked(job) self._save_job_locked(job)
@@ -1479,8 +1657,8 @@ class DownloadQueue:
) )
except Exception as exc: except Exception as exc:
if should_fallback: if should_fallback:
self._append_log(job, f"ani-cli could not start: {exc}") self._append_log(job, f"{backend} could not start: {exc}")
self._append_log(job, "Retrying with anipy-cli fallback.") self._append_log(job, f"Retrying with {attempts[index + 1]['backend']} fallback.")
self._cleanup_staging_dir(job) self._cleanup_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True) staging_dir.mkdir(parents=True, exist_ok=True)
continue continue
@@ -1507,9 +1685,9 @@ class DownloadQueue:
if job.get("cancel_requested"): if job.get("cancel_requested"):
break break
if backend == "ani-cli" and exit_code != 0 and should_fallback: if exit_code != 0 and should_fallback:
self._append_log(job, f"ani-cli failed with exit code {exit_code}.") self._append_log(job, f"{backend} failed with exit code {exit_code}.")
self._append_log(job, "Retrying with anipy-cli fallback.") self._append_log(job, f"Retrying with {attempts[index + 1]['backend']} fallback.")
self._cleanup_staging_dir(job) self._cleanup_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True) staging_dir.mkdir(parents=True, exist_ok=True)
continue continue
@@ -1520,10 +1698,10 @@ class DownloadQueue:
except Exception as exc: except Exception as exc:
finalize_error = exc finalize_error = exc
no_files_downloaded = "No downloaded files were found in the staging folder" in str(exc) no_files_downloaded = "No downloaded files were found in the staging folder" in str(exc)
if backend == "ani-cli" and no_files_downloaded and should_fallback: if no_files_downloaded and should_fallback:
self._append_log( self._append_log(
job, job,
"ani-cli exited without downloading any files. Retrying with anipy-cli fallback.", f"{backend} exited without downloading any files. Retrying with {attempts[index + 1]['backend']} fallback.",
) )
self._cleanup_staging_dir(job) self._cleanup_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True) staging_dir.mkdir(parents=True, exist_ok=True)
@@ -1560,8 +1738,8 @@ class DownloadQueue:
cleanup_staging = True cleanup_staging = True
elif exit_code == 0: elif exit_code == 0:
job["status"] = "done" job["status"] = "done"
if successful_backend == "anipy-cli": if successful_backend and successful_backend != attempts[0]["backend"]:
job.setdefault("log", []).append("Fallback download completed with anipy-cli.") job.setdefault("log", []).append(f"Fallback download completed with {successful_backend}.")
for path in moved_files: for path in moved_files:
job.setdefault("log", []).append(f"Saved: {path}") job.setdefault("log", []).append(f"Saved: {path}")
job.setdefault("log", []).append("Download completed.") job.setdefault("log", []).append("Download completed.")
+18 -2
View File
@@ -11,6 +11,7 @@ QUEUE_HTML = r"""<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ani-cli web queue</title> <title>ani-cli web queue</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;
@@ -317,6 +318,7 @@ QUEUE_HTML = r"""<!doctype html>
<p class="muted">Monitor active downloads, inspect recent output, and clean up finished jobs without leaving the queue view.</p> <p class="muted">Monitor active downloads, inspect recent output, and clean up finished jobs without leaving the queue view.</p>
<div class="row"> <div class="row">
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button> <button class="ghost" id="retryFailedBtn" type="button">Retry failed</button>
<button class="ghost" id="clearFailedBtn" type="button">Clear failed</button>
<button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button> <button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
</div> </div>
</section> </section>
@@ -388,6 +390,13 @@ QUEUE_HTML = r"""<!doctype html>
} }
function buildJobSummary(job) { function buildJobSummary(job) {
if (job.job_type === "jellyfin_handoff") {
const progress = `${job.completed || 0}/${job.total || 0} entries`;
const moved = `${job.moved || 0} moved`;
const files = `${job.moved_files || 0} files`;
const target = job.target_dir || "Jellyfin libraries";
return `${progress} · ${moved} · ${files} · ${target}`;
}
const libraryName = job.anime_name || job.title; const libraryName = job.anime_name || job.title;
const season = String(job.season || "1").padStart(2, "0"); const season = String(job.season || "1").padStart(2, "0");
const seasonFolder = `Season ${season}`; const seasonFolder = `Season ${season}`;
@@ -401,6 +410,12 @@ QUEUE_HTML = r"""<!doctype html>
function renderJobActions(job, actions) { function renderJobActions(job, actions) {
actions.innerHTML = ""; actions.innerHTML = "";
if (job.job_type === "jellyfin_handoff") {
if (job.status !== "running" && job.status !== "pending") {
actions.append(actionButton("Remove", () => queueAction(job.id, "remove")));
}
return;
}
if (job.status === "running" || job.status === "pending") { if (job.status === "running" || job.status === "pending") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger")); actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
return; return;
@@ -414,14 +429,14 @@ QUEUE_HTML = r"""<!doctype html>
function updateJobCard(item, job) { function updateJobCard(item, job) {
item.dataset.jobId = job.id; item.dataset.jobId = job.id;
item.dataset.jobStatus = job.status; item.dataset.jobStatus = job.status;
item.querySelector(".job-title").textContent = job.title; item.querySelector(".job-title").textContent = job.title || "Untitled job";
item.querySelector(".job-head .muted").textContent = buildJobSummary(job); item.querySelector(".job-head .muted").textContent = buildJobSummary(job);
const status = item.querySelector(".status"); const status = item.querySelector(".status");
status.textContent = job.status; status.textContent = job.status;
status.className = "status"; status.className = "status";
status.classList.add(job.status); status.classList.add(job.status);
item.querySelector("pre").textContent = buildJobLog(job) || "Waiting..."; item.querySelector("pre").textContent = buildJobLog(job) || "Waiting...";
item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`; item.querySelector(".toolbar .muted").textContent = job.exit_code === null || job.exit_code === undefined ? "" : `exit ${job.exit_code}`;
renderJobActions(job, item.querySelector(".row")); renderJobActions(job, item.querySelector(".row"));
state.jobCards[job.id] = item; state.jobCards[job.id] = item;
} }
@@ -548,6 +563,7 @@ QUEUE_HTML = r"""<!doctype html>
} }
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished and canceled jobs")); $("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished and canceled jobs"));
$("clearFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-failed", "Removed failed jobs"));
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs")); $("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
(async function init() { (async function init() {
+2
View File
@@ -11,6 +11,7 @@ INDEX_HTML = r"""<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ani-cli web</title> <title>ani-cli web</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;
@@ -687,6 +688,7 @@ INDEX_HTML = r"""<!doctype html>
const payload = { const payload = {
show_id: state.selected.id, show_id: state.selected.id,
query: state.selected.title, query: state.selected.title,
result_index: state.selected.index,
title: state.selected.title, title: state.selected.title,
anime_name: $("animeNameInput").value || state.selected.title, anime_name: $("animeNameInput").value || state.selected.title,
media_type: $("trackMediaType").value, media_type: $("trackMediaType").value,
+671 -5
View File
@@ -72,6 +72,12 @@ class DummyHandler:
self.json_payload = payload self.json_payload = payload
self.json_status = status self.json_status = status
def json_attachment(self, payload, filename, status=HTTPStatus.OK):
return APP.Handler.json_attachment(self, payload, filename, status=status)
def write_response_bytes(self, payload, status, headers):
return APP.Handler.write_response_bytes(self, payload, status, headers)
def error(self, status, message): def error(self, status, message):
self.error_status = status self.error_status = status
self.error_message = message self.error_message = message
@@ -176,6 +182,14 @@ class NetworkGuardTests(unittest.TestCase):
self.assertIn("Username", body) self.assertIn("Username", body)
self.assertIn("Password", body) self.assertIn("Password", body)
def test_favicon_route_serves_checked_in_png(self):
handler = DummyHandler("/favicon.png")
handler.command = "GET"
APP.Handler.do_GET(handler)
self.assertEqual(handler.file_path, http_handler.FAVICON_PATH)
def test_remote_login_page_escapes_hidden_next_value(self): def test_remote_login_page_escapes_hidden_next_value(self):
handler = DummyHandler('/?next="><script>alert(1)</script>') handler = DummyHandler('/?next="><script>alert(1)</script>')
handler.client_address = ("192.168.1.25", 8421) handler.client_address = ("192.168.1.25", 8421)
@@ -373,6 +387,21 @@ class DownloadQueueCancelTests(unittest.TestCase):
self.assertEqual(len(saved), 1) self.assertEqual(len(saved), 1)
self.assertEqual(saved[0][-1], "line flush") self.assertEqual(saved[0][-1], "line flush")
def test_append_log_can_mirror_download_output_to_stdout(self):
queue = object.__new__(APP.DownloadQueue)
queue.lock = threading.RLock()
queue._save_job_locked = lambda _job: None
job = {"id": "job-1", "download_backend": "animdl", "log": [], "updated_at": APP.now_iso()}
with mock.patch.dict(os.environ, {"ANI_CLI_WEB_JOB_STDOUT": "true"}, clear=False), mock.patch("builtins.print") as print_mock:
APP.DownloadQueue._append_log(queue, job, "\x1b[32mDownloading episode 1\x1b[0m\n")
print_mock.assert_called_once_with(
"[download:job-1:animdl] Downloading episode 1",
flush=True,
)
self.assertEqual(job["log"], ["Downloading episode 1"])
class QueueApiTests(unittest.TestCase): class QueueApiTests(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -381,6 +410,7 @@ class QueueApiTests(unittest.TestCase):
self.queue = APP.DOWNLOAD_QUEUE self.queue = APP.DOWNLOAD_QUEUE
with self.queue._connect() as conn: with self.queue._connect() as conn:
conn.execute("DELETE FROM jobs") conn.execute("DELETE FROM jobs")
conn.execute("DELETE FROM jellyfin_sync_jobs")
def test_download_queue_get_returns_single_job(self): def test_download_queue_get_returns_single_job(self):
created = self.queue.add( created = self.queue.add(
@@ -421,6 +451,42 @@ class QueueApiTests(unittest.TestCase):
self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["id"], created["id"]) self.assertEqual(handler.json_payload["id"], created["id"])
def test_queue_route_includes_jellyfin_handoff_jobs(self):
APP.WATCHLIST_JELLYFIN_SYNC.start(
config={
"download_dir": "/tmp/downloads",
"jellyfin_sync_enabled": True,
"jellyfin_tv_dir": "/tmp/jellyfin-tv",
"jellyfin_movie_dir": "/tmp/jellyfin-movies",
}
)
handler = DummyHandler("/api/queue?page=1&per_page=10")
APP.Handler.do_GET(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
jellyfin_jobs = [job for job in handler.json_payload["jobs"] if job.get("job_type") == "jellyfin_handoff"]
self.assertEqual(len(jellyfin_jobs), 1)
self.assertEqual(jellyfin_jobs[0]["title"], "Jellyfin handoff")
self.assertEqual(jellyfin_jobs[0]["mode"], "copy")
def test_queue_item_get_route_returns_jellyfin_handoff_job(self):
created = APP.WATCHLIST_JELLYFIN_SYNC.start(
config={
"download_dir": "/tmp/downloads",
"jellyfin_sync_enabled": True,
"jellyfin_tv_dir": "/tmp/jellyfin-tv",
"jellyfin_movie_dir": "",
}
)["job"]
handler = DummyHandler(f"/api/queue/{created['id']}")
APP.Handler.do_GET(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["id"], created["id"])
self.assertEqual(handler.json_payload["job_type"], "jellyfin_handoff")
def test_retry_rejects_done_jobs(self): def test_retry_rejects_done_jobs(self):
created = self.queue.add( created = self.queue.add(
{ {
@@ -466,6 +532,121 @@ class QueueApiTests(unittest.TestCase):
self.assertEqual(retried["status"], "pending") self.assertEqual(retried["status"], "pending")
self.assertFalse(retried["cancel_requested"]) self.assertFalse(retried["cancel_requested"])
def test_add_reuses_matching_failed_job_instead_of_creating_duplicate(self):
payload = {
"show_id": "show-42",
"query": "Failed Show",
"title": "Failed Show",
"anime_name": "Failed Show",
"media_type": "tv",
"mode": "dub",
"quality": "best",
"episodes": "11",
"download_dir": "/tmp/example",
"season": "2",
"episode_offset": "13",
}
created = self.queue.add(payload)
with self.queue.lock:
job = self.queue._find(created["id"])
job["status"] = "failed"
job["exit_code"] = 1
job["cancel_requested"] = True
job["started_at"] = APP.now_iso()
job["finished_at"] = APP.now_iso()
job["log"] = ["Download failed"]
self.queue._save_job_locked(job)
retried = self.queue.add(payload)
listing = self.queue.list(per_page=10)
self.assertEqual(retried["id"], created["id"])
self.assertEqual(retried["status"], "pending")
self.assertEqual(retried["log"], [])
self.assertIsNone(retried["exit_code"])
self.assertFalse(retried["cancel_requested"])
self.assertEqual(listing["total"], 1)
def test_add_keeps_new_job_when_failed_job_differs(self):
created = self.queue.add(
{
"show_id": "show-42",
"query": "Failed Show",
"title": "Failed Show",
"anime_name": "Failed Show",
"media_type": "tv",
"mode": "dub",
"quality": "best",
"episodes": "11",
"download_dir": "/tmp/example",
"season": "2",
"episode_offset": "13",
}
)
with self.queue.lock:
job = self.queue._find(created["id"])
job["status"] = "failed"
self.queue._save_job_locked(job)
new_job = self.queue.add(
{
"show_id": "show-42",
"query": "Failed Show",
"title": "Failed Show",
"anime_name": "Failed Show",
"media_type": "tv",
"mode": "dub",
"quality": "720",
"episodes": "11",
"download_dir": "/tmp/example",
"season": "2",
"episode_offset": "13",
}
)
listing = self.queue.list(per_page=10)
self.assertNotEqual(new_job["id"], created["id"])
self.assertEqual(listing["total"], 2)
def test_clear_failed_removes_only_failed_jobs(self):
failed = self.queue.add(
{
"query": "Failed Show",
"title": "Failed Show",
"anime_name": "Failed Show",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"season": "1",
}
)
done = self.queue.add(
{
"query": "Done Show",
"title": "Done Show",
"anime_name": "Done Show",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"season": "1",
}
)
with self.queue.lock:
failed_job = self.queue._find(failed["id"])
failed_job["status"] = "failed"
self.queue._save_job_locked(failed_job)
done_job = self.queue._find(done["id"])
done_job["status"] = "done"
self.queue._save_job_locked(done_job)
result = self.queue.clear_failed()
listing = self.queue.list()
self.assertEqual(result["count"], 1)
self.assertEqual([job["id"] for job in listing["jobs"]], [done["id"]])
def test_detach_show_clears_queue_coverage_and_sync_binding(self): def test_detach_show_clears_queue_coverage_and_sync_binding(self):
created = self.queue.add( created = self.queue.add(
{ {
@@ -619,6 +800,36 @@ class QueueApiTests(unittest.TestCase):
) )
self.assertTrue(Path(moved[0]).exists()) 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()
@@ -846,6 +1057,79 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
self.assertEqual(commands[1][0], APP.app_support.ANIPY_CLI) self.assertEqual(commands[1][0], APP.app_support.ANIPY_CLI)
self.assertIn("Fallback download completed with anipy-cli.", stored["log"]) self.assertIn("Fallback download completed with anipy-cli.", stored["log"])
def test_selected_unavailable_animdl_fails_without_start_attempt(self):
queue = APP.DownloadQueue(
lambda: {
"mode": "sub",
"quality": "best",
"download_dir": "/tmp/example",
"download_methods": ["animdl"],
},
start_worker=False,
)
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
staging_dir = queue_jobs.job_staging_dir(job)
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=False), mock.patch.object(
queue_jobs.subprocess, "Popen"
) as popen:
queue._run_job(job)
stored = queue._find(job["id"])
self.assertEqual(stored["status"], "failed")
self.assertEqual(stored["download_backend"], "animdl")
self.assertIn("No selected download methods are installed or executable: animdl.", stored["log"])
self.assertFalse(staging_dir.exists())
popen.assert_not_called()
def test_selected_download_methods_retry_through_animdl(self):
queue = APP.DownloadQueue(
lambda: {
"mode": "sub",
"quality": "best",
"download_dir": "/tmp/example",
"download_methods": ["ani-cli", "anipy-cli", "animdl"],
},
start_worker=False,
)
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
commands = []
envs = []
class FakeProc:
def __init__(self, pid, stdout, exit_code):
self.pid = pid
self.stdout = stdout
self._exit_code = exit_code
def wait(self):
return self._exit_code
processes = [
FakeProc(4321, ["ani-cli attempt\n"], 1),
FakeProc(5432, ["anipy-cli attempt\n"], 1),
FakeProc(6543, ["animdl attempt\n"], 0),
]
def fake_popen(command, **kwargs):
commands.append(command)
envs.append(kwargs.get("env", {}))
return processes.pop(0)
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object(
queue_jobs.subprocess, "Popen", side_effect=fake_popen
), mock.patch.object(
queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"]
):
queue._run_job(job)
stored = queue._find(job["id"])
self.assertEqual(stored["status"], "done")
self.assertTrue(stored["fallback_used"])
self.assertEqual([command[0] for command in commands], [APP.app_support.ANI_CLI, APP.app_support.ANIPY_CLI, APP.app_support.ANIMDL])
self.assertEqual(envs[0]["ANI_CLI_PLAYER"], "download")
self.assertIn("Fallback download completed with animdl.", stored["log"])
class WatchlistRefreshAllTests(unittest.TestCase): class WatchlistRefreshAllTests(unittest.TestCase):
def test_refresh_all_iterates_only_watching_and_planned_show_ids(self): def test_refresh_all_iterates_only_watching_and_planned_show_ids(self):
@@ -1659,7 +1943,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_ignores_legacy_result_index(self): def test_command_for_job_includes_result_switch_for_selected_search_result(self):
command = APP.app_support.command_for_job( command = APP.app_support.command_for_job(
{ {
"query": "Queue Show", "query": "Queue Show",
@@ -1670,6 +1954,20 @@ class WatchlistCompletionTests(unittest.TestCase):
} }
) )
self.assertIn("-S", command)
self.assertEqual(command[command.index("-S") + 1], "2")
def test_command_for_job_ignores_invalid_result_index(self):
command = APP.app_support.command_for_job(
{
"query": "Queue Show",
"quality": "best",
"episodes": "1-10",
"mode": "dub",
"result_index": "nope",
}
)
self.assertNotIn("-S", command) self.assertNotIn("-S", command)
def test_command_for_job_builds_anipy_fallback_search_spec(self): def test_command_for_job_builds_anipy_fallback_search_spec(self):
@@ -1689,6 +1987,25 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertIn("-l", command) self.assertIn("-l", command)
self.assertEqual(command[-1], "/tmp/anipy-staging") 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",
@@ -1994,6 +2311,189 @@ class JellyfinSyncTests(unittest.TestCase):
self.assertIn("title mismatch", str(ctx.exception).lower()) self.assertIn("title mismatch", str(ctx.exception).lower())
class WatchlistFilesystemReconcileTests(unittest.TestCase):
def setUp(self):
with APP.WATCHLIST._connect() as conn:
conn.execute("DELETE FROM watchlist")
def seed_watchlist_item(self, **overrides):
now = APP.now_iso()
item = {
"show_id": "show-reconcile-1",
"title": "Reconcile Show",
"category": "watching",
"downloaded": True,
"status": "updated",
"status_message": "Tracked.",
"created_at": now,
"updated_at": now,
"last_checked": now,
"sub_count": 12,
"dub_count": 12,
"expected_count": 12,
"airing_status": "Finished",
"sub_latest_episode": "12",
"dub_latest_episode": "12",
"animeschedule_route": None,
"animeschedule_title": None,
"anidb_aid": None,
"anidb_title": None,
"media_type": "tv",
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": "Reconcile Show",
"auto_download_source_name": "Reconcile Show",
"auto_download_series": "2",
"auto_download_offset": "13",
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "3"]),
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
item.update(overrides)
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
APP.WATCHLIST._upsert_conn(conn, item)
def test_reconcile_watchlist_downloaded_files_updates_episode_sets_from_filesystem(self):
self.seed_watchlist_item()
with tempfile.TemporaryDirectory() as temp_root:
season_dir = Path(temp_root) / "tv" / "Reconcile Show" / "Season 02"
season_dir.mkdir(parents=True, exist_ok=True)
(season_dir / "Reconcile Show - S02E13.mp4").write_bytes(b"one")
(season_dir / "Reconcile Show - S02E14.mkv").write_bytes(b"two")
result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"})
self.assertEqual(result["changed"], 1)
item = APP.WATCHLIST.get("show-reconcile-1")
self.assertTrue(item["downloaded"])
self.assertEqual(item["downloaded_dub_episodes"], ["1", "2"])
self.assertEqual(item["downloaded_sub_episodes"], [])
def test_reconcile_watchlist_downloaded_files_clears_missing_files(self):
self.seed_watchlist_item(
downloaded_sub_episodes_json=APP.encode_episode_values(["1"]),
downloaded_dub_episodes_json=APP.encode_episode_values(["1", "2"]),
)
result = APP.reconcile_watchlist_downloaded_files({"download_dir": "/tmp/missing-download-root", "mode": "sub", "quality": "best"})
self.assertEqual(result["changed"], 1)
item = APP.WATCHLIST.get("show-reconcile-1")
self.assertFalse(item["downloaded"])
self.assertEqual(item["downloaded_sub_episodes"], [])
self.assertEqual(item["downloaded_dub_episodes"], [])
def test_reconcile_watchlist_downloaded_files_marks_movie_present_when_file_exists(self):
self.seed_watchlist_item(
show_id="show-movie-1",
title="Movie Reconcile",
media_type="movie",
downloaded=False,
auto_download_name="Movie Reconcile",
auto_download_series="1",
auto_download_offset=None,
downloaded_sub_episodes_json=None,
downloaded_dub_episodes_json=None,
)
with tempfile.TemporaryDirectory() as temp_root:
movie_dir = Path(temp_root) / "movie" / "Movie Reconcile"
movie_dir.mkdir(parents=True, exist_ok=True)
(movie_dir / "Movie Reconcile.mp4").write_bytes(b"movie")
result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"})
self.assertEqual(result["changed"], 1)
item = APP.WATCHLIST.get("show-movie-1")
self.assertTrue(item["downloaded"])
class WatchlistBackupTests(unittest.TestCase):
def setUp(self):
with APP.WATCHLIST._connect() as conn:
conn.execute("DELETE FROM watchlist")
conn.execute("DELETE FROM watchlist_removals")
def seed_watchlist_item(self, **overrides):
now = APP.now_iso()
item = {
"show_id": "show-backup-1",
"title": "Backup Show",
"category": "finished",
"downloaded": True,
"status": "updated",
"status_message": "Ready.",
"created_at": now,
"updated_at": now,
"last_checked": now,
"sub_count": 2,
"dub_count": 2,
"expected_count": 2,
"airing_status": "Finished",
"sub_latest_episode": "2",
"dub_latest_episode": "2",
"animeschedule_route": "backup-route",
"animeschedule_title": "Backup Schedule",
"anidb_aid": "123",
"anidb_title": "Backup AniDB",
"media_type": "tv",
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": "Backup Show",
"auto_download_source_name": "Backup Search",
"auto_download_series": "1",
"auto_download_offset": None,
"downloaded_sub_episodes_json": APP.encode_episode_values(["1"]),
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "2"]),
"thumbnail_path": "show-backup-1.jpg",
"thumbnail_checked_at": now,
}
item.update(overrides)
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
APP.WATCHLIST._upsert_conn(conn, item)
def test_export_watchlist_backup_includes_raw_watchlist_and_removal_rows(self):
self.seed_watchlist_item()
with APP.WATCHLIST._connect() as conn:
conn.execute(
"INSERT INTO watchlist_removals (show_id, title, removed_at) VALUES (?, ?, ?)",
("removed-show", "Removed Show", APP.now_iso()),
)
backup = APP.export_watchlist_backup()
self.assertEqual(backup["format"], "ani-cli-web-watchlist-backup")
self.assertEqual(backup["format_version"], 1)
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["show_id"], "show-backup-1")
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["downloaded_dub_episodes_json"], APP.encode_episode_values(["1", "2"]))
self.assertEqual(backup["tables"]["watchlist_removals"]["rows"][0]["show_id"], "removed-show")
def test_import_watchlist_backup_replaces_existing_watchlist_tables(self):
self.seed_watchlist_item(show_id="old-show", title="Old Show")
backup = APP.export_watchlist_backup()
backup["tables"]["watchlist"]["rows"][0]["show_id"] = "new-show"
backup["tables"]["watchlist"]["rows"][0]["title"] = "New Show"
backup["tables"]["watchlist_removals"]["rows"] = [
{"show_id": "removed-new", "title": "Removed New", "removed_at": APP.now_iso()}
]
result = APP.import_watchlist_backup(backup)
self.assertEqual(result["imported"], 1)
with self.assertRaises(KeyError):
APP.WATCHLIST.get("old-show")
restored = APP.WATCHLIST.get("new-show")
self.assertEqual(restored["title"], "New Show")
self.assertEqual(restored["downloaded_dub_episodes"], ["1", "2"])
with APP.WATCHLIST._connect() as conn:
removal = conn.execute("SELECT title FROM watchlist_removals WHERE show_id = ?", ("removed-new",)).fetchone()
self.assertEqual(removal["title"], "Removed New")
def test_import_watchlist_backup_rejects_unrecognized_format(self):
with self.assertRaises(ValueError):
APP.import_watchlist_backup({"format": "not-this-app", "format_version": 1, "tables": {}})
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase): class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
def setUp(self): def setUp(self):
with APP.WATCHLIST._connect() as conn: with APP.WATCHLIST._connect() as conn:
@@ -2170,6 +2670,7 @@ class ConfigSnapshotTests(unittest.TestCase):
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.assertFalse(config["anipy_cli_fallback_enabled"])
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")
@@ -2195,6 +2696,7 @@ class ConfigSnapshotTests(unittest.TestCase):
} }
) )
self.assertTrue(config["anipy_cli_fallback_enabled"]) self.assertTrue(config["anipy_cli_fallback_enabled"])
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)
@@ -2205,6 +2707,16 @@ class ConfigSnapshotTests(unittest.TestCase):
self.assertIn("jellyfin", config["jellyfin_tv_dir"]) self.assertIn("jellyfin", config["jellyfin_tv_dir"])
self.assertIn("jellyfin", config["jellyfin_movie_dir"]) self.assertIn("jellyfin", config["jellyfin_movie_dir"])
def test_normalize_config_filters_download_methods(self):
config = APP.normalize_config(
{
"download_methods": ["animdl", "bad-method", "anipy-cli", "animdl"],
}
)
self.assertEqual(config["download_methods"], ["animdl", "anipy-cli"])
self.assertTrue(config["anipy_cli_fallback_enabled"])
def test_normalize_config_filters_discord_webhook_events(self): def test_normalize_config_filters_discord_webhook_events(self):
config = APP.normalize_config( config = APP.normalize_config(
{ {
@@ -2792,6 +3304,7 @@ class HandlerRouteTests(unittest.TestCase):
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.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"])
self.assertEqual(handler.json_payload["download_methods"], ["ani-cli", "anipy-cli"])
self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"]) self.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)
@@ -2802,6 +3315,14 @@ class HandlerRouteTests(unittest.TestCase):
self.assertEqual(handler.json_payload["jellyfin_tv_dir"], "/tmp/jellyfin-tv") self.assertEqual(handler.json_payload["jellyfin_tv_dir"], "/tmp/jellyfin-tv")
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):
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","download_methods":["ani-cli","animdl"]}'
handler = DummyHandler("/api/config", body=body, content_length=len(body))
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["download_methods"], ["ani-cli", "animdl"])
self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"])
def test_version_route_includes_cli_versions(self): def test_version_route_includes_cli_versions(self):
handler = DummyHandler("/api/version") handler = DummyHandler("/api/version")
APP.Handler.do_GET(handler) APP.Handler.do_GET(handler)
@@ -2810,12 +3331,85 @@ class HandlerRouteTests(unittest.TestCase):
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.assertIn("ani_cli_version", handler.json_payload)
self.assertIn("anipy_cli_version", handler.json_payload) self.assertIn("anipy_cli_version", handler.json_payload)
self.assertIn("animdl_version", handler.json_payload)
def test_dependencies_route_reports_anipy_cli_status(self): def test_ani_cli_version_probe_uses_download_player(self):
http_handler.installed_ani_cli_version.cache_clear()
calls = []
def fake_run(command, **kwargs):
calls.append((command, kwargs))
return mock.Mock(stdout="ani-cli 4.9.0\n", stderr="")
try:
with mock.patch.object(http_handler.subprocess, "run", side_effect=fake_run):
self.assertEqual(http_handler.installed_ani_cli_version(), "ani-cli 4.9.0")
finally:
http_handler.installed_ani_cli_version.cache_clear()
self.assertEqual(calls[0][0], [http_handler.ANI_CLI, "--version"])
self.assertEqual(calls[0][1]["env"]["ANI_CLI_PLAYER"], "download")
def test_dependencies_route_reports_fallback_tool_status(self):
handler = DummyHandler("/api/dependencies") 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("anipy-cli", handler.json_payload) self.assertIn("anipy-cli", handler.json_payload)
self.assertIn("animdl", handler.json_payload)
def test_dependency_status_checks_docker_fallback_tool_paths(self):
with mock.patch.object(http_handler, "cli_executable_exists", return_value=True), mock.patch.object(
http_handler, "cli_executable_exists_any", return_value=True
) as exists_any:
status = http_handler.dependency_status()
self.assertTrue(status["animdl"])
exists_any.assert_any_call(
"curl_firefox135",
"curl_chrome136",
"curl_chrome116",
"curl_ff117",
)
exists_any.assert_any_call(
http_handler.ANIMDL,
"animdl",
"/usr/local/bin/animdl",
"/usr/bin/animdl",
)
exists_any.assert_any_call(
http_handler.ANIPY_CLI,
"anipy-cli",
"/usr/local/bin/anipy-cli",
"/usr/bin/anipy-cli",
)
def test_clear_failed_route_removes_failed_jobs(self):
with APP.DOWNLOAD_QUEUE._connect() as conn:
conn.execute("DELETE FROM jobs")
job = APP.DOWNLOAD_QUEUE.add(
{
"query": "Failed Route Show",
"title": "Failed Route Show",
"anime_name": "Failed Route Show",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"season": "1",
}
)
with APP.DOWNLOAD_QUEUE.lock:
stored = APP.DOWNLOAD_QUEUE._find(job["id"])
stored["status"] = "failed"
APP.DOWNLOAD_QUEUE._save_job_locked(stored)
handler = DummyHandler("/api/queue/clear-failed", body=b"{}", content_length=2)
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["count"], 1)
self.assertEqual(APP.DOWNLOAD_QUEUE.list()["total"], 0)
def test_changelog_route_returns_project_changelog_content(self): def test_changelog_route_returns_project_changelog_content(self):
handler = DummyHandler("/api/changelog") handler = DummyHandler("/api/changelog")
@@ -3277,6 +3871,59 @@ class HandlerRouteTests(unittest.TestCase):
} }
) )
def test_watchlist_reconcile_post_merges_partial_payload_with_saved_config(self):
body = b'{"download_dir":"/tmp/override"}'
handler = DummyHandler("/api/config/watchlist/reconcile-downloads", body=body, content_length=len(body))
payload = {"message": "ok", "total": 2, "changed": 1, "items": []}
handler.handler_context = mock.Mock(
get_config_snapshot=mock.Mock(
return_value={
"download_dir": "/tmp/example",
"mode": "sub",
"quality": "best",
}
),
reconcile_watchlist_downloaded_files=mock.Mock(return_value=payload),
)
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
handler.handler_context.reconcile_watchlist_downloaded_files.assert_called_once_with(
{
"download_dir": "/tmp/override",
"mode": "sub",
"quality": "best",
}
)
def test_watchlist_export_route_returns_json_attachment(self):
handler = DummyHandler("/api/config/watchlist/export")
payload = {"format": "ani-cli-web-watchlist-backup", "tables": {"watchlist": {"rows": []}}}
handler.handler_context = mock.Mock(
ensure_runtime=mock.Mock(),
runtime_state=mock.Mock(return_value={}),
export_watchlist_backup=mock.Mock(return_value=payload),
)
APP.Handler.do_GET(handler)
self.assertEqual(handler.response_status, HTTPStatus.OK)
headers = dict(handler.response_headers)
self.assertEqual(headers.get("Content-Type"), "application/json")
self.assertIn("attachment", headers.get("Content-Disposition", ""))
self.assertEqual(json.loads(handler.wfile.getvalue().decode("utf-8")), payload)
def test_watchlist_import_route_returns_import_summary(self):
body = b'{"format":"ani-cli-web-watchlist-backup","format_version":1,"tables":{"watchlist":{"rows":[]}}}'
handler = DummyHandler("/api/config/watchlist/import", body=body, content_length=len(body))
payload = {"message": "Imported 0 watchlist entries from backup.", "imported": 0, "removals": 0}
handler.handler_context = mock.Mock(import_watchlist_backup=mock.Mock(return_value=payload))
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload, payload)
handler.handler_context.import_watchlist_backup.assert_called_once()
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self): def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
handler = DummyHandler("/api/config") handler = DummyHandler("/api/config")
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object( with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
@@ -3340,6 +3987,13 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn("async function api(path, options = {})", APP.CONFIG_HTML) self.assertIn("async function api(path, options = {})", APP.CONFIG_HTML)
self.assertIn("async function api(path, options = {})", APP.WATCHLIST_HTML) self.assertIn("async function api(path, options = {})", APP.WATCHLIST_HTML)
def test_pages_include_favicon_link(self):
expected = '<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">'
self.assertIn(expected, APP.INDEX_HTML)
self.assertIn(expected, APP.QUEUE_HTML)
self.assertIn(expected, APP.CONFIG_HTML)
self.assertIn(expected, APP.WATCHLIST_HTML)
def test_config_page_places_dependencies_before_settings_grid(self): def test_config_page_places_dependencies_before_settings_grid(self):
self.assertIn('<section class="deps" id="deps"></section>', APP.CONFIG_HTML) self.assertIn('<section class="deps" id="deps"></section>', APP.CONFIG_HTML)
self.assertIn('<div class="settings-grid">', APP.CONFIG_HTML) self.assertIn('<div class="settings-grid">', APP.CONFIG_HTML)
@@ -3348,9 +4002,11 @@ class TemplateHelperTests(unittest.TestCase):
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="aniCliVersionLine"', APP.CONFIG_HTML)
self.assertIn('id="anipyCliVersionLine"', APP.CONFIG_HTML) self.assertIn('id="anipyCliVersionLine"', APP.CONFIG_HTML)
self.assertIn('id="animdlVersionLine"', APP.CONFIG_HTML)
self.assertIn('ani-cli ${data.ani_cli_version || "Unavailable"}', APP.CONFIG_HTML) self.assertIn('ani-cli ${data.ani_cli_version || "Unavailable"}', APP.CONFIG_HTML)
self.assertIn('anipy-cli ${data.anipy_cli_version || "Unavailable"}', APP.CONFIG_HTML) self.assertIn('anipy-cli ${data.anipy_cli_version || "Unavailable"}', APP.CONFIG_HTML)
self.assertIn('id="anipyCliFallbackEnabled"', 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)
@@ -3366,9 +4022,19 @@ class TemplateHelperTests(unittest.TestCase):
def test_search_page_sends_watchlist_auto_download_series(self): def test_search_page_sends_watchlist_auto_download_series(self):
self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML) self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML)
def test_search_page_queues_selected_title_without_result_index(self): def test_queue_page_includes_clear_failed_bulk_action(self):
self.assertIn('id="clearFailedBtn"', APP.QUEUE_HTML)
self.assertIn('/api/queue/clear-failed', APP.QUEUE_HTML)
self.assertIn('Removed failed jobs', APP.QUEUE_HTML)
def test_queue_page_formats_jellyfin_handoff_jobs(self):
self.assertIn('job.job_type === "jellyfin_handoff"', APP.QUEUE_HTML)
self.assertIn('const progress = `${job.completed || 0}/${job.total || 0} entries`;', APP.QUEUE_HTML)
self.assertIn('const moved = `${job.moved || 0} moved`;', APP.QUEUE_HTML)
def test_search_page_queues_selected_title_with_result_index(self):
self.assertIn('query: state.selected.title,', APP.INDEX_HTML) self.assertIn('query: state.selected.title,', APP.INDEX_HTML)
self.assertNotIn('index: state.selected.index,', APP.INDEX_HTML) self.assertIn('result_index: state.selected.index,', APP.INDEX_HTML)
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)
+1
View File
@@ -11,6 +11,7 @@ WATCHLIST_HTML = r"""<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ani-cli web - watchlist</title> <title>ani-cli web - watchlist</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;