Compare commits

...
51 Commits
Author SHA1 Message Date
Dymas bcf9e15d40 Fix anipy fallback episode numbering
Use the queued episode spec when finalizing anipy-cli fallback TV downloads so retries keep the requested episode numbers instead of being renumbered from 1, and update tests plus release docs for the fix.
2026-07-09 15:16:53 +02:00
Dymas 0c17499efa Install anipy-cli in Docker image
Install anipy-cli systemwide during the Docker build so fallback downloads work inside the container without extra setup, and update the README to describe the new Docker behavior.
2026-07-09 15:03:27 +02:00
Dymas e84ef16f7d Add repository agent instructions
Add the local AGENTS.md file with project-specific workflow notes so future changes follow the repository's versioning, changelog, README, and commit expectations.
2026-07-09 14:22:28 +02:00
Dymas 4e980220b1 Add Docker branch selector for ani-cli-web
Add an  build argument to the Docker image so the cloned ani-cli-web source can come from a selected branch, and document the new build option in the README.
2026-07-09 14:22:04 +02:00
Dymas d8ed64bd64 Add anipy-cli download fallback
Add an optional config toggle that retries failed ani-cli downloads with anipy-cli, expose fallback runtime status in the config page, and update release docs and tests for the new behavior.
2026-07-09 14:11:40 +02:00
Dymas 332ec670fc Add Jellyfin handoff progress job 2026-06-14 13:09:46 +02:00
Dymas 6403c98b7d Add watchlist source name override for ani-cli queries 2026-06-13 16:52:11 +02:00
Dymas ef00ff20a9 Bump version to 0.45.17 2026-06-07 02:17:18 +02:00
Dymas bff36940ac Add Homepage watchlist summary endpoint 2026-06-07 02:15:13 +02:00
Dymas 18d82a8678 Show alt titles in search results 2026-06-02 13:03:22 +02:00
Dymas 94740ebb76 Move search results into poster grid 2026-06-02 11:06:13 +02:00
Dymas d075aa11f9 Trim README to user-facing deployment guide 2026-05-26 20:12:57 +02:00
Dymas 541cbe83bb Add changelog viewer to runtime info 2026-05-26 20:06:03 +02:00
Dymas c757a58f69 Trim repo-only files from Docker image 2026-05-26 20:01:52 +02:00
Dymas 2c1650d699 Load runtime version from VERSION file 2026-05-26 19:49:10 +02:00
Dymas 7a0fa024ed Fix first refresh watchlist auto-download backlog 2026-05-26 19:37:20 +02:00
Dymas bf4fdc79f5 Fix jellyfin sync config merging 2026-05-26 11:42:21 +02:00
Dymas 4c430e1982 Refine remote config and path browsing 2026-05-26 10:50:31 +02:00
Dymas 83497d8ff0 Harden remote access and path handling 2026-05-26 09:20:02 +02:00
Dymas ff605aca03 Fix movie watchlist completion sync 2026-05-26 07:26:54 +02:00
Dymas 28410b2946 Clarify queue cleanup and detached sync logs 2026-05-26 07:20:54 +02:00
Dymas f5c476162e Keep removed watchlist shows from reappearing 2026-05-26 07:17:22 +02:00
Dymas b72d93268c Harden queue matching and retry behavior 2026-05-26 00:02:02 +02:00
Dymas 1ceb773f99 Harden queue identity and Jellyfin move checks 2026-05-25 23:51:16 +02:00
Dymas 8fb32a59ba Tidy watchlist action button layout 2026-05-25 23:34:52 +02:00
Dymas 5db94cffb1 Stop forcing watchlist ani-cli result indexes 2026-05-25 23:24:31 +02:00
Dymas 1f50c3c666 Clarify no-download queue failures 2026-05-25 23:13:41 +02:00
Dymas 94ded2725e Fix partial series completion for watchlist and Jellyfin 2026-05-25 23:09:14 +02:00
Dymas c006fb2d1d Expand Discord download and Jellyfin notifications 2026-05-25 19:40:51 +02:00
Dymas 75262d261d Add watchlist episode offset naming 2026-05-25 17:57:42 +02:00
Dymas d10ae832b3 Limit bulk refresh to watching and planned 2026-05-25 17:40:13 +02:00
Dymas aef3be9d2d Add host path browser to config page 2026-05-25 17:20:55 +02:00
Dymas ece4ce0d5e Add Jellyfin library handoff controls 2026-05-25 17:10:23 +02:00
Dymas 139d7d9e2f Add TV and movie watchlist download tags 2026-05-25 16:53:24 +02:00
Dymas 1487257bed Replace token auth with single-user login 2026-05-25 16:30:01 +02:00
Dymas 8549944be1 Show ani-cli version in config runtime info 2026-05-25 16:10:18 +02:00
Dymas 58f96cb6dd Reflow config page into responsive grid 2026-05-25 10:40:30 +02:00
Dymas 1b887269da Widen auto-download name fields 2026-05-25 10:34:23 +02:00
Dymas d34fbae7e7 Pass search season into watchlist auto-download 2026-05-25 10:30:33 +02:00
Dymas cad3cc46c4 Open alternative titles in modal overlay 2026-05-25 10:25:00 +02:00
Dymas df4eb39ddf Open auto-download settings in modal overlay 2026-05-25 10:19:50 +02:00
Dymas e48bc21944 Track downloaded episodes for auto-download 2026-05-25 10:11:43 +02:00
Dymas baea85e24c Improve auto-download title and series controls 2026-05-25 10:02:32 +02:00
Dymas 3d42657837 Add watchlist auto-download settings 2026-05-25 09:50:20 +02:00
Dymas 46af0ff47d Use AniDB for watchlist alternative titles 2026-05-25 09:24:57 +02:00
Dymas 64979d5d32 Add AniList alternative titles to watchlist 2026-05-25 09:10:42 +02:00
Dymas 643408b19d Refine queue page polling 2026-05-24 11:47:38 +02:00
Dymas 93446c5e92 Use inline watchlist download mode picker 2026-05-23 19:19:31 +02:00
Dymas 2ff4e390c4 Revert "Show AllManga alternative watchlist titles"
This reverts commit 84c2e533d0.
2026-05-23 19:14:35 +02:00
Dymas bead7da60a Revert "Collapse watchlist alternative titles"
This reverts commit 0c1b46e395.
2026-05-23 19:14:34 +02:00
Dymas 5b0f4043ed Revert "Backfill watchlist alternative titles lazily"
This reverts commit 8aa490adf8.
2026-05-23 19:14:34 +02:00
16 changed files with 5896 additions and 1153 deletions
+6
View File
@@ -0,0 +1,6 @@
- always bump up the VERSION unless its changes to Dockerfile, *.md files
- always document changes in simple CHANGELOG.md unless modifiing Dockerfile, *.md, VERSION
- 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.
- always update README.md to reflect current project.
- always commit changes with commentary
+186 -6
View File
@@ -1,16 +1,196 @@
# Changelog
## 0.37.2 - 2026-05-23
## 0.47.1 - 2026-07-09
- Added lazy watchlist alt-title backfill for visible cards, so older entries can fetch and show the new `Alternative titles` button without requiring a manual refresh first.
- Fixed `anipy-cli` fallback renaming for queued TV downloads so retried jobs now keep the requested episode numbers from the queue, avoiding cases like a retried `S02E11` being renamed as `S02E01`.
## 0.37.1 - 2026-05-23
## 0.47.0 - 2026-07-09
- Fixed AllManga alternative title parsing to read the visible `span.altnames` markup and changed watchlist cards to reveal those titles only when the new `Alternative titles` button is pressed.
- Added an optional `anipy-cli` fallback toggle on the Config page so failed `ani-cli` queue jobs can automatically retry once with `anipy-cli`.
- Exposed `anipy-cli` availability and version in Config page runtime checks, and updated queue processing so fallback downloads still finalize into the same TV or movie library layout.
## 0.37.0 - 2026-05-23
## 0.46.0 - 2026-06-14
- Added watchlist support for alternative English and synonym titles pulled from the AllManga show page and shown directly on each watchlist card.
- Changed manual Jellyfin handoff to run as a persisted background job instead of a single blocking request, so the Config page can show live progress while files are being moved.
- Added a Jellyfin sync status API plus restart-safe SQLite-backed job tracking with per-entry and per-file progress details for current and recent handoff runs.
- Updated Jellyfin moves to transfer files individually into the target library, which keeps progress counts accurate while preserving the existing final folder layout.
## 0.45.18 - 2026-06-13
- Added a `Source name` field to the watchlist auto-download editor so per-show jobs can send a more specific anime title to `ani-cli` when provider searches return multiple similarly named results.
- Migrated existing watchlist databases by adding an `auto_download_source_name` column and defaulting stored entries to the tracked title, preserving current behavior until you override it.
- Changed both watchlist `Download all` and automatic watchlist episode queueing to use the saved `Source name` for the `ani-cli` query while keeping the separate library `Name` field for folder and file naming.
## 0.45.17 - 2026-06-07
- Added a lightweight `/api/watchlist/homepage` endpoint for Homepage's Custom API widget, returning simple watchlist category counts for `watchlist`, `planned`, `finished`, `dropped`, and `total`.
## 0.45.16 - 2026-06-02
- Show the first English AniDB alternative title beneath each Search result when one exists, reusing the watchlist-backed alternative-title extraction path instead of duplicating title matching in the browser.
- Included alternative titles in the Search API payload so the result cards can render the subtitle without doing extra per-card network requests.
## 0.45.15 - 2026-06-02
- Moved Search page results out of the sidebar and into a poster-style grid below the selection panel so hits now read like watchlist cards instead of a compact list.
- Added a dedicated search thumbnail route that reuses AnimeSchedule or AniDB artwork, letting search results show cover images without needing to add the title to the watchlist first.
## 0.45.14 - 2026-05-26
- Added a full-width `Changelog` button under Config page Runtime info that opens a floating scrollable panel and loads the contents of the project's `CHANGELOG.md`.
- Added a new `/api/changelog` route for the Config page modal and kept `CHANGELOG.md` inside the Docker runtime image so the in-container changelog viewer works after deployment.
## 0.45.13 - 2026-05-26
- Trimmed the Docker runtime image by removing repo-only files from `/app` after cloning, so the container no longer carries development docs, Compose/build files, or the test suite it does not use at runtime.
- Kept the checked-in `VERSION` file inside the image so Runtime info and startup logging still have the release metadata they now read at runtime.
## 0.45.12 - 2026-05-26
- Changed `ani-cli-web` runtime version reporting to load directly from the project `VERSION` file instead of a duplicated hardcoded constant, which keeps Runtime info, API version responses, and startup logs in sync with release metadata.
- Added regression coverage to ensure the loaded runtime version matches the checked-in `VERSION` file.
## 0.45.11 - 2026-05-26
- Fixed watchlist auto-download queueing so the first manual or scheduled refresh after adding a `Watching` series can pick up already-available missing episodes instead of waiting for a later refresh cycle.
- Kept add-time `initial` watchlist refreshes non-downloading, so tracking a show still seeds metadata first and only later refreshes are allowed to enqueue backlog or newly released episodes.
## 0.45.10 - 2026-05-26
- Fixed watchlist-synced movie downloads so successful movie jobs move existing watchlist entries into `Finished` instead of leaving them stuck in `Watching` or `Planned`.
- Preserved queued job media type when creating missing watchlist entries from completed downloads, which prevents synced movies from being recreated as TV entries and keeps Jellyfin routing correct.
## 0.45.9 - 2026-05-26
- Clarified queue cleanup wording so the UI and README now match the real `Clear finished` behavior, which removes both finished and canceled jobs.
- Fixed successful detached queue jobs to log that watchlist sync was skipped instead of incorrectly claiming that watchlist download state synced.
## 0.45.8 - 2026-05-26
- Made watchlist removal sticky by recording removed show IDs, detaching related queue jobs from watchlist sync, and preventing later completed downloads from silently recreating deleted entries.
- Cleared watchlist queue bindings when a show is removed so old job history no longer suppresses future auto-download coverage if that anime is tracked again later.
- Updated manual watchlist `Download all` queueing to honor the per-anime auto-download quality first, then fall back to the global auto-download quality before the general queue default.
## 0.45.7 - 2026-05-25
- Removed the remaining backend `ani-cli -S` dependency so queued downloads no longer trust caller-supplied search result ordinals, including older persisted jobs that still carry a legacy `result_index`.
- Simplified watchlist `Download all` and auto-download queueing to use the stored watchlist `show_id` plus direct episode lookups, avoiding unnecessary `search_anime()` match failures when provider search results drift.
- Restricted queue retries to failed or canceled jobs, which prevents finished downloads from being retried and accidentally redownloading episodes or resending completion side effects.
## 0.45.6 - 2026-05-25
- Hardened Search page queueing so explicit downloads now submit the selected anime title directly instead of replaying a fragile web-result ordinal as `ani-cli -S`, which avoids cross-search mismatches between the API and `ani-cli`.
- Tightened watchlist download identity fallback so jobs without a stored result index now sync back only when the fallback search produces one unique confident title match, skipping ambiguous matches instead of silently picking result `1`.
- Tightened Jellyfin handoff recovery so a missing source folder with an existing target now counts as `already moved` only when the target library actually looks complete; incomplete targets are surfaced as failures for retry and notification handling.
## 0.45.5 - 2026-05-25
- Reworked the watchlist card action layout into two rows so `Refresh` and `Remove` sit on the first line, with `Download all` and `Auto-download` underneath for a less cluttered button group.
## 0.45.4 - 2026-05-25
- Fixed watchlist-triggered downloads to stop forcing `ani-cli` search result indexes from the web API result order; explicit search-page selections still keep their chosen result, but watchlist and auto-download jobs now avoid bad `-S` mismatches when `ani-cli` resolves the title differently.
## 0.45.3 - 2026-05-25
- Improved queue failure handling for no-op `ani-cli` runs: when the command exits successfully but downloads no files, the job now fails with a clear message explaining that nothing was downloaded and the empty staging folder is cleaned up.
## 0.45.2 - 2026-05-25
- Fixed watchlist completion so a successful partial dub or sub download no longer moves a TV series to `Finished`; the category now changes only when the downloaded mode has every expected episode both available and downloaded.
- Fixed Jellyfin TV handoff to use that same mode-aware completion rule, preventing partial dub libraries such as `8/12` from being moved early.
## 0.45.1 - 2026-05-25
- Expanded the `download_completed` Discord webhook so it includes the full saved file list instead of only the first few paths, splitting the list across extra embeds when needed.
- Added `jellyfin_sync_success` and `jellyfin_sync_failed` Discord webhook events for finished-library handoffs, with success and actionable failure notifications emitted from Jellyfin moves.
## 0.45.0 - 2026-05-25
- Added an optional per-anime auto-download episode offset for split-part seasons, so queued watchlist downloads can rename files as `S02E13`, `S02E14`, and onward while still downloading the underlying source episodes normally.
- Added a migration-safe `auto_download_offset` watchlist column, exposed it in the watchlist auto-download editor, and applied it to both manual watchlist `Download all` jobs and automatic watchlist episode downloads.
## 0.44.1 - 2026-05-25
- Limited bulk watchlist refreshes to entries marked `Watching` or `Planned`, so manual `Refresh All` runs and scheduled refreshes skip `Finished` and `Dropped` shows.
## 0.44.0 - 2026-05-25
- Added host filesystem browse buttons to the Config page path fields so default download and Jellyfin library folders can be selected from a built-in browser modal instead of typed manually.
- Added an authenticated `/api/fs/browse` endpoint for directory and file browsing to support the new host path picker workflow.
## 0.43.0 - 2026-05-25
- Added a Jellyfin section on the Config page with enable or disable control, separate TV and movie destination folders, and a manual `Run now` handoff trigger.
- Added automatic Jellyfin handoff for completed watchlist entries, moving TV libraries only once the show is finished and fully downloaded in at least one language, while downloaded movies move into the configured Jellyfin movie library.
## 0.42.0 - 2026-05-25
- Added a watchlist `TV` or `Movie` media tag with a migration-safe SQLite update for existing installs, plus Search and Watchlist controls to set or change that tag.
- Changed completed library layout so TV downloads now go under `.../tv/<anime>/Season NN/...`, while movie-tagged entries go under `.../movie/<anime>/<anime>.ext` without season-style folders or episode naming.
## 0.41.0 - 2026-05-25
- Replaced the shared remote-access token flow with a classic single-user username/password login, backed by a persistent signed session cookie for browser sessions and HTTP Basic auth for API clients.
- Added `ANI_CLI_WEB_AUTH_USERNAME` and `ANI_CLI_WEB_AUTH_PASSWORD` support plus matching `config.json` fields, while keeping those private auth fields out of the public config API so the web UI does not leak or overwrite them.
## 0.40.6 - 2026-05-25
- Fixed the Config page `Dependencies` panel so it stretches across the full settings width, centered its dependency badges, and added the installed `ani-cli` version to the runtime info block.
## 0.40.5 - 2026-05-25
- Moved the Config page `Dependencies` panel to the top as a full-width card and changed the remaining settings panels to a two-column layout that collapses back to one column at `1080px` and below.
## 0.40.4 - 2026-05-25
- Widened the watchlist auto-download modal layout so `Name source` and `Name` each fill half of the dialog row instead of staying in narrow grid cells.
## 0.40.3 - 2026-05-25
- Passed the Search page `Season` value into new watchlist entries so the initial auto-download series setting matches what was chosen before tracking the show.
## 0.40.2 - 2026-05-25
- Replaced the inline `Alternative titles` expansion with a centered overlay dialog so opening AniDB title variants no longer shifts the watchlist layout.
## 0.40.1 - 2026-05-25
- Replaced the per-card auto-download expansion with a centered overlay dialog so opening settings no longer shifts the watchlist grid layout.
## 0.40.0 - 2026-05-25
- Added per-watchlist storage for downloaded sub and dub episode lists so auto-download can compare actual downloaded history instead of only refresh count deltas.
- Changed auto-download to queue any available episodes that are still missing after a later manual or scheduled refresh, including backlog episodes that existed before tracking started.
## 0.39.1 - 2026-05-25
- Replaced the auto-download name datalist with an explicit dropdown that lists the current title, AniDB alternative titles, and a `Custom` option.
- Added a per-anime `Series` field to auto-download settings so queued episodes can target a chosen series/season number instead of always using `1`.
## 0.39.0 - 2026-05-25
- Added watchlist auto-download support with global Config controls plus per-anime mode, quality, and library-name settings hidden behind a card-level editor.
- Limited auto-download to `Watching` entries after later manual or scheduled refreshes, and only queue episodes that are newly discovered and not already pending or downloaded.
## 0.38.0 - 2026-05-25
- Switched watchlist `Alternative titles` from AniList lookups to AniDB title-dump matching and now keep only English alternate titles.
- Reused the cached AniDB title metadata already present in the app, which makes alternate-title matching more predictable for watchlist refreshes.
## 0.37.0 - 2026-05-25
- Added an `Alternative titles` toggle to every watchlist card so AniList title variants can be expanded inline.
- Cached AniList title matches during watchlist refreshes and stored Romaji, English, Native, and synonym titles in SQLite with a migration-safe schema update for existing installs.
## 0.36.3 - 2026-05-24
- Changed the Queue page to stop re-rendering the full job list on every poll and refresh only visible non-terminal job cards in place.
- Added single-job queue reads so active job logs and status can update every 2.5 seconds while `done`, `failed`, and `canceled` jobs stop polling automatically.
## 0.36.2 - 2026-05-23
- Replaced the watchlist `Download all` text prompt with an inline dropdown-style picker that lets you choose `Sub` or `Dub` directly on the card.
## 0.36.1 - 2026-05-23
+16 -2
View File
@@ -3,12 +3,14 @@ FROM python:3.12-slim
ARG ANI_CLI=https://github.com/pystardust/ani-cli.git
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
ENV DEBIAN_FRONTEND=noninteractive \
ANI_CLI_REPO=${ANI_CLI} \
ANI_CLI_BRANCH=${ANI_CLI_BRANCH} \
ANI_CLI_WEB_REPO=${ANI_CLI_WEB} \
ANI_CLI_WEB_BRANCH=${ANI_CLI_WEB_BRANCH} \
ANI_CLI_BIN=ani-cli \
ANI_CLI_DOWNLOAD_DIR=/downloads \
ANI_CLI_WEB_HOST=0.0.0.0 \
@@ -46,8 +48,20 @@ RUN curl -fL "${YT_DLP_RELEASE_URL}" -o /usr/local/bin/yt-dlp \
&& chmod 0755 /usr/local/bin/yt-dlp \
&& yt-dlp --version
RUN git clone --depth 1 "${ANI_CLI_WEB}" /app \
&& rm -rf /app/.git
RUN python3 -m pip install --no-cache-dir anipy-cli \
&& anipy-cli --version
RUN git clone --depth 1 --branch "${ANI_CLI_WEB_BRANCH}" "${ANI_CLI_WEB}" /app \
&& rm -rf /app/.git \
&& rm -f \
/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
+121 -254
View File
@@ -1,81 +1,69 @@
# ani-cli web
Small local web UI for a system-wide `ani-cli` install.
Local web UI for a system-wide `ani-cli` install.
Current version: `0.37.2`
Current version: `0.47.1`
## Project Layout
## What it does
```text
ani-cli-web/
app.py Main entrypoint and runtime wiring.
app_support.py Shared config, state, and helper utilities.
Dockerfile Container image build.
docker-compose.yaml Example Compose service.
queue_jobs.py Download queue and watchlist refresh workers.
watchlist_identity.py Download-to-watchlist identity matching.
title_matching.py Shared title normalization and matching.
http_handler.py HTTP routing and access control.
web_templates.py Shared page aggregation.
search_page.py Search page template.
queue_page.py Queue page template.
config_page.py Config page template.
watchlist_page.py Watchlist page template.
docker-entrypoint.sh Container startup wrapper.
ani-cli-web Launcher script.
test_app.py Regression tests.
VERSION Project version.
CHANGELOG.md Change history.
README.md Project documentation.
```
- 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
- Optionally retry failed `ani-cli` downloads with `anipy-cli`
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
- Expose a small JSON summary endpoint for Homepage dashboard widgets
- 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
- Move fully completed libraries into Jellyfin TV or movie folders with a tracked background handoff job
- Send optional Discord webhook notifications
## Quick Start
## Pages
Run the app:
- `/`: 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
- `/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
## Quick start
Requirements:
- `ani-cli` installed and available in `PATH`
- Optional: `anipy-cli` 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`
Run locally:
```sh
./ani-cli-web
```
Run with debug logging:
```sh
./ani-cli-web --debug
```
Open:
```text
http://127.0.0.1:8421/
```
Default bind address is `127.0.0.1:8421`.
Useful flags and env:
- `./ani-cli-web --debug`
- `ANI_CLI_WEB_HOST=0.0.0.0`
- `ANI_CLI_WEB_PORT=8421`
- `ANI_CLI_BIN=/path/to/ani-cli`
- `ANIPY_CLI_BIN=/path/to/anipy-cli`
## Docker
The image installs `ani-cli` system-wide, downloads the latest upstream `yt-dlp` release binary from GitHub, and clones `ani-cli-web` into `/app` during build.
Default build args:
```text
ANI_CLI=https://github.com/pystardust/ani-cli.git
ANI_CLI_BRANCH=master
ANI_CLI_WEB=https://gitea.coreplay.eu/Dymas/ani-cli-web.git
```
Build:
```sh
docker build -t ani-cli-web .
```
Build with a custom `ani-cli` branch:
Build a specific `ani-cli-web` branch:
```sh
docker build \
--build-arg ANI_CLI_BRANCH=my-branch \
--build-arg ANI_CLI=https://github.com/pystardust/ani-cli.git \
--build-arg ANI_CLI_WEB=https://gitea.coreplay.eu/Dymas/ani-cli-web.git \
--build-arg ANI_CLI_WEB_BRANCH=anipy_backup \
-t ani-cli-web .
```
@@ -84,7 +72,6 @@ Run:
```sh
docker run --rm \
-p 8421:8421 \
-e UPDATE_ON_START=true \
-e USER_UID="$(id -u)" \
-e USER_GID="$(id -g)" \
-v "$(pwd)/downloads:/downloads" \
@@ -94,219 +81,104 @@ docker run --rm \
Compose:
```sh
docker compose up --build -d
```
Use host ownership for mounted downloads:
```sh
USER_UID="$(id -u)" USER_GID="$(id -g)" docker compose up --build -d
```
Container notes:
Docker notes:
- `ani-cli` is installed at `/usr/local/bin/ani-cli`.
- `yt-dlp` is installed at `/usr/local/bin/yt-dlp` from the latest GitHub release during build.
- `ani-cli-web` runs from `/app`.
- Downloads go to `/downloads`.
- App state lives in `/app/.ani-cli-web`.
- The container binds `0.0.0.0:8421`.
- `UPDATE_ON_START=true` runs `sudo ani-cli --update` before startup.
- `USER_UID` and `USER_GID` keep mounted files from being owned by `root`.
- The container serves on `0.0.0.0:8421`
- Downloads are written under `/downloads`
- App state is stored under `/app/.ani-cli-web`
- `USER_UID` and `USER_GID` help keep mounted files owned by your host user
- `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
- `anipy-cli` is installed systemwide inside the container, so fallback retries are available without extra container setup
## What It Does
- Search AllAnime in `sub` or `dub`.
- Queue downloads with editable folder, quality, season, and episode range.
- Save defaults on `/config`.
- Track shows on `/watchlist`.
- Refresh watchlist episode counts in the background.
- Schedule periodic watchlist refresh jobs.
- Send Discord webhook notifications for refresh discoveries and failures.
- Send Discord webhook notifications for completed downloads.
- Cache watchlist thumbnails locally.
- Sync successful downloads back into the watchlist.
- Keep queue and watchlist data in SQLite under `.ani-cli-web/`.
## Pages
### Search Page
Path: `/`
Use it to:
1. Search for an anime.
2. Load episodes.
3. Adjust folder, quality, season, and episode range.
4. Add the show to the queue or watchlist.
Highlights:
- Search stays focused on finding shows, loading episodes, and queueing new downloads.
- Added-to-queue notices now point to the dedicated Queue page for progress tracking.
- Search request guards still avoid stale responses overwriting newer results.
- The shared sidebar menu now shows one navigation entry per line for cleaner balance across all pages.
### Queue Page
Path: `/queue`
Use it to:
1. Monitor active, pending, failed, and finished download jobs.
2. Inspect recent per-job logs with newest lines first.
3. Retry failed jobs.
4. Remove finished jobs.
5. Cancel running or pending jobs.
Highlights:
- Automatic queue polling every 2.5 seconds.
- Dedicated queue pagination and cleanup controls.
- Search and queue workflows are now separated into distinct pages.
- Each queue job card can be collapsed, and finished jobs start collapsed by default to keep the page compact.
### Config Page
Path: `/config`
Use it to set:
1. Default download folder.
2. Default `sub` or `dub` mode.
3. Default quality.
4. Scheduled watchlist refresh enable or disable.
5. Scheduled refresh period in minutes.
6. Delay between shows during `Refresh All`.
7. Discord webhook URL.
8. Discord notification events plus a test action.
Saved settings are written to `.ani-cli-web/config.json`.
Discord notification events:
- Download completed.
- Refresh finished and found new episodes.
- Refresh failed or finished with per-show refresh errors.
- Refresh interrupted by restart or shutdown.
- Unexpected runtime errors.
## Key behavior
### Watchlist
Path: `/watchlist`
- Adding a show tracks it without downloading immediately
- Later manual refreshes or scheduled refreshes can queue missing episodes
- `Download all` queues every currently available episode for the chosen mode
- Successful downloads sync back into the watchlist
- 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
Categories:
### Auto-download
- `Watching`
- `Planned`
- `Finished`
- `Dropped`
- Runs only for entries in `Watching`
- Uses the per-show language, quality, `Source name` search override, season, library name, and optional episode offset settings
- Can queue backlog episodes that were already available before the show was first tracked
Ways to add shows:
### Download fallback
1. From the Search page.
2. Manually on `/watchlist`.
- Optional and configured from `/config`
- When enabled, a failed `ani-cli` queue job retries once with `anipy-cli`
- Fallback retries keep the queued episode numbers when the downloaded `anipy-cli` filenames are renumbered from `1`
- Docker images install `anipy-cli` automatically; non-Docker installs should provide it in `PATH` themselves
Main behavior:
### Jellyfin handoff
1. `Refresh` updates one show.
2. `Refresh All` runs in the background.
3. `Download all` prompts for `sub` or `dub` and queues every currently available episode directly.
4. Clicking the title opens the source page.
5. `Remove` deletes the entry and cached poster.
6. Each card has an inline category selector.
7. Summary cards show total tracked shows plus `sub` and `dub` counts.
8. Pagination shows 30 cards per page.
- Optional and configured from `/config`
- TV libraries move only after the show is finished and download-complete
- 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
- Recent Jellyfin handoff jobs survive page reloads and show their latest status after app restarts
Status behavior:
## Homepage widget API
- `Sub` and `Dub` pills turn green when that track looks complete.
- Cards with both `sub` and `dub` fully ready get a thin green border.
- Finished cards show a small check badge.
- Green check means downloaded through the app.
- Gray check means finished manually.
For Homepage's Custom API widget, use:
Download sync:
```text
http://127.0.0.1:8421/api/watchlist/homepage
```
- Existing watchlist entries move to `Finished` when a download succeeds.
- Missing entries are added to `Finished`.
- If ani-cli resolves a different `show_id`, the app can reconcile a unique title match instead of creating a duplicate finished row.
Example response:
Thumbnail behavior:
```json
{
"watchlist": 12,
"planned": 4,
"finished": 31,
"dropped": 2,
"total": 49
}
```
- Thumbnails are served from the local `/api/watchlist/thumb/...` route.
- Cached thumbnails are reused.
This endpoint is intentionally small so Homepage can map the returned values directly into its widget fields.
Title behavior:
## Runtime info
- Watchlist refreshes also try to pull alternative English and synonym titles from the AllManga show page.
- Watchlist cards show an `Alternative titles` button when names are available.
- Pressing that button expands the stored alternative titles on demand.
- Visible watchlist cards also try to backfill missing alternative titles automatically, so older entries do not need a manual refresh just to get the button.
- Missing covers are warmed in small batches.
- Failed lookups back off before retrying.
- You can force `Reload` or use `Upload` when no thumbnail exists.
The Config page runtime panel shows:
Refresh behavior:
- `ani-cli-web` version loaded from `VERSION`
- Installed `ani-cli` version
- Installed `anipy-cli` version and dependency status
- A `Changelog` button that opens a scrollable viewer backed by `CHANGELOG.md`
- Per-show refreshes are queued in the background.
- Queued refresh state survives restarts.
- Duplicate refresh queueing is avoided.
- Scheduled refresh runs log start, stop, success, and failure to stdout.
- `Refresh All` works one show at a time with a configurable delay.
- When a Discord webhook is configured, new-episode refresh results include one notification card per anime with sub and dub counts and a cached thumbnail attachment when available.
## Remote access
## Access Guard
By default the app only serves loopback clients such as `127.0.0.1` and `::1`.
By default the app only serves loopback clients.
To allow LAN or remote access:
```sh
ANI_CLI_WEB_ALLOW_REMOTE=1
ANI_CLI_WEB_REMOTE_TOKEN=choose-a-long-random-token
ANI_CLI_WEB_AUTH_USERNAME=admin
ANI_CLI_WEB_AUTH_PASSWORD=choose-a-long-random-password
```
Non-local clients must send one of:
Optional remote path restriction:
- `Authorization: Bearer <token>`
- `X-AniCli-Web-Token: <token>`
Browser bootstrap also works:
```text
http://YOUR-HOST:8421/?token=your-token-here
```sh
ANI_CLI_WEB_REMOTE_PATH_ROOTS=/downloads:/srv/jellyfin-tv:/srv/jellyfin-movies
```
For normal browser visits, the app now shows a sign-in page automatically when the token cookie is missing.
## State and storage
The app stores the token in an HTTP-only cookie and redirects to the clean URL. The cookie is persistent, so you usually only need to sign in once per browser.
## Download Queue
Path: `/queue`
The queue is stored in SQLite, shown 10 jobs at a time, and managed from its dedicated page.
Available actions:
- `Retry`
- `Cancel`
- `Remove`
- `Retry failed`
- `Remove finished`
Downloads are staged first, then moved into a media-friendly layout:
```text
ANI_CLI_DOWNLOAD_DIR/anime_name/Season 01/anime_name - S01E01.mp4
```
## Runtime State
All local state is stored under:
Project state is stored in:
```text
ani-cli-web/.ani-cli-web/
@@ -315,37 +187,39 @@ ani-cli-web/.ani-cli-web/
Main contents:
- `config.json`
- `remote-sessions.json`
- `state.sqlite3`
- `thumbnails/`
- AniDB title cache data
- staging files for active downloads
Legacy state from older locations is migrated on startup.
Downloaded files are organized like this:
```text
ANI_CLI_DOWNLOAD_DIR/tv/Show Name/Season 01/Show Name - S01E01.mp4
ANI_CLI_DOWNLOAD_DIR/movie/Movie Name/Movie Name.mp4
```
## Configuration
Environment variables:
Most users only need these environment variables:
- `ANI_CLI_BIN`: path or command name for `ani-cli`
- `ANI_CLI_DOWNLOAD_DIR`: initial default download folder
- `ANI_CLI_MODE`: initial mode, `sub` or `dub`
- `ANI_CLI_QUALITY`: initial quality such as `best`, `1080`, or `720`
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`
- `ANI_CLI_WEB_PORT`: server port, default `8421`
- `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients
- `ANI_CLI_WEB_REMOTE_TOKEN`: shared secret for remote access
- `ANI_CLI_WEB_DEBUG`: enable debug logging
- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory
- `UPDATE_ON_START`: run `sudo ani-cli --update` in the container before startup
- `USER_UID` and `USER_GID`: run the container as a specific host user/group
- `ANI_CLI_BIN`
- `ANIPY_CLI_BIN`
- `ANI_CLI_WEB_HOST`
- `ANI_CLI_WEB_PORT`
- `ANI_CLI_WEB_ALLOW_REMOTE`
- `ANI_CLI_WEB_AUTH_USERNAME`
- `ANI_CLI_WEB_AUTH_PASSWORD`
- `ANI_CLI_WEB_REMOTE_PATH_ROOTS`
- `ANI_CLI_WEB_DEBUG`
- `ANI_CLI_WEB_STATE_ROOT`
Saved config fields include:
Container-specific variables:
- `discord_webhook_url`
- `discord_webhook_events`
- `watchlist_auto_refresh_enabled`
- `watchlist_auto_refresh_minutes`
- `watchlist_refresh_delay_seconds`
- `UPDATE_ON_START`
- `USER_UID`
- `USER_GID`
Everything else can usually be configured from `/config`.
## Testing
@@ -354,10 +228,3 @@ Run the regression suite with:
```sh
python3 -m unittest test_app.py
```
## Notes
- Runtime boot is lazy. Importing `app.py` does not immediately start worker threads.
- Queue and watchlist data share the same SQLite database: `.ani-cli-web/state.sqlite3`.
- Runtime folders such as `.ani-cli-web/` and `downloads/` are ignored by git.
- The Docker image clones both upstream repositories during build.
+1 -1
View File
@@ -1 +1 @@
0.37.2
0.47.1
+985 -249
View File
File diff suppressed because it is too large Load Diff
+499 -36
View File
@@ -2,24 +2,31 @@
"""Shared constants and helper utilities for ani-cli-web."""
import hashlib
import hmac
import ipaddress
import json
import mimetypes
import os
import re
import secrets
import shutil
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
import urllib.error
import urllib.request
from urllib.parse import urlparse
from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent
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"
APP_NAME = "ani-cli-web"
VERSION = "0.37.2"
VERSION_FILE = PROJECT_ROOT / "VERSION"
CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md"
ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to"
@@ -34,6 +41,7 @@ DEBUG_MODE = False
QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"}
MODE_CHOICES = {"sub", "dub"}
MEDIA_TYPE_CHOICES = {"tv", "movie"}
WATCHLIST_CATEGORY_LABELS = {
"watching": "Watching",
"planned": "Planned",
@@ -53,6 +61,8 @@ WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0
WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300
DISCORD_WEBHOOK_EVENT_CHOICES = (
"download_completed",
"jellyfin_sync_success",
"jellyfin_sync_failed",
"watchlist_refresh_new_episodes",
"watchlist_refresh_failed",
"watchlist_refresh_interrupted",
@@ -60,11 +70,33 @@ DISCORD_WEBHOOK_EVENT_CHOICES = (
)
DISCORD_WEBHOOK_EVENT_LABELS = {
"download_completed": "Download completed",
"jellyfin_sync_success": "Jellyfin move succeeded",
"jellyfin_sync_failed": "Jellyfin move failed",
"watchlist_refresh_new_episodes": "Refresh finished with new episodes found",
"watchlist_refresh_failed": "Refresh failed or finished with refresh errors",
"watchlist_refresh_interrupted": "Refresh interrupted by shutdown or restart",
"runtime_error": "Unexpected runtime errors",
}
REMOTE_ACCESS_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
REMOTE_SESSION_STORE_LOCK = threading.RLock()
def load_app_version(default="0.0.0"):
try:
version = VERSION_FILE.read_text(encoding="utf-8").strip()
except OSError:
return default
return version or default
def load_project_text_file(path, default=""):
try:
return Path(path).read_text(encoding="utf-8")
except OSError:
return default
VERSION = load_app_version()
def env_flag(name, default=False):
@@ -82,18 +114,140 @@ def remote_access_allowed():
return env_flag("ANI_CLI_WEB_ALLOW_REMOTE")
def remote_access_token():
return str(os.environ.get("ANI_CLI_WEB_REMOTE_TOKEN") or "").strip()
def remote_access_username(config=None):
configured = str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip()
if configured:
return configured
if isinstance(config, dict):
return str(config.get("auth_username") or "").strip()
return ""
def remote_access_token_configured():
return bool(remote_access_token())
def remote_access_password(config=None):
configured = str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip()
if configured:
return configured
if isinstance(config, dict):
return str(config.get("auth_password") or "").strip()
return ""
def remote_access_token_matches(value):
def remote_access_credentials_configured(config=None):
return bool(remote_access_username(config) and remote_access_password(config))
def remote_access_credentials_match(username, password, config=None):
expected_username = remote_access_username(config)
expected_password = remote_access_password(config)
provided_username = str(username or "").strip()
provided_password = str(password or "").strip()
return bool(
expected_username
and expected_password
and hmac.compare_digest(provided_username, expected_username)
and hmac.compare_digest(provided_password, expected_password)
)
def remote_access_session_value(config=None):
return create_remote_access_session(config)
def remote_access_session_fingerprint(config=None):
username = remote_access_username(config)
password = remote_access_password(config)
if not username or not password:
return ""
return hashlib.sha256(f"{username}\0{password}".encode("utf-8")).hexdigest()
def _remote_access_session_token_digest(value):
return hashlib.sha256(str(value or "").strip().encode("utf-8")).hexdigest()
def _load_remote_access_sessions_locked():
data = load_json(REMOTE_SESSIONS_PATH, {})
if not isinstance(data, dict):
return {}
return data
def _write_remote_access_sessions_locked(data):
write_json(REMOTE_SESSIONS_PATH, data)
def _prune_remote_access_sessions_locked(data, now=None):
timestamp = int(now if now is not None else time.time())
changed = False
for token_digest, payload in list(data.items()):
try:
expires_at = int(payload.get("expires_at") or 0)
except (AttributeError, TypeError, ValueError):
expires_at = 0
if expires_at <= timestamp:
data.pop(token_digest, None)
changed = True
return changed
def create_remote_access_session(config=None, ttl_seconds=REMOTE_ACCESS_SESSION_TTL_SECONDS):
username = remote_access_username(config)
fingerprint = remote_access_session_fingerprint(config)
if not username or not fingerprint:
return ""
now = int(time.time())
token = secrets.token_urlsafe(32)
payload = {
"username": username,
"fingerprint": fingerprint,
"created_at": now_iso(),
"expires_at": now + max(60, int(ttl_seconds or REMOTE_ACCESS_SESSION_TTL_SECONDS)),
}
with REMOTE_SESSION_STORE_LOCK:
sessions = _load_remote_access_sessions_locked()
_prune_remote_access_sessions_locked(sessions, now=now)
sessions[_remote_access_session_token_digest(token)] = payload
_write_remote_access_sessions_locked(sessions)
return token
def remote_access_session_matches(value, config=None):
provided = str(value or "").strip()
expected = remote_access_token()
return bool(expected and provided and hmac.compare_digest(provided, expected))
fingerprint = remote_access_session_fingerprint(config)
username = remote_access_username(config)
if not provided or not fingerprint or not username:
return False
token_digest = _remote_access_session_token_digest(provided)
with REMOTE_SESSION_STORE_LOCK:
sessions = _load_remote_access_sessions_locked()
changed = _prune_remote_access_sessions_locked(sessions)
payload = sessions.get(token_digest)
if changed:
_write_remote_access_sessions_locked(sessions)
if not isinstance(payload, dict):
return False
return bool(
hmac.compare_digest(str(payload.get("fingerprint") or ""), fingerprint)
and hmac.compare_digest(str(payload.get("username") or ""), username)
)
def clear_remote_access_sessions(config=None):
expected_fingerprint = str(config.get("fingerprint") or "") if isinstance(config, dict) else ""
with REMOTE_SESSION_STORE_LOCK:
sessions = _load_remote_access_sessions_locked()
if expected_fingerprint:
filtered = {
token_digest: payload
for token_digest, payload in sessions.items()
if str((payload or {}).get("fingerprint") or "") != expected_fingerprint
}
else:
filtered = {}
if filtered == sessions:
return False
_write_remote_access_sessions_locked(filtered)
return True
def background_workers_enabled():
@@ -137,12 +291,22 @@ DEFAULT_CONFIG = {
"download_dir": default_download_dir(),
"mode": os.environ.get("ANI_CLI_MODE", "sub"),
"quality": os.environ.get("ANI_CLI_QUALITY", "best"),
"anipy_cli_fallback_enabled": False,
"watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
"watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS,
"auto_download_enabled": False,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"jellyfin_sync_enabled": False,
"jellyfin_tv_dir": "",
"jellyfin_movie_dir": "",
"discord_webhook_url": "",
"discord_webhook_events": [],
"auth_username": str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip(),
"auth_password": str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip(),
}
PRIVATE_CONFIG_KEYS = {"auth_username", "auth_password"}
def now_iso():
@@ -235,6 +399,7 @@ WATCHLIST_JSON_PATH = project_state_path("watchlist.json")
THUMBNAIL_ROOT = project_state_dir("thumbnails")
THUMBNAIL_RETRY_SECONDS = 15 * 60
ANIDB_TITLES_PATH = project_state_path("anidb-anime-titles.xml.gz")
REMOTE_SESSIONS_PATH = project_state_path("remote-sessions.json")
ANIDB_TITLES_CACHE = {
"mtime": None,
"entries": None,
@@ -282,16 +447,39 @@ def normalize_config(data):
if quality.endswith("p") and quality[:-1].isdigit():
quality = quality[:-1]
download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"])
anipy_cli_fallback_enabled = config.get("anipy_cli_fallback_enabled")
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds")
auto_download_enabled = config.get("auto_download_enabled")
auto_download_mode = str(config.get("auto_download_mode", "dub")).lower()
auto_download_quality = str(config.get("auto_download_quality", "best")).lower()
if auto_download_quality.endswith("p") and auto_download_quality[:-1].isdigit():
auto_download_quality = auto_download_quality[:-1]
jellyfin_sync_enabled = config.get("jellyfin_sync_enabled")
jellyfin_tv_dir = str(config.get("jellyfin_tv_dir") or "").strip()
jellyfin_movie_dir = str(config.get("jellyfin_movie_dir") or "").strip()
webhook_url = str(config.get("discord_webhook_url") or "").strip()
webhook_events = config.get("discord_webhook_events")
auth_username = str(config.get("auth_username") or "").strip()
auth_password = str(config.get("auth_password") or "").strip()
if isinstance(anipy_cli_fallback_enabled, str):
anipy_cli_fallback_enabled = anipy_cli_fallback_enabled.strip().lower() in {"1", "true", "yes", "on"}
else:
anipy_cli_fallback_enabled = bool(anipy_cli_fallback_enabled)
if isinstance(auto_refresh_enabled, str):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
else:
auto_refresh_enabled = bool(auto_refresh_enabled)
if isinstance(auto_download_enabled, str):
auto_download_enabled = auto_download_enabled.strip().lower() in {"1", "true", "yes", "on"}
else:
auto_download_enabled = bool(auto_download_enabled)
if isinstance(jellyfin_sync_enabled, str):
jellyfin_sync_enabled = jellyfin_sync_enabled.strip().lower() in {"1", "true", "yes", "on"}
else:
jellyfin_sync_enabled = bool(jellyfin_sync_enabled)
try:
auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES)
@@ -313,9 +501,16 @@ def normalize_config(data):
config["mode"] = mode if mode in MODE_CHOICES else "sub"
config["quality"] = quality if quality in QUALITY_CHOICES else "best"
config["download_dir"] = str(Path(download_dir).expanduser())
config["anipy_cli_fallback_enabled"] = anipy_cli_fallback_enabled
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes
config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds
config["auto_download_enabled"] = auto_download_enabled
config["auto_download_mode"] = auto_download_mode if auto_download_mode in MODE_CHOICES else "dub"
config["auto_download_quality"] = auto_download_quality if auto_download_quality in QUALITY_CHOICES else "best"
config["jellyfin_sync_enabled"] = jellyfin_sync_enabled
config["jellyfin_tv_dir"] = str(Path(jellyfin_tv_dir).expanduser()) if jellyfin_tv_dir else ""
config["jellyfin_movie_dir"] = str(Path(jellyfin_movie_dir).expanduser()) if jellyfin_movie_dir else ""
if isinstance(webhook_events, str):
webhook_events = [part.strip() for part in webhook_events.split(",")]
elif not isinstance(webhook_events, list):
@@ -329,9 +524,84 @@ def normalize_config(data):
normalized_events.append(normalized_name)
config["discord_webhook_url"] = webhook_url
config["discord_webhook_events"] = normalized_events
config["auth_username"] = auth_username
config["auth_password"] = auth_password
return config
def sanitize_public_config(config):
source = normalize_config(config or {})
return {key: value for key, value in source.items() if key not in PRIVATE_CONFIG_KEYS}
def _env_remote_path_roots():
raw = str(os.environ.get("ANI_CLI_WEB_REMOTE_PATH_ROOTS") or "").strip()
if not raw:
return []
roots = []
for part in raw.split(os.pathsep):
text = str(part or "").strip()
if not text:
continue
try:
resolved = Path(text).expanduser().resolve()
except OSError:
continue
if resolved not in roots:
roots.append(resolved)
return roots
def configured_remote_path_roots(config=None):
normalized = normalize_config(config or {})
roots = list(_env_remote_path_roots())
for raw_path in (
normalized.get("download_dir"),
normalized.get("jellyfin_tv_dir"),
normalized.get("jellyfin_movie_dir"),
):
text = str(raw_path or "").strip()
if not text:
continue
try:
resolved = Path(text).expanduser().resolve()
except OSError:
continue
if resolved not in roots:
roots.append(resolved)
return roots
def path_is_within_roots(path, roots):
try:
candidate = Path(path).expanduser().resolve()
except OSError:
return False
for root in roots or []:
try:
root_path = Path(root).expanduser().resolve()
except OSError:
continue
if candidate == root_path or root_path in candidate.parents:
return True
return False
def validate_discord_webhook_url(url):
text = str(url or "").strip()
if not text:
return ""
parsed = urlparse(text)
if parsed.scheme != "https":
raise ValueError("Discord webhook URL must use https.")
host = str(parsed.netloc or "").split("@", 1)[-1].split(":", 1)[0].strip().lower()
if host not in {"discord.com", "ptb.discord.com", "canary.discord.com", "discordapp.com"}:
raise ValueError("Discord webhook URL must point to an official Discord webhook host.")
if not str(parsed.path or "").startswith("/api/webhooks/"):
raise ValueError("Discord webhook URL must use the Discord /api/webhooks/ path.")
return text
def discord_webhook_enabled(config, event_name, force=False):
if not isinstance(config, dict):
return False
@@ -422,12 +692,67 @@ def _post_discord_webhook(url, payload, attachments=None, timeout=10):
raise RuntimeError("Discord webhook request timed out") from exc
def _chunk_discord_lines(lines, max_chars=3800):
chunks = []
current = []
current_length = 0
for raw_line in lines or []:
line = str(raw_line or "").strip()
if not line:
continue
added_length = len(line) + (1 if current else 0)
if current and current_length + added_length > max_chars:
chunks.append(current)
current = [line]
current_length = len(line)
continue
current.append(line)
current_length += added_length
if current:
chunks.append(current)
return chunks
def _post_discord_embeds_with_file_list(webhook_url, content, summary_embed, file_lines, attachments=None):
chunks = _chunk_discord_lines(file_lines)
if not chunks:
_post_discord_webhook(webhook_url, {"content": content, "embeds": [summary_embed]}, attachments=attachments)
return
total_chunks = len(chunks)
first_batch = chunks[:9]
request_embeds = [summary_embed]
for index, chunk in enumerate(first_batch, start=1):
request_embeds.append(
{
"title": f"Saved files ({index}/{total_chunks})",
"description": "\n".join(chunk)[:4096],
"color": summary_embed.get("color", 5763719),
}
)
_post_discord_webhook(webhook_url, {"content": content, "embeds": request_embeds}, attachments=attachments)
sent_chunks = len(first_batch)
while sent_chunks < total_chunks:
batch = chunks[sent_chunks : sent_chunks + 10]
embeds = []
for offset, chunk in enumerate(batch, start=1):
chunk_index = sent_chunks + offset
embeds.append(
{
"title": f"Saved files ({chunk_index}/{total_chunks})",
"description": "\n".join(chunk)[:4096],
"color": summary_embed.get("color", 5763719),
}
)
_post_discord_webhook(webhook_url, {"content": content, "embeds": embeds})
sent_chunks += len(batch)
def send_discord_webhook_event(config, event_name, payload=None, force=False):
normalized = normalize_config(config or {})
event = str(event_name or "").strip()
if not discord_webhook_enabled(normalized, event, force=force):
return {"sent": False, "reason": "disabled"}
webhook_url = normalized["discord_webhook_url"]
webhook_url = validate_discord_webhook_url(normalized["discord_webhook_url"])
payload = payload or {}
if event == "test":
@@ -501,7 +826,6 @@ def send_discord_webhook_event(config, event_name, payload=None, force=False):
description_lines.append(f"Watchlist category: {str(payload.get('category')).strip()}")
if moved_files:
description_lines.append(f"Saved files: {len(moved_files)}")
description_lines.extend(str(path).strip() for path in moved_files[:5] if str(path).strip())
embed = {
"title": title[:256],
"description": "\n".join(description_lines)[:4096] or "Download completed.",
@@ -513,11 +837,64 @@ def send_discord_webhook_event(config, event_name, payload=None, force=False):
attachment_name = f"download-thumb{thumb_path.suffix or '.jpg'}"
attachments.append({"path": thumb_path, "filename": attachment_name})
embed["thumbnail"] = {"url": f"attachment://{attachment_name}"}
request_payload = {
"content": "ani-cli-web finished a download.",
"embeds": [embed],
_post_discord_embeds_with_file_list(
webhook_url,
"ani-cli-web finished a download.",
embed,
moved_files,
attachments=attachments,
)
return {"sent": True, "event": event}
if event == "jellyfin_sync_success":
title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip()
moved_files = payload.get("moved_files") if isinstance(payload.get("moved_files"), list) else []
description_lines = [
f"Media type: {str(payload.get('media_type') or 'tv').strip()}",
f"Source: {str(payload.get('source') or 'manual').strip()}",
]
if payload.get("target"):
description_lines.append(f"Target: {str(payload.get('target')).strip()}")
if moved_files:
description_lines.append(f"Moved files: {len(moved_files)}")
embed = {
"title": f"Jellyfin handoff: {title[:236]}",
"description": "\n".join(description_lines)[:4096],
"color": 5763719,
}
_post_discord_webhook(webhook_url, request_payload, attachments=attachments)
_post_discord_embeds_with_file_list(
webhook_url,
"ani-cli-web moved a finished library into Jellyfin.",
embed,
moved_files,
)
return {"sent": True, "event": event}
if event == "jellyfin_sync_failed":
title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip()
reason = str(payload.get("reason") or "unknown").strip()
description_lines = [
f"Reason: {reason}",
f"Media type: {str(payload.get('media_type') or 'tv').strip()}",
f"Source: {str(payload.get('source') or 'manual').strip()}",
]
if payload.get("error"):
description_lines.append(f"Error: {str(payload.get('error')).strip()}")
if payload.get("source_path"):
description_lines.append(f"Source path: {str(payload.get('source_path')).strip()}")
if payload.get("target"):
description_lines.append(f"Target path: {str(payload.get('target')).strip()}")
request_payload = {
"content": "ani-cli-web could not move a finished library into Jellyfin.",
"embeds": [
{
"title": f"Jellyfin handoff failed: {title[:229]}",
"description": "\n".join(description_lines)[:4096],
"color": 15158332,
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
if event == "watchlist_refresh_failed":
@@ -592,6 +969,13 @@ def strip_control(value):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n")
def cli_executable_exists(command):
text = str(command or "").strip()
if not text:
return False
return bool(shutil.which(text) or (Path(text).exists() and os.access(text, os.X_OK)))
def printable_command(command):
return " ".join(sh_quote(part) for part in command)
@@ -628,9 +1012,30 @@ def normalize_season(value):
return str(number)
def normalize_episode_offset(value):
text = str(value or "").strip()
if not text:
return None
if not re.match(r"^[0-9]+$", text):
raise ValueError("Episode offset must be a positive number")
number = int(text, 10)
if number < 1:
raise ValueError("Episode offset must be a positive number")
return str(number)
def normalize_media_type(value, default="tv"):
media_type = str(value or default).strip().lower()
return media_type if media_type in MEDIA_TYPE_CHOICES else default
def job_output_dir(job):
anime_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
return Path(job["download_dir"]).expanduser() / anime_name / f"Season {season_label(job)}"
media_type = normalize_media_type(job.get("media_type"))
library_root = Path(job["download_dir"]).expanduser() / media_type / anime_name
if media_type == "movie":
return library_root
return library_root / f"Season {season_label(job)}"
def job_staging_dir(job):
@@ -651,11 +1056,46 @@ def episode_label(value):
return re.sub(r"[^0-9A-Za-z.]+", "-", text).strip("-") or "00"
def apply_episode_offset(value, offset):
normalized_offset = normalize_episode_offset(offset)
text = str(value or "").strip()
if not normalized_offset or not text:
return text
match = re.match(r"^0*([0-9]+)(.*)$", text)
if not match:
return text
number = int(match.group(1) or "0", 10)
if number < 1:
return text
suffix = match.group(2) or ""
return f"{int(normalized_offset, 10) + number - 1}{suffix}"
def extract_episode_from_filename(path):
match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem)
return match.group(1) if match else None
def episode_values_from_spec(value):
spec = str(value or "").strip()
if not spec:
return []
values = []
for part in re.split(r"\s+", spec):
if not part:
continue
if "-" in part:
start_text, end_text = [piece.strip() for piece in part.split("-", 1)]
if start_text.isdigit() and end_text.isdigit():
start = int(start_text, 10)
end = int(end_text, 10)
if start <= end:
values.extend(str(number) for number in range(start, end + 1))
continue
values.append(part)
return values
def unique_destination(path):
if not path.exists():
return path
@@ -671,31 +1111,40 @@ def finalize_library_files(job):
staging_dir = job_staging_dir(job)
target_dir = job_output_dir(job)
library_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
media_type = normalize_media_type(job.get("media_type"))
season = season_label(job)
episode_offset = normalize_episode_offset(job.get("episode_offset"))
target_dir.mkdir(parents=True, exist_ok=True)
files = sorted(
[path for path in staging_dir.iterdir() if path.is_file()],
key=lambda path: (path.stat().st_mtime, path.name),
[path for path in staging_dir.rglob("*") if path.is_file()],
key=lambda path: (path.stat().st_mtime, str(path.relative_to(staging_dir)).lower()),
)
if not files:
raise RuntimeError("No downloaded files were found in the staging folder")
moved = []
fallback_episode = 1
requested_episode_values = episode_values_from_spec(job.get("episodes"))
backend = str(job.get("download_backend") or "").strip().lower()
for source in files:
ep = extract_episode_from_filename(source)
if ep is None:
ep = str(fallback_episode)
fallback_episode += 1
final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}"
if media_type == "movie":
final_name = f"{library_name}{source.suffix}"
else:
ep = None
if backend == "anipy-cli" and requested_episode_values:
ep = requested_episode_values.pop(0)
if ep is None:
ep = extract_episode_from_filename(source)
if ep is None:
ep = str(fallback_episode)
fallback_episode += 1
ep = apply_episode_offset(ep, episode_offset)
final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}"
destination = unique_destination(target_dir / final_name)
shutil.move(str(source), str(destination))
moved.append(str(destination))
try:
staging_dir.rmdir()
except OSError:
pass
shutil.rmtree(staging_dir, ignore_errors=True)
return moved
@@ -707,16 +1156,11 @@ def build_job(payload, config):
anime_name = sanitize_path_component(payload.get("anime_name") or title)
if not query:
raise ValueError("Missing search query")
try:
index = int(payload.get("index", 1))
except (TypeError, ValueError) as exc:
raise ValueError("Invalid result index") from exc
if index < 1:
raise ValueError("Result index must be positive")
mode = str(payload.get("mode") or config["mode"]).lower()
quality = str(payload.get("quality") or config["quality"]).lower()
download_dir = str(payload.get("download_dir") or config["download_dir"]).strip()
media_type = normalize_media_type(payload.get("media_type"), default="tv")
if mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub")
if quality not in QUALITY_CHOICES:
@@ -727,9 +1171,11 @@ def build_job(payload, config):
"show_id": str(payload.get("show_id") or "").strip(),
"title": title,
"anime_name": anime_name,
"media_type": media_type,
"season": normalize_season(payload.get("season")),
"episode_offset": normalize_episode_offset(payload.get("episode_offset")),
"query": query,
"result_index": index,
"result_index": None,
"mode": mode,
"quality": quality,
"episodes": validate_episode_spec(payload.get("episodes")),
@@ -745,7 +1191,26 @@ def build_job(payload, config):
}
def command_for_job(job):
def anipy_search_spec(job):
query = re.sub(r"\s+", " ", str(job.get("query") or "").replace(":", " ")).strip()
mode = "dub" if str(job.get("mode") or "").strip().lower() == "dub" else "sub"
return f"{query}:{job['episodes']}:{mode}"
def command_for_job(job, backend="ani-cli", download_path=None):
normalized_backend = str(backend or "ani-cli").strip().lower()
if normalized_backend == "anipy-cli":
command = [
ANIPY_CLI,
"-D",
"-s",
anipy_search_spec(job),
"-q",
job["quality"],
]
if download_path:
command.extend(["-l", str(download_path)])
return command
command = [
ANI_CLI,
"-d",
@@ -753,8 +1218,6 @@ def command_for_job(job):
job["quality"],
"-e",
job["episodes"],
"-S",
str(job["result_index"]),
]
if job["mode"] == "dub":
command.append("--dub")
+609 -83
View File
@@ -134,7 +134,7 @@ CONFIG_HTML = r"""<!doctype html>
gap: 20px;
min-width: 0;
align-content: start;
justify-items: start;
justify-items: stretch;
}
h1, h2, p { margin: 0; }
h1 {
@@ -223,6 +223,9 @@ CONFIG_HTML = r"""<!doctype html>
display: grid;
gap: 10px;
}
.wide-button {
width: 100%;
}
.chips {
display: flex;
flex-wrap: wrap;
@@ -243,6 +246,12 @@ CONFIG_HTML = r"""<!doctype html>
color: var(--muted);
font-size: 12px;
}
.path-input {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -280,14 +289,151 @@ CONFIG_HTML = r"""<!doctype html>
flex-wrap: wrap;
align-items: center;
}
.deps {
justify-items: center;
text-align: center;
}
.deps .chips {
justify-content: center;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
align-items: start;
width: 100%;
}
.settings-main, .deps {
width: min(760px, 100%);
width: 100%;
}
.overlay[hidden] {
display: none !important;
}
.overlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 22px;
background: rgba(4, 3, 10, 0.78);
backdrop-filter: blur(10px);
}
.browser-modal {
width: min(100%, 860px);
max-height: min(84vh, 760px);
overflow: hidden;
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(180deg, rgba(29, 22, 47, 0.96), rgba(17, 13, 29, 0.98));
box-shadow: var(--shadow);
display: grid;
grid-template-rows: auto auto auto 1fr auto;
gap: 12px;
padding: 18px;
}
.browser-header,
.browser-actions,
.browser-footer {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.browser-path {
min-height: 42px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(10, 7, 18, 0.82);
color: var(--text);
padding: 10px 13px;
overflow-wrap: anywhere;
}
.browser-list {
overflow: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
padding: 8px;
display: grid;
gap: 8px;
min-height: 220px;
}
.browser-roots {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--line);
}
.browser-roots .ghost.active {
background: rgba(255, 255, 255, 0.08);
border-color: var(--line-strong);
color: var(--text);
}
.browser-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 44px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
text-align: left;
}
.browser-entry .muted {
font-size: 12px;
}
.browser-entry-name {
overflow-wrap: anywhere;
}
.changelog-modal {
width: min(100%, 900px);
max-height: min(84vh, 820px);
overflow: hidden;
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(180deg, rgba(29, 22, 47, 0.96), rgba(17, 13, 29, 0.98));
box-shadow: var(--shadow);
display: grid;
grid-template-rows: auto 1fr;
gap: 12px;
padding: 18px;
}
.changelog-header {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.changelog-content {
overflow: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
padding: 16px;
}
.changelog-content pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--text);
font: 13px/1.55 "Cascadia Code", "Fira Code", Consolas, monospace;
}
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); }
.toolbar, .row { align-items: stretch; flex-direction: column; }
}
@media (max-width: 1080px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.form-grid, .check-grid {
grid-template-columns: 1fr;
@@ -311,119 +457,292 @@ CONFIG_HTML = r"""<!doctype html>
</div>
</section>
<section class="settings">
<h2>Runtime info</h2>
<h2>Runtime info</h2>
<div class="stack muted">
<p id="versionLine">Loading version...</p>
<p id="aniCliVersionLine">Detecting ani-cli version...</p>
<p id="anipyCliVersionLine">Detecting anipy-cli fallback...</p>
<p>Saved settings live in `.ani-cli-web/config.json` inside this project.</p>
</div>
<button class="ghost wide-button" id="openChangelogBtn" type="button">Changelog</button>
</section>
</aside>
<main>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Defaults</h2>
<p class="muted">Manage the folder, mode, and quality that the Search page starts with.</p>
</div>
<button class="primary" id="saveConfigBtn" type="button">Save</button>
</div>
<div class="notice" id="notice"></div>
<div class="stack">
<label>Default folder
<input id="downloadDir" placeholder="/home/me/Downloads/Anime">
</label>
</div>
<div class="form-grid">
<label>Default mode
<select id="configMode">
<option value="sub">Subtitled (Sub)</option>
<option value="dub">Dubbed (Dub)</option>
</select>
</label>
<label>Default quality
<select id="configQuality">
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
</select>
</label>
</div>
<div class="field-hint">Tip: these are the saved defaults only. The active search form can still be adjusted per download.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Watchlist schedule</h2>
<p class="muted">Periodically queue a background watchlist refresh to check for newly available episodes.</p>
</div>
</div>
<div class="form-grid">
<label>Automatic refresh
<select id="watchlistAutoRefreshEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</label>
<label>Refresh period (minutes)
<input id="watchlistAutoRefreshMinutes" type="number" min="1" max="10080" step="1" value="60">
</label>
<label>Delay between series (seconds)
<input id="watchlistRefreshDelaySeconds" type="number" min="0" max="300" step="1" value="5">
</label>
</div>
<div class="field-hint">When enabled, the server periodically starts the existing background “Refresh All” job using this interval. Bulk refreshes now wait this many seconds between shows to avoid flooding upstream sources.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Discord notifications</h2>
<p class="muted">Send webhook notifications for refresh discoveries, refresh problems, and unexpected runtime errors.</p>
</div>
<button id="testWebhookBtn" type="button">Test webhook</button>
</div>
<div class="stack">
<label>Discord webhook URL
<textarea id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/..."></textarea>
</label>
<div class="field-hint">The new-episodes notification includes one card per anime, with local cached thumbnails attached to the webhook when available.</div>
</div>
<div class="stack">
<label>Notify for</label>
<div class="check-grid">
""" + WEBHOOK_EVENT_OPTIONS + r"""
</div>
</div>
</section>
<section class="deps" id="deps"></section>
<div class="notice" id="notice"></div>
<div class="settings-grid">
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Defaults</h2>
<p class="muted">Manage the folder, mode, and quality that the Search page starts with.</p>
</div>
<button class="primary" id="saveConfigBtn" type="button">Save</button>
</div>
<div class="stack">
<label>Default folder
<div class="path-input">
<input id="downloadDir" placeholder="/home/me/Downloads/Anime">
<button class="ghost browse-path-btn" type="button" data-target="downloadDir" data-mode="dir">Browse</button>
</div>
</label>
</div>
<div class="form-grid">
<label>Default mode
<select id="configMode">
<option value="sub">Subtitled (Sub)</option>
<option value="dub">Dubbed (Dub)</option>
</select>
</label>
<label>Default quality
<select id="configQuality">
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
</select>
</label>
</div>
<div class="field-hint">Tip: these are the saved defaults only. The active search form can still be adjusted per download.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Download fallback</h2>
<p class="muted">If a queued `ani-cli` download fails, optionally retry it once with `anipy-cli`.</p>
</div>
</div>
<div class="form-grid">
<label>Automatic retry with anipy-cli
<select id="anipyCliFallbackEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</label>
</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>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Watchlist schedule</h2>
<p class="muted">Periodically queue a background watchlist refresh to check for newly available episodes.</p>
</div>
</div>
<div class="form-grid">
<label>Automatic refresh
<select id="watchlistAutoRefreshEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</label>
<label>Refresh period (minutes)
<input id="watchlistAutoRefreshMinutes" type="number" min="1" max="10080" step="1" value="60">
</label>
<label>Delay between series (seconds)
<input id="watchlistRefreshDelaySeconds" type="number" min="0" max="300" step="1" value="5">
</label>
</div>
<div class="field-hint">When enabled, the server periodically starts the existing background “Refresh All” job using this interval. Bulk refreshes now wait this many seconds between shows to avoid flooding upstream sources.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Auto-download</h2>
<p class="muted">Automatically queue newly discovered episodes for shows in the `Watching` category after manual or scheduled refreshes.</p>
</div>
</div>
<div class="form-grid">
<label>Automatic queueing
<select id="autoDownloadEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</label>
<label>Default language
<select id="autoDownloadMode">
<option value="dub">Dubbed (Dub)</option>
<option value="sub">Subtitled (Sub)</option>
</select>
</label>
<label>Default quality
<select id="autoDownloadQuality">
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
</select>
</label>
</div>
<div class="field-hint">Adding a new watchlist entry does not auto-download anything. Auto-download only reacts to later manual refreshes or scheduled refreshes that find new episodes.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Jellyfin handoff</h2>
<p class="muted">Move fully completed anime libraries from the default download area into the folders that Jellyfin scans.</p>
</div>
<button id="runJellyfinSyncBtn" type="button">Run now</button>
</div>
<div class="form-grid">
<label>Automatic handoff
<select id="jellyfinSyncEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</label>
<label>Jellyfin TV library
<div class="path-input">
<input id="jellyfinTvDir" placeholder="/srv/media/anime-tv">
<button class="ghost browse-path-btn" type="button" data-target="jellyfinTvDir" data-mode="dir">Browse</button>
</div>
</label>
<label>Jellyfin movie library
<div class="path-input">
<input id="jellyfinMovieDir" placeholder="/srv/media/anime-movies">
<button class="ghost browse-path-btn" type="button" data-target="jellyfinMovieDir" data-mode="dir">Browse</button>
</div>
</label>
</div>
<div class="field-hint">TV entries move only after the show is marked `Finished` and at least one downloaded language covers the expected episode count. Movie entries move after they are downloaded. The manual trigger scans the current watchlist and moves anything already eligible.</div>
<div class="muted" id="jellyfinSyncStatus">No recent Jellyfin handoff job.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Discord notifications</h2>
<p class="muted">Send webhook notifications for refresh discoveries, refresh problems, and unexpected runtime errors.</p>
</div>
<button id="testWebhookBtn" type="button">Test webhook</button>
</div>
<div class="stack">
<label>Discord webhook URL
<textarea id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/..."></textarea>
</label>
<div class="field-hint">The new-episodes notification includes one card per anime, with local cached thumbnails attached to the webhook when available.</div>
</div>
<div class="stack">
<label>Notify for</label>
<div class="check-grid">
""" + WEBHOOK_EVENT_OPTIONS + r"""
</div>
</div>
</section>
</div>
</main>
</div>
<div class="overlay" id="pathBrowserOverlay" hidden>
<div class="browser-modal" role="dialog" aria-modal="true" aria-labelledby="pathBrowserTitle">
<div class="browser-header">
<div>
<h2 id="pathBrowserTitle">Choose path</h2>
<p class="muted" id="pathBrowserSubtitle">Browse folders on the host and select the destination path.</p>
</div>
<button class="ghost" id="pathBrowserCloseBtn" type="button" aria-label="Close path browser">&times;</button>
</div>
<div class="browser-path" id="pathBrowserCurrentPath"></div>
<div class="browser-actions">
<div class="inline-actions">
<button class="ghost" id="pathBrowserHomeBtn" type="button">Home</button>
<button class="ghost" id="pathBrowserUpBtn" type="button">Up</button>
<button class="ghost" id="pathBrowserRefreshBtn" type="button">Refresh</button>
</div>
<div class="muted" id="pathBrowserStatus"></div>
</div>
<div class="browser-list" id="pathBrowserList"></div>
<div class="browser-footer">
<button class="ghost" id="pathBrowserCancelBtn" type="button">Cancel</button>
<button class="primary" id="pathBrowserSelectBtn" type="button">Use this path</button>
</div>
</div>
</div>
<div class="overlay" id="changelogOverlay" hidden>
<div class="changelog-modal" role="dialog" aria-modal="true" aria-labelledby="changelogTitle">
<div class="changelog-header">
<div>
<h2 id="changelogTitle">Changelog</h2>
<p class="muted">Recent release notes loaded from the project's `CHANGELOG.md` file.</p>
</div>
<button class="ghost" id="closeChangelogBtn" type="button" aria-label="Close changelog">&times;</button>
</div>
<div class="changelog-content">
<pre id="changelogContent">Loading changelog...</pre>
</div>
</div>
</div>
<script>
const state = {
config: {
mode: "sub",
quality: "best",
download_dir: "",
anipy_cli_fallback_enabled: false,
watchlist_auto_refresh_enabled: false,
watchlist_auto_refresh_minutes: 60,
watchlist_refresh_delay_seconds: 5,
auto_download_enabled: false,
auto_download_mode: "dub",
auto_download_quality: "best",
jellyfin_sync_enabled: false,
jellyfin_tv_dir: "",
jellyfin_movie_dir: "",
discord_webhook_url: "",
discord_webhook_events: []
}
},
pathBrowser: {
targetId: "",
mode: "dir",
path: "",
parent_path: "",
home_path: ""
},
jellyfinSyncRunning: false,
jellyfinSyncJobId: null
};
const $ = (id) => document.getElementById(id);
""" + BROWSER_API_HELPER + r"""
const pathBrowserOverlay = $("pathBrowserOverlay");
const pathBrowserCurrentPath = $("pathBrowserCurrentPath");
const pathBrowserList = $("pathBrowserList");
const pathBrowserStatus = $("pathBrowserStatus");
const changelogOverlay = $("changelogOverlay");
const changelogContent = $("changelogContent");
function setNotice(text) {
$("notice").textContent = text || "";
}
function setJellyfinSyncButton(running) {
$("runJellyfinSyncBtn").disabled = !!running;
$("runJellyfinSyncBtn").textContent = running ? "Running..." : "Run now";
}
function setJellyfinSyncStatus(job) {
const el = $("jellyfinSyncStatus");
if (!job) {
el.textContent = "No recent Jellyfin handoff job.";
return;
}
if (job.status === "running") {
const title = job.current_title ? ` · ${job.current_title}` : "";
const file = job.current_file ? ` · ${job.current_file}` : "";
el.textContent = `${job.completed || 0}/${job.total || 0} processed · ${job.moved || 0} moved · ${job.moved_files || 0} files${title}${file}`;
return;
}
el.textContent = job.message || "Jellyfin handoff idle.";
}
function selectedWebhookEvents() {
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
}
@@ -433,9 +752,16 @@ CONFIG_HTML = r"""<!doctype html>
download_dir: $("downloadDir").value,
mode: $("configMode").value,
quality: $("configQuality").value,
anipy_cli_fallback_enabled: $("anipyCliFallbackEnabled").value === "true",
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value,
auto_download_enabled: $("autoDownloadEnabled").value === "true",
auto_download_mode: $("autoDownloadMode").value,
auto_download_quality: $("autoDownloadQuality").value,
jellyfin_sync_enabled: $("jellyfinSyncEnabled").value === "true",
jellyfin_tv_dir: $("jellyfinTvDir").value,
jellyfin_movie_dir: $("jellyfinMovieDir").value,
discord_webhook_url: $("discordWebhookUrl").value,
discord_webhook_events: selectedWebhookEvents()
};
@@ -446,9 +772,16 @@ CONFIG_HTML = r"""<!doctype html>
$("downloadDir").value = data.download_dir;
$("configMode").value = data.mode;
$("configQuality").value = data.quality;
$("anipyCliFallbackEnabled").value = String(Boolean(data.anipy_cli_fallback_enabled));
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
$("autoDownloadEnabled").value = String(Boolean(data.auto_download_enabled));
$("autoDownloadMode").value = data.auto_download_mode;
$("autoDownloadQuality").value = data.auto_download_quality;
$("jellyfinSyncEnabled").value = String(Boolean(data.jellyfin_sync_enabled));
$("jellyfinTvDir").value = data.jellyfin_tv_dir || "";
$("jellyfinMovieDir").value = data.jellyfin_movie_dir || "";
$("discordWebhookUrl").value = data.discord_webhook_url || "";
const selected = new Set(data.discord_webhook_events || []);
for (const input of document.querySelectorAll(".webhook-event")) {
@@ -484,6 +817,152 @@ CONFIG_HTML = r"""<!doctype html>
}
}
async function pollJellyfinSyncStatus(notifyOnComplete = true) {
try {
const data = await api("/api/config/jellyfin/sync-status");
const job = data.job || null;
const running = !!data.running && !!job;
const wasRunning = state.jellyfinSyncRunning;
state.jellyfinSyncRunning = running;
state.jellyfinSyncJobId = job ? job.id : null;
setJellyfinSyncButton(running);
setJellyfinSyncStatus(job);
if (running) {
setNotice(job.message || "Jellyfin handoff running...");
} else if (job && wasRunning && notifyOnComplete) {
setNotice(job.message || "Jellyfin handoff completed.");
}
} catch (error) {
setNotice(error.message);
}
}
async function runJellyfinSync() {
try {
const data = await api("/api/config/jellyfin/sync", {
method: "POST",
body: JSON.stringify(formConfigPayload())
});
const job = data.job || null;
state.jellyfinSyncRunning = !!job && (job.status === "pending" || job.status === "running");
state.jellyfinSyncJobId = job ? job.id : null;
setJellyfinSyncButton(state.jellyfinSyncRunning);
setJellyfinSyncStatus(job);
setNotice(data.message || "Jellyfin handoff started.");
} catch (error) {
setNotice(error.message);
}
}
function closePathBrowser() {
state.pathBrowser = {
targetId: "",
mode: "dir",
path: "",
parent_path: "",
home_path: "",
root_paths: []
};
pathBrowserOverlay.hidden = true;
pathBrowserList.innerHTML = "";
pathBrowserStatus.textContent = "";
}
function closeChangelog() {
changelogOverlay.hidden = true;
}
function renderPathBrowser(data) {
state.pathBrowser = {
...state.pathBrowser,
...data
};
pathBrowserCurrentPath.textContent = data.path || "";
pathBrowserStatus.textContent = "";
pathBrowserList.innerHTML = "";
const roots = Array.isArray(data.root_paths) ? data.root_paths : [];
if (roots.length > 1) {
const rootGroup = document.createElement("div");
rootGroup.className = "browser-roots";
for (const rootPath of roots) {
const active = data.path === rootPath || String(data.path || "").startsWith(`${rootPath}/`);
const button = document.createElement("button");
button.type = "button";
button.className = `ghost${active ? " active" : ""}`;
button.textContent = rootPath;
button.addEventListener("click", () => loadPathBrowser(rootPath));
rootGroup.appendChild(button);
}
pathBrowserList.appendChild(rootGroup);
}
if (!Array.isArray(data.entries) || !data.entries.length) {
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "No entries found in this folder.";
pathBrowserList.appendChild(empty);
return;
}
for (const entry of data.entries) {
const button = document.createElement("button");
button.type = "button";
button.className = "browser-entry";
button.innerHTML = `
<span class="browser-entry-name"></span>
<span class="muted"></span>
`;
button.querySelector(".browser-entry-name").textContent = entry.name;
button.querySelector(".muted").textContent = entry.is_dir ? "Folder" : "File";
button.addEventListener("click", async () => {
if (entry.is_dir) {
await loadPathBrowser(entry.path);
return;
}
state.pathBrowser.path = entry.path;
pathBrowserCurrentPath.textContent = entry.path;
});
pathBrowserList.appendChild(button);
}
}
async function loadPathBrowser(path = "") {
try {
pathBrowserStatus.textContent = "Loading...";
const data = await api(`/api/fs/browse?path=${encodeURIComponent(path || state.pathBrowser.path || "")}&mode=${encodeURIComponent(state.pathBrowser.mode || "dir")}`);
renderPathBrowser(data);
} catch (error) {
pathBrowserStatus.textContent = error.message;
}
}
async function openPathBrowser(targetId, mode = "dir") {
state.pathBrowser = {
targetId,
mode,
path: $(targetId).value.trim() || "",
parent_path: "",
home_path: "",
root_paths: []
};
pathBrowserOverlay.hidden = false;
await loadPathBrowser(state.pathBrowser.path);
}
async function browseParentPath() {
if (!state.pathBrowser.parent_path) return;
await loadPathBrowser(state.pathBrowser.parent_path);
}
async function browseHomePath() {
if (!state.pathBrowser.home_path) return;
await loadPathBrowser(state.pathBrowser.home_path);
}
function selectCurrentBrowserPath() {
if (!state.pathBrowser.targetId) return;
$(state.pathBrowser.targetId).value = state.pathBrowser.path || "";
closePathBrowser();
}
async function loadDeps() {
const data = await api("/api/dependencies");
const el = $("deps");
@@ -502,16 +981,63 @@ CONFIG_HTML = r"""<!doctype html>
async function loadVersion() {
const data = await api("/api/version");
$("versionLine").textContent = `${data.name} ${data.version}`;
$("aniCliVersionLine").textContent = `ani-cli ${data.ani_cli_version || "Unavailable"}`;
$("anipyCliVersionLine").textContent = `anipy-cli ${data.anipy_cli_version || "Unavailable"}`;
}
async function openChangelog() {
try {
changelogContent.textContent = "Loading changelog...";
changelogOverlay.hidden = false;
const data = await api("/api/changelog");
changelogContent.textContent = data.content || "Changelog unavailable.";
} catch (error) {
changelogContent.textContent = error.message || "Changelog unavailable.";
}
}
$("saveConfigBtn").addEventListener("click", saveConfig);
$("testWebhookBtn").addEventListener("click", testWebhook);
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
$("openChangelogBtn").addEventListener("click", openChangelog);
$("closeChangelogBtn").addEventListener("click", closeChangelog);
$("pathBrowserCloseBtn").addEventListener("click", closePathBrowser);
$("pathBrowserCancelBtn").addEventListener("click", closePathBrowser);
$("pathBrowserSelectBtn").addEventListener("click", selectCurrentBrowserPath);
$("pathBrowserUpBtn").addEventListener("click", browseParentPath);
$("pathBrowserHomeBtn").addEventListener("click", browseHomePath);
$("pathBrowserRefreshBtn").addEventListener("click", () => loadPathBrowser());
pathBrowserOverlay.addEventListener("click", (event) => {
if (event.target === pathBrowserOverlay) {
closePathBrowser();
}
});
changelogOverlay.addEventListener("click", (event) => {
if (event.target === changelogOverlay) {
closeChangelog();
}
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !pathBrowserOverlay.hidden) {
closePathBrowser();
}
if (event.key === "Escape" && !changelogOverlay.hidden) {
closeChangelog();
}
});
for (const button of document.querySelectorAll(".browse-path-btn")) {
button.addEventListener("click", () => openPathBrowser(button.dataset.target || "", button.dataset.mode || "dir"));
}
(async function init() {
try {
await loadConfig();
await pollJellyfinSyncStatus(false);
await loadDeps();
await loadVersion();
window.setInterval(() => {
pollJellyfinSyncStatus();
}, 2500);
} catch (error) {
setNotice(error.message);
}
+2 -1
View File
@@ -19,7 +19,8 @@ services:
ANI_CLI_WEB_HOST: 0.0.0.0
ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421}
ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false}
ANI_CLI_WEB_REMOTE_TOKEN: ${ANI_CLI_WEB_REMOTE_TOKEN:-}
ANI_CLI_WEB_AUTH_USERNAME: ${ANI_CLI_WEB_AUTH_USERNAME:-}
ANI_CLI_WEB_AUTH_PASSWORD: ${ANI_CLI_WEB_AUTH_PASSWORD:-}
ANI_CLI_DOWNLOAD_DIR: /downloads
volumes:
- ./downloads:/downloads
+348 -88
View File
@@ -6,29 +6,44 @@ import json
import mimetypes
import os
import shutil
import subprocess
import traceback
from base64 import b64decode
from binascii import Error as BinasciiError
from dataclasses import dataclass
from http.cookies import SimpleCookie
from functools import lru_cache
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler
from html import escape as html_escape
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
from app_support import (
ANI_CLI,
ANIPY_CLI,
APP_NAME,
CHANGELOG_FILE,
CLIENT_DISCONNECT_ERRORS,
MAX_JSON_BODY_BYTES,
MODE_CHOICES,
VERSION,
client_address_is_local,
configured_remote_path_roots,
debug_enabled,
debug_log,
load_project_text_file,
cli_executable_exists,
normalize_config,
path_is_within_roots,
remote_access_allowed,
remote_access_token_configured,
remote_access_token_matches,
remote_access_credentials_configured,
remote_access_credentials_match,
remote_access_session_matches,
remote_access_session_value,
sanitize_public_config,
send_discord_webhook_event,
validate_discord_webhook_url,
)
@dataclass(frozen=True)
@@ -39,19 +54,26 @@ class HandlerContext:
ensure_runtime: object
episode_list: object
get_config_snapshot: object
get_jellyfin_sync_status: object
get_watchlist_homepage_summary: object
get_watchlist: object
get_watchlist_refresh_status: object
http_error: object
index_html: str
queue_html: str
remove_from_watchlist: object
run_jellyfin_sync: object
runtime_state: object
save_runtime_config: object
search_anime: object
resolve_search_thumbnail: object
start_jellyfin_sync: object
test_discord_webhook_config: object
thumbnail_file_path: object
update_all_watchlist_statuses: object
update_watchlist_auto_download: object
update_watchlist_category: object
update_watchlist_media_type: object
update_watchlist_status: object
upload_watchlist_thumbnail: object
watchlist_html: str
@@ -67,15 +89,107 @@ def build_handler_class(context):
def dependency_status():
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
result = {name: bool(shutil.which(name)) for name in checks}
result["ani-cli"] = bool(shutil.which(ANI_CLI) or (Path(ANI_CLI).exists() and os.access(ANI_CLI, os.X_OK)))
result["ani-cli"] = cli_executable_exists(ANI_CLI)
result["anipy-cli"] = cli_executable_exists(ANIPY_CLI)
return result
def browse_filesystem(path_value="", mode="dir", allowed_roots=None):
raw_mode = str(mode or "dir").strip().lower()
browse_mode = raw_mode if raw_mode in {"dir", "file"} else "dir"
raw_path = str(path_value or "").strip()
if raw_path:
base_path = Path(raw_path).expanduser()
elif allowed_roots:
base_path = allowed_roots[0]
else:
base_path = Path.home()
try:
resolved = base_path.resolve()
except OSError as exc:
raise ValueError(f"Could not access path: {exc}") from exc
if not resolved.exists():
raise ValueError("Selected path does not exist.")
if resolved.is_file():
resolved = resolved.parent
if not resolved.is_dir():
raise ValueError("Selected path is not a directory.")
if allowed_roots and not path_is_within_roots(resolved, allowed_roots):
raise PermissionError("That path is outside the allowed remote browse roots.")
entries = []
try:
for child in resolved.iterdir():
try:
is_dir = child.is_dir()
except OSError:
continue
if allowed_roots and not path_is_within_roots(child, allowed_roots):
continue
if browse_mode == "dir" and not is_dir:
continue
entries.append(
{
"name": child.name,
"path": str(child),
"is_dir": is_dir,
}
)
except OSError as exc:
raise ValueError(f"Could not list directory: {exc}") from exc
entries.sort(key=lambda item: (not item["is_dir"], item["name"].lower(), item["name"]))
parent_path = str(resolved.parent if resolved.parent != resolved else resolved)
if allowed_roots and not path_is_within_roots(parent_path, allowed_roots):
parent_path = str(resolved)
root_paths = [str(Path(root)) for root in (allowed_roots or [])]
return {
"path": str(resolved),
"parent_path": parent_path,
"home_path": str(allowed_roots[0] if allowed_roots else Path.home()),
"root_paths": root_paths,
"mode": browse_mode,
"entries": entries,
}
@lru_cache(maxsize=1)
def installed_ani_cli_version():
try:
completed = subprocess.run(
[ANI_CLI, "--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"
@lru_cache(maxsize=1)
def installed_anipy_cli_version():
try:
completed = subprocess.run(
[ANIPY_CLI, "--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):
server_version = "AniCliWeb/1.0"
remote_token_cookie_name = "ani_cli_web_token"
remote_token_cookie_max_age_seconds = 30 * 24 * 60 * 60
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status"}
remote_session_cookie_name = "ani_cli_web_session"
remote_session_cookie_max_age_seconds = 30 * 24 * 60 * 60
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status", "/api/config/jellyfin/sync-status"}
def _context(self):
context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None)
@@ -119,15 +233,57 @@ class Handler(BaseHTTPRequestHandler):
return candidate
return peer_host
def _remote_token(self):
return (
self.headers.get("X-AniCli-Web-Token")
or self.headers.get("x-ani-cli-web-token")
or Handler._bearer_token(self)
or Handler._cookie_token(self)
or Handler._query_token(self)
or ""
)
def _authorization_header(self):
return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip()
def _host_header(self):
host = str(self.headers.get("X-Forwarded-Host") or self.headers.get("x-forwarded-host") or "").strip()
if host:
return host.split(",", 1)[0].strip()
return str(self.headers.get("Host") or self.headers.get("host") or "").strip()
def _origin(self):
return str(self.headers.get("Origin") or self.headers.get("origin") or "").strip()
def _content_type(self):
return str(self.headers.get("Content-Type") or self.headers.get("content-type") or "").strip()
def _request_is_secure(self):
forwarded_proto = str(self.headers.get("X-Forwarded-Proto") or self.headers.get("x-forwarded-proto") or "").strip()
if forwarded_proto:
return forwarded_proto.split(",", 1)[0].strip().lower() == "https"
forwarded = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip()
if forwarded:
for part in forwarded.split(";"):
key, _, value = part.partition("=")
if key.strip().lower() == "proto":
return value.strip().strip('"').lower() == "https"
return False
def _origin_matches_host(self):
origin = Handler._origin(self)
if not origin:
return False
parsed = urlparse(origin)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return False
return parsed.netloc.lower() == Handler._host_header(self).lower()
def _basic_auth_credentials(self):
header = Handler._authorization_header(self)
if not header.startswith("Basic "):
return ("", "")
encoded = header[len("Basic ") :].strip()
if not encoded:
return ("", "")
try:
decoded = b64decode(encoded.encode("ascii"), validate=True).decode("utf-8")
except (BinasciiError, UnicodeDecodeError, ValueError):
return ("", "")
username, separator, password = decoded.partition(":")
if not separator:
return ("", "")
return (username.strip(), password.strip())
def _bearer_token(self):
header = str(self.headers.get("Authorization") or "").strip()
@@ -135,7 +291,7 @@ class Handler(BaseHTTPRequestHandler):
return ""
return header[len("Bearer ") :].strip()
def _cookie_token(self):
def _cookie_value(self, name):
raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip()
if not raw:
return ""
@@ -144,21 +300,20 @@ class Handler(BaseHTTPRequestHandler):
cookie.load(raw)
except Exception:
return ""
morsel = cookie.get(Handler.remote_token_cookie_name)
morsel = cookie.get(name)
if not morsel:
return ""
return morsel.value.strip()
def _query_token(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query, keep_blank_values=True)
for key in ("token", "remote_token"):
values = params.get(key) or []
for value in values:
text = str(value).strip()
if text:
return text
return ""
def _session_cookie(self):
return Handler._cookie_value(self, Handler.remote_session_cookie_name)
def _remote_credentials_valid(self):
session = Handler._session_cookie(self)
if session and remote_access_session_matches(session):
return True
username, password = Handler._basic_auth_credentials(self)
return remote_access_credentials_match(username, password)
def _normalized_next_path(self, value):
raw = str(value or "").strip()
@@ -175,55 +330,52 @@ class Handler(BaseHTTPRequestHandler):
query = parsed.query or ""
return urlunparse(("", "", path, "", query, ""))
def _current_path_without_tokens(self, parsed):
def _current_path_without_auth_query(self, parsed):
params = parse_qs(parsed.query, keep_blank_values=True)
params.pop("token", None)
params.pop("remote_token", None)
query = urlencode(params, doseq=True)
return urlunparse(("", "", parsed.path or "/", "", query, ""))
def _remote_auth_cookie(self, token):
return (
f"{Handler.remote_token_cookie_name}={token}; "
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_token_cookie_max_age_seconds}"
def _remote_auth_cookie(self, session_value):
attributes = (
f"{Handler.remote_session_cookie_name}={session_value}; "
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}"
)
if Handler._request_is_secure(self):
attributes += "; Secure"
return attributes
def _set_remote_auth_cookie_and_redirect(self, token, location):
def _clear_remote_auth_cookie(self):
attributes = (
f"{Handler.remote_session_cookie_name}=; "
"Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
)
if Handler._request_is_secure(self):
attributes += "; Secure"
return attributes
def _set_remote_auth_cookie_and_redirect(self, session_value, location):
Handler.write_response_bytes(
self,
b"",
HTTPStatus.SEE_OTHER,
{
"Location": Handler._normalized_next_path(self, location),
"Set-Cookie": Handler._remote_auth_cookie(self, token),
"Set-Cookie": Handler._remote_auth_cookie(self, session_value),
},
)
def _remote_cookie_bootstrap_response(self, parsed):
client_host = Handler._effective_client_host(self)
if client_address_is_local(client_host):
return False
if not remote_access_allowed() or not remote_access_token_configured():
return False
if Handler._cookie_token(self):
return False
token = Handler._query_token(self)
if not token or not remote_access_token_matches(token):
return False
location = Handler._current_path_without_tokens(self, parsed)
Handler._set_remote_auth_cookie_and_redirect(self, token, location)
return True
def _is_browser_page_request(self, parsed):
if getattr(self, "command", "") != "GET":
return False
return not str(parsed.path or "").startswith("/api/")
def _render_remote_login_page(self, parsed, message=""):
next_path = Handler._current_path_without_tokens(self, parsed)
next_path = Handler._current_path_without_auth_query(self, parsed)
error_html = ""
if message:
error_html = f'<p class="error">{message}</p>'
error_html = f'<p class="error">{html_escape(message)}</p>'
html = f"""<!doctype html>
<html lang="en">
<head>
@@ -315,17 +467,20 @@ class Handler(BaseHTTPRequestHandler):
<section class="panel">
<div>
<h1>{APP_NAME}</h1>
<p class="muted">Enter the remote access token for this browser. After that, the app keeps a persistent auth cookie and future visits stay signed in.</p>
<p class="muted">Sign in with the configured username and password. After that, this browser keeps a persistent auth session cookie.</p>
</div>
{error_html}
<form method="post" action="/auth/login">
<input type="hidden" name="next" value="{next_path}">
<label>Remote access token
<input name="token" type="password" autocomplete="current-password" autofocus required>
<input type="hidden" name="next" value="{html_escape(next_path, quote=True)}">
<label>Username
<input name="username" type="text" autocomplete="username" autofocus required>
</label>
<label>Password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Sign in</button>
</form>
<p class="muted">API clients can still use <code>Authorization: Bearer &lt;token&gt;</code> or <code>?token=...</code>.</p>
<p class="muted">API clients can also use <code>Authorization: Basic &lt;base64(username:password)&gt;</code>.</p>
</section>
</body>
</html>
@@ -346,25 +501,45 @@ class Handler(BaseHTTPRequestHandler):
raise PermissionError(
"Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients."
)
if not remote_access_token_configured():
if not remote_access_credentials_configured():
raise PermissionError(
"Remote access requires ANI_CLI_WEB_REMOTE_TOKEN for non-local clients."
"Remote access requires ANI_CLI_WEB_AUTH_USERNAME and ANI_CLI_WEB_AUTH_PASSWORD for non-local clients."
)
if not remote_access_token_matches(Handler._remote_token(self)):
raise PermissionError("Remote access token missing or invalid.")
if not Handler._remote_credentials_valid(self):
raise PermissionError("Remote username or password missing or invalid.")
def enforce_same_origin(self):
origin = Handler._origin(self)
if not origin:
if Handler._session_cookie(self):
raise PermissionError("Browser API requests must include a same-origin Origin header.")
return
if not Handler._origin_matches_host(self):
raise PermissionError("Cross-origin browser requests are not allowed.")
def _remote_path_roots(self):
return configured_remote_path_roots(Handler._context(self).get_config_snapshot())
def validate_remote_path(self, path_value, label):
client_host = Handler._effective_client_host(self)
if client_address_is_local(client_host):
return
roots = Handler._remote_path_roots(self)
if not roots:
raise PermissionError(f"Remote {label} changes are disabled until a server-side path root is configured.")
if not path_is_within_roots(path_value, roots):
raise PermissionError(f"Remote {label} must stay inside the configured remote path roots.")
def do_GET(self):
parsed = urlparse(self.path)
try:
if Handler._remote_cookie_bootstrap_response(self, parsed):
return
if parsed.path == "/auth/login":
if Handler._cookie_token(self) and remote_access_token_matches(Handler._cookie_token(self)):
if Handler._session_cookie(self) and remote_access_session_matches(Handler._session_cookie(self)):
params = parse_qs(parsed.query, keep_blank_values=True)
next_path = (params.get("next") or ["/"])[0]
Handler._set_remote_auth_cookie_and_redirect(self, Handler._cookie_token(self), next_path)
Handler._set_remote_auth_cookie_and_redirect(self, Handler._session_cookie(self), next_path)
return
if remote_access_allowed() and remote_access_token_configured():
if remote_access_allowed() and remote_access_credentials_configured():
Handler._render_remote_login_page(self, parsed)
return
self.ensure_client_access()
@@ -377,11 +552,31 @@ class Handler(BaseHTTPRequestHandler):
elif parsed.path == "/watchlist":
self.html(Handler._context(self).watchlist_html)
elif parsed.path == "/api/config":
self.json(Handler._context(self).get_config_snapshot())
self.json(sanitize_public_config(Handler._context(self).get_config_snapshot()))
elif parsed.path == "/api/version":
self.json({"name": APP_NAME, "version": VERSION})
self.json(
{
"name": APP_NAME,
"version": VERSION,
"ani_cli_version": installed_ani_cli_version(),
"anipy_cli_version": installed_anipy_cli_version(),
}
)
elif parsed.path == "/api/changelog":
self.json({"content": load_project_text_file(CHANGELOG_FILE, default="Changelog unavailable.")})
elif parsed.path == "/api/dependencies":
self.json(dependency_status())
elif parsed.path == "/api/fs/browse":
params = parse_qs(parsed.query)
client_host = Handler._effective_client_host(self)
allowed_roots = None if client_address_is_local(client_host) else Handler._remote_path_roots(self)
self.json(
browse_filesystem(
(params.get("path") or [""])[0],
(params.get("mode") or ["dir"])[0],
allowed_roots=allowed_roots,
)
)
elif parsed.path == "/api/search":
runtime = Handler._runtime(self)
config = runtime["config"]
@@ -393,6 +588,14 @@ class Handler(BaseHTTPRequestHandler):
if mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub")
self.json({"results": Handler._context(self).search_anime(query, mode)})
elif parsed.path == "/api/search/thumb":
Handler._runtime(self)
params = parse_qs(parsed.query)
title = (params.get("title") or [""])[0].strip()
if not title:
raise ValueError("Title is required")
path = Handler._context(self).resolve_search_thumbnail(title)
self.file(path)
elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"):
runtime = Handler._runtime(self)
config = runtime["config"]
@@ -408,6 +611,14 @@ class Handler(BaseHTTPRequestHandler):
page = (params.get("page") or ["1"])[0]
per_page = (params.get("per_page") or ["10"])[0]
self.json(runtime["download_queue"].list(page=page, per_page=per_page))
elif parsed.path.startswith("/api/queue/"):
runtime = Handler._runtime(self)
parts = parsed.path.strip("/").split("/")
if len(parts) != 3:
self.error(HTTPStatus.NOT_FOUND, "Not found")
return
_, _, job_id = parts
self.json(runtime["download_queue"].get(job_id))
elif parsed.path.startswith("/api/watchlist/thumb/"):
runtime = Handler._runtime(self)
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
@@ -420,6 +631,9 @@ class Handler(BaseHTTPRequestHandler):
return
debug_log("thumbnail.route.hit", show_id=show_id, resolved_path=path)
self.file(path)
elif parsed.path == "/api/watchlist/homepage":
Handler._runtime(self)
self.json(Handler._context(self).get_watchlist_homepage_summary())
elif parsed.path in {"/api/watchlist", "/api/watchlist/get"}:
Handler._runtime(self)
params = parse_qs(parsed.query)
@@ -430,6 +644,9 @@ class Handler(BaseHTTPRequestHandler):
elif parsed.path == "/api/watchlist/refresh-status":
Handler._runtime(self)
self.json(Handler._context(self).get_watchlist_refresh_status())
elif parsed.path == "/api/config/jellyfin/sync-status":
Handler._runtime(self)
self.json(Handler._context(self).get_jellyfin_sync_status())
else:
self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
@@ -439,29 +656,48 @@ class Handler(BaseHTTPRequestHandler):
parsed = urlparse(self.path)
try:
if parsed.path == "/auth/login":
if not remote_access_allowed() or not remote_access_token_configured():
if not remote_access_allowed() or not remote_access_credentials_configured():
raise PermissionError("Remote access sign-in is not available.")
payload = Handler.body_form(self)
token = str(payload.get("token") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "").strip()
next_path = payload.get("next") or "/"
if not remote_access_token_matches(token):
if not remote_access_credentials_match(username, password):
Handler._render_remote_login_page(
self,
urlparse(Handler._normalized_next_path(self, next_path)),
"Remote access token missing or invalid.",
"Username or password missing or invalid.",
)
return
Handler._set_remote_auth_cookie_and_redirect(self, token, next_path)
Handler._set_remote_auth_cookie_and_redirect(self, remote_access_session_value(), next_path)
return
self.ensure_client_access()
Handler.enforce_same_origin(self)
if parsed.path == "/api/config":
Handler.update_config(self, Handler.require_json_object(self, self.body_json()))
elif parsed.path == "/api/config/jellyfin/sync":
payload = Handler.require_json_object(self, self.body_json())
current = Handler._context(self).get_config_snapshot()
merged = dict(current)
merged.update(payload)
for key, label in (
("download_dir", "download directory"),
("jellyfin_tv_dir", "Jellyfin TV directory"),
("jellyfin_movie_dir", "Jellyfin movie directory"),
):
if str(merged.get(key) or "").strip():
Handler.validate_remote_path(self, merged[key], label)
self.json(Handler._context(self).start_jellyfin_sync(merged), HTTPStatus.ACCEPTED)
elif parsed.path == "/api/config/webhook/test":
payload = Handler.require_json_object(self, self.body_json())
validate_discord_webhook_url(payload.get("discord_webhook_url"))
self.json(Handler._context(self).test_discord_webhook_config(payload))
elif parsed.path == "/api/queue":
runtime = Handler._runtime(self)
self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED)
payload = Handler.require_json_object(self, self.body_json())
if str(payload.get("download_dir") or "").strip():
Handler.validate_remote_path(self, payload["download_dir"], "download directory")
self.json(runtime["download_queue"].add(payload), HTTPStatus.CREATED)
elif parsed.path == "/api/queue/retry-failed":
runtime = Handler._runtime(self)
self.json(runtime["download_queue"].retry_all_failed())
@@ -499,16 +735,6 @@ class Handler(BaseHTTPRequestHandler):
force=Handler.optional_bool_field(self, payload, "force", default=False),
)
)
elif parsed.path == "/api/watchlist/ensure-alt-titles":
runtime = Handler._runtime(self)
payload = Handler.require_json_object(self, self.body_json())
self.json(
runtime["watchlist"].ensure_alt_titles_many(
Handler.optional_list_field(self, payload, "show_ids") or [],
limit=payload.get("limit", 6),
force=Handler.optional_bool_field(self, payload, "force", default=False),
)
)
elif parsed.path == "/api/watchlist/update-status":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id")
@@ -521,6 +747,24 @@ class Handler(BaseHTTPRequestHandler):
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category")))
elif parsed.path == "/api/watchlist/update-media-type":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(Handler._context(self).update_watchlist_media_type(payload["show_id"], payload.get("media_type")))
elif parsed.path == "/api/watchlist/update-auto-download":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(
Handler._context(self).update_watchlist_auto_download(
payload["show_id"],
payload.get("mode"),
payload.get("quality"),
payload.get("name"),
payload.get("source_name"),
payload.get("series"),
payload.get("episode_offset"),
)
)
elif parsed.path == "/api/watchlist/update-all":
Handler._runtime(self)
result = Handler._context(self).update_all_watchlist_statuses()
@@ -540,11 +784,24 @@ class Handler(BaseHTTPRequestHandler):
self.exception(exc)
def update_config(self, payload):
config = normalize_config(payload)
payload = Handler.require_json_object(self, payload)
current = Handler._context(self).get_config_snapshot()
merged = dict(current)
merged.update(payload)
config = normalize_config(merged)
for key, label in (
("download_dir", "download directory"),
("jellyfin_tv_dir", "Jellyfin TV directory"),
("jellyfin_movie_dir", "Jellyfin movie directory"),
):
if str(config.get(key) or "").strip():
Handler.validate_remote_path(self, config[key], label)
if "discord_webhook_url" in payload and str(config.get("discord_webhook_url") or "").strip():
validate_discord_webhook_url(config["discord_webhook_url"])
Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
context = Handler._context(self)
context.save_runtime_config(config)
self.json(context.get_config_snapshot())
context.save_runtime_config(payload)
self.json(sanitize_public_config(context.get_config_snapshot()))
def require_fields(self, payload, *names):
payload = Handler.require_json_object(self, payload)
@@ -585,6 +842,9 @@ class Handler(BaseHTTPRequestHandler):
raise ValueError(f"Field '{name}' must be a boolean")
def body_json(self):
content_type = Handler._content_type(self).split(";", 1)[0].strip().lower()
if content_type != "application/json":
raise ValueError("Request body must use Content-Type: application/json.")
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError as exc:
@@ -669,7 +929,7 @@ class Handler(BaseHTTPRequestHandler):
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
elif isinstance(exc, PermissionError):
parsed = urlparse(getattr(self, "path", "") or "/")
if remote_access_allowed() and remote_access_token_configured() and Handler._is_browser_page_request(self, parsed):
if remote_access_allowed() and remote_access_credentials_configured() and Handler._is_browser_page_request(self, parsed):
Handler._render_remote_login_page(self, parsed, str(exc))
else:
self.error(HTTPStatus.FORBIDDEN, str(exc))
+554 -52
View File
@@ -10,20 +10,24 @@ import sqlite3
import subprocess
import threading
import time
import re
from app_support import (
ANIPY_CLI,
MAX_LOG_LINES,
PROJECT_ROOT,
QUEUE_PATH,
STATE_DB_PATH,
WORKER_SHUTDOWN_TIMEOUT_SECONDS,
build_job,
cli_executable_exists,
command_for_job,
debug_log,
finalize_library_files,
job_output_dir,
job_staging_dir,
load_json,
normalize_config,
now_iso,
printable_command,
send_discord_webhook_event,
@@ -37,6 +41,7 @@ JOB_STRUCTURED_COLUMNS = (
"show_id",
"title",
"anime_name",
"media_type",
"season",
"query",
"result_index",
@@ -266,7 +271,7 @@ class WatchlistRefreshJobs:
new_episode_items = []
refresh_error_items = []
try:
show_ids = self.store.all_show_ids()
show_ids = self.store.all_show_ids(categories=("watching", "planned"))
delay_seconds = self._refresh_delay_seconds()
started_at = now_iso()
with self.lock:
@@ -291,7 +296,12 @@ class WatchlistRefreshJobs:
skipped = False
error_message = ""
try:
item = self.store.refresh(show_id, include_thumbnail=False)
try:
item = self.store.refresh(show_id, include_thumbnail=False, refresh_source=source_name)
except TypeError as exc:
if "refresh_source" not in str(exc):
raise
item = self.store.refresh(show_id, include_thumbnail=False)
if item is None:
skipped = True
else:
@@ -511,6 +521,326 @@ class WatchlistAutoRefreshScheduler:
self.wakeup.clear()
class JellyfinSyncJobs:
CONFIG_KEYS = (
"download_dir",
"jellyfin_sync_enabled",
"jellyfin_tv_dir",
"jellyfin_movie_dir",
"discord_webhook_url",
"discord_webhook_events",
)
def __init__(self, store, sync_fn, config_getter=None, start_worker=True):
self.store = store
self.sync_fn = sync_fn
self.config_getter = config_getter or (lambda: {})
self.lock = threading.RLock()
self.wakeup = threading.Event()
self.stop_event = threading.Event()
self.pending = []
self.jobs = []
self.current_job_id = None
self._init_db()
self._restore_interrupted_jobs()
self._load_jobs()
self.worker = None
if start_worker:
self.ensure_worker()
def _connect(self):
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
return conn
def ensure_worker(self):
with self.lock:
if self.worker is not None and self.worker.is_alive():
return
self.stop_event.clear()
self.worker = threading.Thread(target=self._run, daemon=True)
self.worker.start()
def shutdown(self, wait=False):
self.stop_event.set()
self.wakeup.set()
worker = self.worker
if wait and worker is not None and worker.is_alive():
worker.join(timeout=WORKER_SHUTDOWN_TIMEOUT_SECONDS)
return worker is None or not worker.is_alive()
def _init_db(self):
STATE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS jellyfin_sync_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
payload TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_jellyfin_sync_created_at ON jellyfin_sync_jobs(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_jellyfin_sync_status ON jellyfin_sync_jobs(status)")
def _job_from_row(self, row):
payload = row["payload"] if isinstance(row, sqlite3.Row) else row[0]
return json.loads(payload)
def _upsert_job_conn(self, conn, job):
conn.execute(
"""
INSERT INTO jellyfin_sync_jobs (id, status, created_at, updated_at, payload)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
updated_at = excluded.updated_at,
payload = excluded.payload
""",
(
job["id"],
job["status"],
job["created_at"],
job["updated_at"],
json.dumps(job, sort_keys=True),
),
)
def _trim_history_conn(self, conn):
rows = conn.execute(
"SELECT id FROM jellyfin_sync_jobs ORDER BY created_at DESC LIMIT -1 OFFSET 10"
).fetchall()
if rows:
conn.executemany("DELETE FROM jellyfin_sync_jobs WHERE id = ?", [(row["id"],) for row in rows])
def _save_job_locked(self, job):
with self._connect() as conn:
self._upsert_job_conn(conn, job)
self._trim_history_conn(conn)
def _load_jobs(self):
with self.lock, self._connect() as conn:
rows = conn.execute(
"SELECT payload FROM jellyfin_sync_jobs ORDER BY created_at DESC LIMIT 10"
).fetchall()
self.jobs = [self._job_from_row(row) for row in rows]
def _restore_interrupted_jobs(self):
with self.lock, self._connect() as conn:
rows = conn.execute(
"SELECT payload FROM jellyfin_sync_jobs WHERE status IN ('pending', 'running')"
).fetchall()
for row in rows:
job = self._job_from_row(row)
job["status"] = "interrupted"
job["finished_at"] = now_iso()
job["updated_at"] = job["finished_at"]
job["current_title"] = None
job["current_file"] = None
job["message"] = "Interrupted by web app restart."
self._upsert_job_conn(conn, job)
self._trim_history_conn(conn)
def _copy_job(self, job):
return json.loads(json.dumps(job))
def _find_locked(self, job_id):
for job in self.jobs:
if job["id"] == job_id:
return job
return None
def _job_config(self, config):
normalized = normalize_config(config or self.config_getter() or {})
return {key: normalized.get(key) for key in self.CONFIG_KEYS}
def status(self):
with self.lock:
job = next((candidate for candidate in self.jobs if candidate["status"] in {"pending", "running"}), None)
if job is None:
job = self.jobs[0] if self.jobs else None
active = bool(job and job["status"] in {"pending", "running"})
return {"job": self._copy_job(job) if job else None, "running": active}
def start(self, config=None, source="manual"):
source_name = str(source or "manual").strip().lower() or "manual"
with self.lock:
job = next((candidate for candidate in self.jobs if candidate["status"] in {"pending", "running"}), None)
if job is not None:
return {
"message": "Jellyfin handoff already queued." if job["status"] == "pending" else "Jellyfin handoff already running.",
"job": self._copy_job(job),
"created": False,
}
now = now_iso()
job = {
"id": os.urandom(16).hex(),
"status": "pending",
"source": source_name,
"config": self._job_config(config),
"created_at": now,
"updated_at": now,
"started_at": None,
"finished_at": None,
"total": 0,
"completed": 0,
"moved": 0,
"errors": 0,
"moved_files": 0,
"current_title": None,
"current_file": None,
"message": "Queued Jellyfin handoff.",
"items": [],
}
self.jobs.insert(0, job)
self.jobs = self.jobs[:10]
self._save_job_locked(job)
self.pending.append(job["id"])
self.wakeup.set()
return {"message": "Jellyfin handoff started.", "job": self._copy_job(job), "created": True}
def _next_pending(self):
with self.lock:
while self.pending:
job_id = self.pending.pop(0)
job = self._find_locked(job_id)
if job is not None:
self.current_job_id = job_id
return job
return None
def _run(self):
while not self.stop_event.is_set():
job = self._next_pending()
if not job:
self.wakeup.wait(2)
self.wakeup.clear()
continue
self._run_job(job)
def _run_job(self, job):
interrupted = False
result_items = []
try:
active_config = self._job_config(job.get("config") or self.config_getter() or {})
show_ids = self.store.all_show_ids()
started_at = now_iso()
with self.lock:
job["status"] = "running"
job["started_at"] = started_at
job["updated_at"] = started_at
job["total"] = len(show_ids)
job["completed"] = 0
job["moved"] = 0
job["errors"] = 0
job["moved_files"] = 0
job["current_title"] = None
job["current_file"] = None
job["items"] = []
job["message"] = f"Moving 0/{len(show_ids)} watchlist entries to Jellyfin."
self._save_job_locked(job)
for index, show_id in enumerate(show_ids, start=1):
if self.stop_event.is_set():
interrupted = True
break
try:
item = self.store.get(show_id)
except KeyError:
item = None
if item is None:
with self.lock:
job["completed"] = index
job["updated_at"] = now_iso()
job["message"] = f"Moving {index}/{len(show_ids)}: removed entry"
self._save_job_locked(job)
continue
title = str(item.get("title") or show_id)
def progress(update):
with self.lock:
job["current_title"] = title
job["current_file"] = update.get("current_file")
job["updated_at"] = now_iso()
file_total = int(update.get("file_total") or 0)
file_index = int(update.get("file_index") or 0)
if file_total > 0:
job["message"] = (
f"Moving {index}/{len(show_ids)}: {title} "
f"({file_index}/{file_total} files)"
)
else:
job["message"] = f"Moving {index}/{len(show_ids)}: {title}"
self._save_job_locked(job)
result = self.sync_fn(item, automatic=False, config=active_config, progress_fn=progress)
result_items.append(
{
"show_id": item.get("show_id"),
"title": item.get("title"),
"media_type": item.get("media_type"),
"moved": bool(result.get("moved")),
"reason": result.get("reason"),
"target": result.get("target"),
}
)
moved_files = len(result.get("moved_files") or []) if isinstance(result.get("moved_files"), list) else 0
errored = str(result.get("reason") or "").strip() in {"error", "target_missing", "source_missing", "target_incomplete"}
with self.lock:
job["completed"] = index
job["moved"] += 1 if result.get("moved") else 0
job["moved_files"] += moved_files
job["errors"] += 1 if errored else 0
job["current_title"] = title
job["current_file"] = None
job["updated_at"] = now_iso()
job["items"] = result_items[-20:]
if errored and result.get("error"):
job["last_error"] = str(result.get("error"))
outcome = "moved" if result.get("moved") else str(result.get("reason") or "skipped")
job["message"] = f"Moving {index}/{len(show_ids)}: {title} ({outcome})"
self._save_job_locked(job)
finished_at = now_iso()
with self.lock:
job["status"] = "interrupted" if interrupted else "done"
job["finished_at"] = finished_at
job["updated_at"] = finished_at
job["current_title"] = None
job["current_file"] = None
job["items"] = result_items[-20:]
if interrupted:
job["message"] = f"Jellyfin handoff interrupted after {job['completed']} entries."
elif job["errors"]:
job["message"] = (
f"Moved {job['moved']} of {job['completed']} processed watchlist entries to Jellyfin "
f"with {job['errors']} issues."
)
else:
job["message"] = (
f"Moved {job['moved']} watchlist entr{'y' if job['moved'] == 1 else 'ies'} to Jellyfin libraries."
)
self._save_job_locked(job)
except Exception as exc:
finished_at = now_iso()
with self.lock:
job["status"] = "failed"
job["finished_at"] = finished_at
job["updated_at"] = finished_at
job["current_title"] = None
job["current_file"] = None
job["message"] = f"Jellyfin handoff failed: {exc}"
job["last_error"] = str(exc)
self._save_job_locked(job)
finally:
with self.lock:
self.current_job_id = None
class DownloadQueue:
def __init__(self, config_getter, watchlist_sync_fn=None, job_prepare_fn=None, start_worker=True):
self.config_getter = config_getter
@@ -590,6 +920,7 @@ class DownloadQueue:
show_id TEXT,
title TEXT,
anime_name TEXT,
media_type TEXT,
season TEXT,
query TEXT,
result_index INTEGER,
@@ -615,6 +946,7 @@ class DownloadQueue:
("show_id", "ALTER TABLE jobs ADD COLUMN show_id TEXT"),
("title", "ALTER TABLE jobs ADD COLUMN title TEXT"),
("anime_name", "ALTER TABLE jobs ADD COLUMN anime_name TEXT"),
("media_type", "ALTER TABLE jobs ADD COLUMN media_type TEXT"),
("season", "ALTER TABLE jobs ADD COLUMN season TEXT"),
("query", "ALTER TABLE jobs ADD COLUMN query TEXT"),
("result_index", "ALTER TABLE jobs ADD COLUMN result_index INTEGER"),
@@ -716,17 +1048,18 @@ class DownloadQueue:
"""
INSERT INTO jobs (
id, status, created_at, updated_at,
show_id, title, anime_name, season, query, result_index, mode, quality, episodes, download_dir,
show_id, title, anime_name, media_type, season, query, result_index, mode, quality, episodes, download_dir,
started_at, finished_at, exit_code, pid, command, staging_dir, target_dir, cancel_requested,
log_payload, payload
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
updated_at = excluded.updated_at,
show_id = excluded.show_id,
title = excluded.title,
anime_name = excluded.anime_name,
media_type = excluded.media_type,
season = excluded.season,
query = excluded.query,
result_index = excluded.result_index,
@@ -753,6 +1086,7 @@ class DownloadQueue:
job.get("show_id"),
job.get("title"),
job.get("anime_name"),
job.get("media_type"),
job.get("season"),
job.get("query"),
job.get("result_index"),
@@ -798,6 +1132,10 @@ class DownloadQueue:
"pages": pages,
}
def get(self, job_id):
with self.lock:
return self._find(job_id)
def add(self, payload):
job = build_job(payload, self.config_getter())
if self.job_prepare_fn is not None:
@@ -809,17 +1147,87 @@ class DownloadQueue:
self.wakeup.set()
return job
def _expand_episode_spec(self, episode_spec, available_episodes):
spec = str(episode_spec or "").strip()
values = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
if not spec or not values:
return []
expanded = []
seen = set()
parts = [part for part in re.split(r"\s+", spec) if part]
index_map = {value: index for index, value in enumerate(values)}
for part in parts:
if "-" in part:
start, end = [piece.strip() for piece in part.split("-", 1)]
if start in index_map and end in index_map:
start_index = index_map[start]
end_index = index_map[end]
if start_index > end_index:
start_index, end_index = end_index, start_index
for value in values[start_index : end_index + 1]:
if value not in seen:
seen.add(value)
expanded.append(value)
continue
if part in index_map and part not in seen:
seen.add(part)
expanded.append(part)
return expanded
def covered_episodes(self, show_id, mode, available_episodes):
normalized_show_id = str(show_id or "").strip()
normalized_mode = str(mode or "").strip().lower()
if not normalized_show_id or not normalized_mode:
return set()
with self.lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT episodes
FROM jobs
WHERE show_id = ? AND mode = ? AND status IN ('pending', 'running', 'done')
ORDER BY created_at ASC
""",
(normalized_show_id, normalized_mode),
).fetchall()
covered = set()
for row in rows:
covered.update(self._expand_episode_spec((row["episodes"] if isinstance(row, sqlite3.Row) else row[0]), available_episodes))
return covered
def detach_show(self, show_id):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
return {"ok": True, "count": 0}
count = 0
with self.lock, self._connect() as conn:
rows = conn.execute("SELECT * FROM jobs WHERE show_id = ?", (normalized_show_id,)).fetchall()
for row in rows:
job = self._job_from_row(row)
job["show_id"] = ""
job["skip_watchlist_sync"] = True
job["updated_at"] = now_iso()
self._upsert_job_conn(conn, job)
count += 1
if self.current_job is not None and str(self.current_job.get("show_id") or "").strip() == normalized_show_id:
self.current_job["show_id"] = ""
self.current_job["skip_watchlist_sync"] = True
self.current_job["updated_at"] = now_iso()
return {"ok": True, "count": count}
def retry(self, job_id):
with self.lock:
job = self._find(job_id)
if job["status"] == "running":
raise ValueError("Running jobs cannot be retried")
if job["status"] not in {"failed", "canceled"}:
raise ValueError("Only failed or canceled jobs can be retried")
job["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)
self.wakeup.set()
@@ -967,6 +1375,36 @@ class DownloadQueue:
self._save_job_locked(job)
self._cleanup_staging_dir(job)
def _command_attempts(self, job, staging_dir):
config = normalize_config(self.config_getter() or {})
attempts = [
{
"backend": "ani-cli",
"command": command_for_job(job, backend="ani-cli"),
"env": {
**os.environ.copy(),
"ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
"ANI_CLI_MODE": job["mode"],
"ANI_CLI_QUALITY": job["quality"],
"TERM": os.environ.get("TERM", "xterm-256color"),
},
}
]
fallback_enabled = bool(config.get("anipy_cli_fallback_enabled"))
fallback_available = cli_executable_exists(ANIPY_CLI)
if fallback_enabled and fallback_available:
attempts.append(
{
"backend": "anipy-cli",
"command": command_for_job(job, backend="anipy-cli", download_path=staging_dir),
"env": {
**os.environ.copy(),
"TERM": os.environ.get("TERM", "xterm-256color"),
},
}
)
return attempts, fallback_enabled, fallback_available
def _run(self):
while not self.stop_event.is_set():
job = self._next_pending()
@@ -977,79 +1415,129 @@ class DownloadQueue:
self._run_job(job)
def _run_job(self, job):
command = command_for_job(job)
staging_dir = job_staging_dir(job)
target_dir = job_output_dir(job)
env = os.environ.copy()
env.update(
{
"ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
"ANI_CLI_MODE": job["mode"],
"ANI_CLI_QUALITY": job["quality"],
"TERM": env.get("TERM", "xterm-256color"),
}
)
staging_dir.mkdir(parents=True, exist_ok=True)
target_dir.mkdir(parents=True, exist_ok=True)
attempts, fallback_enabled, fallback_available = self._command_attempts(job, staging_dir)
primary_command = attempts[0]["command"]
with self.lock:
job["status"] = "running"
job["started_at"] = now_iso()
job["updated_at"] = job["started_at"]
job["command"] = printable_command(command)
job["command"] = printable_command(primary_command)
job["staging_dir"] = str(staging_dir)
job["target_dir"] = str(target_dir)
job["download_backend"] = "ani-cli"
job["fallback_used"] = False
job["log"] = [
f"Starting: {job['command']}",
f"Staging in: {job['staging_dir']}",
f"Final folder: {job['target_dir']}",
]
if fallback_enabled and fallback_available:
job["log"].append("anipy-cli fallback is enabled and will retry automatically if ani-cli fails.")
elif fallback_enabled:
job["log"].append("anipy-cli fallback is enabled, but anipy-cli is not installed or not executable.")
job["cancel_requested"] = False
job["_dirty_log_lines"] = 0
self._save_job_locked(job)
try:
process = subprocess.Popen(
command,
cwd=str(PROJECT_ROOT),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
start_new_session=True,
)
except Exception as exc:
self._fail_job_startup(job, exc)
return
with self.lock:
self.current_process = process
self.current_job_id = job["id"]
self.current_job = job
job["pid"] = process.pid
self._save_job_locked(job)
try:
assert process.stdout is not None
for line in process.stdout:
self._append_log(job, line)
exit_code = process.wait()
exit_code = 1
successful_backend = None
finalize_error = None
moved_files = []
watchlist_sync_error = None
synced_watchlist_item = None
cleanup_staging = False
if exit_code == 0 and not job.get("cancel_requested"):
for index, attempt in enumerate(attempts):
backend = attempt["backend"]
command = attempt["command"]
should_fallback = index + 1 < len(attempts)
with self.lock:
job["download_backend"] = backend
job["command"] = printable_command(command)
if index > 0:
job["fallback_used"] = True
job["pid"] = None
job["updated_at"] = now_iso()
if index > 0:
job.setdefault("log", []).append(f"Retrying with {backend}: {job['command']}")
self._save_job_locked(job)
try:
moved_files = finalize_library_files(job)
process = subprocess.Popen(
command,
cwd=str(PROJECT_ROOT),
env=attempt["env"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
start_new_session=True,
)
except Exception as exc:
finalize_error = exc
else:
if self.watchlist_sync_fn is not None:
try:
synced_watchlist_item = self.watchlist_sync_fn(job)
except Exception as exc:
watchlist_sync_error = exc
if should_fallback:
self._append_log(job, f"ani-cli could not start: {exc}")
self._append_log(job, "Retrying with anipy-cli fallback.")
self._cleanup_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True)
continue
self._fail_job_startup(job, exc)
return
with self.lock:
self.current_process = process
self.current_job_id = job["id"]
self.current_job = job
job["pid"] = process.pid
self._save_job_locked(job)
assert process.stdout is not None
for line in process.stdout:
self._append_log(job, line)
exit_code = process.wait()
with self.lock:
job["pid"] = None
self.current_process = None
self.current_job_id = None
self.current_job = None
self._save_job_locked(job)
if job.get("cancel_requested"):
break
if backend == "ani-cli" and exit_code != 0 and should_fallback:
self._append_log(job, f"ani-cli failed with exit code {exit_code}.")
self._append_log(job, "Retrying with anipy-cli fallback.")
self._cleanup_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True)
continue
if exit_code == 0:
try:
moved_files = finalize_library_files(job)
except Exception as exc:
finalize_error = exc
no_files_downloaded = "No downloaded files were found in the staging folder" in str(exc)
if backend == "ani-cli" and no_files_downloaded and should_fallback:
self._append_log(
job,
"ani-cli exited without downloading any files. Retrying with anipy-cli fallback.",
)
self._cleanup_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True)
finalize_error = None
continue
else:
successful_backend = backend
if self.watchlist_sync_fn is not None:
try:
synced_watchlist_item = self.watchlist_sync_fn(job)
except Exception as exc:
watchlist_sync_error = exc
break
break
with self.lock:
canceled = job.get("cancel_requested")
@@ -1062,19 +1550,33 @@ class DownloadQueue:
job.setdefault("log", []).append("Canceled.")
elif finalize_error:
job["status"] = "failed"
job.setdefault("log", []).append(f"Library rename failed: {finalize_error}")
message = str(finalize_error).strip()
if "No downloaded files were found in the staging folder" in message:
job.setdefault("log", []).append(
f"{job.get('download_backend') or 'Downloader'} exited without downloading any files. The selected result or episodes may not be downloadable."
)
else:
job.setdefault("log", []).append(f"Library rename failed: {finalize_error}")
cleanup_staging = True
elif exit_code == 0:
job["status"] = "done"
if successful_backend == "anipy-cli":
job.setdefault("log", []).append("Fallback download completed with anipy-cli.")
for path in moved_files:
job.setdefault("log", []).append(f"Saved: {path}")
job.setdefault("log", []).append("Download completed.")
if watchlist_sync_error:
job.setdefault("log", []).append(f"Watchlist sync failed: {watchlist_sync_error}")
elif self.watchlist_sync_fn is not None:
job.setdefault("log", []).append("Watchlist download state synced.")
if job.get("skip_watchlist_sync"):
job.setdefault("log", []).append("Watchlist sync skipped for detached job.")
else:
job.setdefault("log", []).append("Watchlist download state synced.")
else:
job["status"] = "failed"
job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.")
job.setdefault("log", []).append(
f"{job.get('download_backend') or 'Download'} failed with exit code {exit_code}."
)
cleanup_staging = True
if canceled:
cleanup_staging = True
+102 -56
View File
@@ -326,7 +326,7 @@ QUEUE_HTML = r"""<!doctype html>
<h2>What you can do here</h2>
<p class="muted">Search stays focused on finding shows and adding new jobs. Queue now handles status, logs, retries, and cleanup as a separate workflow.</p>
</div>
<span class="badge">Auto refresh every 2.5s</span>
<span class="badge">Active jobs update every 2.5s</span>
</section>
</aside>
@@ -351,7 +351,8 @@ QUEUE_HTML = r"""<!doctype html>
queueTotal: 0,
queueRequestId: 0,
queueTimer: null,
collapsedJobs: {}
collapsedJobs: {},
jobCards: {}
};
const $ = (id) => document.getElementById(id);
@@ -382,11 +383,89 @@ QUEUE_HTML = r"""<!doctype html>
state.collapsedJobs[jobId] = collapsed;
}
function isTerminalStatus(status) {
return ["done", "failed", "canceled"].includes(status);
}
function buildJobSummary(job) {
const libraryName = job.anime_name || job.title;
const season = String(job.season || "1").padStart(2, "0");
const seasonFolder = `Season ${season}`;
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
return `${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`;
}
function buildJobLog(job) {
return [...(job.log || [])].slice(-28).reverse().join("\n");
}
function renderJobActions(job, actions) {
actions.innerHTML = "";
if (job.status === "running" || job.status === "pending") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
return;
}
if (job.status === "failed" || job.status === "canceled") {
actions.append(actionButton("Retry", () => queueAction(job.id, "retry")));
}
actions.append(actionButton("Remove", () => queueAction(job.id, "remove")));
}
function updateJobCard(item, job) {
item.dataset.jobId = job.id;
item.dataset.jobStatus = job.status;
item.querySelector(".job-title").textContent = job.title;
item.querySelector(".job-head .muted").textContent = buildJobSummary(job);
const status = item.querySelector(".status");
status.textContent = job.status;
status.className = "status";
status.classList.add(job.status);
item.querySelector("pre").textContent = buildJobLog(job) || "Waiting...";
item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`;
renderJobActions(job, item.querySelector(".row"));
state.jobCards[job.id] = item;
}
function createJobCard(job) {
const item = document.createElement("article");
item.className = "job";
const collapsed = isCollapsed(job);
if (collapsed) item.classList.add("collapsed");
item.innerHTML = `
<div class="job-head">
<button class="job-toggle ghost" type="button" aria-label="Toggle job details" aria-expanded="true"></button>
<div>
<div class="job-title"></div>
<p class="muted"></p>
</div>
<span class="status"></span>
</div>
<div class="job-body">
<pre></pre>
<div class="toolbar">
<span class="muted"></span>
<div class="row" style="flex:0 0 auto"></div>
</div>
</div>
`;
const toggle = item.querySelector(".job-toggle");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
toggle.addEventListener("click", () => {
const nextCollapsed = !item.classList.contains("collapsed");
item.classList.toggle("collapsed", nextCollapsed);
toggle.setAttribute("aria-expanded", nextCollapsed ? "false" : "true");
setCollapsed(job.id, nextCollapsed);
});
updateJobCard(item, job);
return item;
}
function renderQueue(data) {
const jobs = data.jobs || [];
state.queuePage = data.page || 1;
state.queuePages = data.pages || 1;
state.queueTotal = data.total || 0;
state.jobCards = {};
const el = $("queue");
el.innerHTML = "";
if (!jobs.length) {
@@ -395,58 +474,7 @@ QUEUE_HTML = r"""<!doctype html>
return;
}
for (const job of jobs) {
const item = document.createElement("article");
item.className = "job";
const collapsed = isCollapsed(job);
if (collapsed) item.classList.add("collapsed");
const log = [...(job.log || [])].slice(-28).reverse().join("\n");
item.innerHTML = `
<div class="job-head">
<button class="job-toggle ghost" type="button" aria-label="Toggle job details" aria-expanded="true"></button>
<div>
<div class="job-title"></div>
<p class="muted"></p>
</div>
<span class="status"></span>
</div>
<div class="job-body">
<pre></pre>
<div class="toolbar">
<span class="muted"></span>
<div class="row" style="flex:0 0 auto"></div>
</div>
</div>
`;
const toggle = item.querySelector(".job-toggle");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
toggle.addEventListener("click", () => {
const nextCollapsed = !item.classList.contains("collapsed");
item.classList.toggle("collapsed", nextCollapsed);
toggle.setAttribute("aria-expanded", nextCollapsed ? "false" : "true");
setCollapsed(job.id, nextCollapsed);
});
item.querySelector(".job-title").textContent = job.title;
const libraryName = job.anime_name || job.title;
const season = String(job.season || "1").padStart(2, "0");
const seasonFolder = `Season ${season}`;
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
item.querySelector(".job-head .muted").textContent =
`${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`;
const status = item.querySelector(".status");
status.textContent = job.status;
status.classList.add(job.status);
item.querySelector("pre").textContent = log || "Waiting...";
item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`;
const actions = item.querySelector(".row");
if (job.status === "running") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
} else if (job.status === "pending") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
} else {
actions.append(actionButton("Retry", () => queueAction(job.id, "retry")));
actions.append(actionButton("Remove", () => queueAction(job.id, "remove")));
}
el.appendChild(item);
el.appendChild(createJobCard(job));
}
renderQueuePager();
}
@@ -501,13 +529,31 @@ QUEUE_HTML = r"""<!doctype html>
return data;
}
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs"));
async function refreshVisibleJob(jobId) {
const item = state.jobCards[jobId];
if (!item) return null;
const job = await api(`/api/queue/${jobId}`);
if (!state.jobCards[jobId]) return job;
updateJobCard(state.jobCards[jobId], job);
return job;
}
async function pollVisibleJobs() {
const activeJobIds = Object.entries(state.jobCards)
.filter(([, item]) => item && !isTerminalStatus(item.dataset.jobStatus || ""))
.map(([jobId]) => jobId);
for (const jobId of activeJobIds) {
await refreshVisibleJob(jobId);
}
}
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished and canceled jobs"));
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
(async function init() {
try {
await loadQueue();
state.queueTimer = startSerialPoll(() => loadQueue(), 2500);
state.queueTimer = startSerialPoll(() => pollVisibleJobs(), 2500);
} catch (error) {
setNotice(error.message);
}
+185 -28
View File
@@ -135,9 +135,9 @@ INDEX_HTML = r"""<!doctype html>
}
main {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 20px;
min-width: 0;
align-content: start;
}
h1, h2, h3, p { margin: 0; }
h1 {
@@ -232,47 +232,127 @@ INDEX_HTML = r"""<!doctype html>
color: var(--text);
font-weight: 700;
}
.results {
display: grid;
gap: 10px;
min-width: 0;
}
.result, .settings, .episodes-panel, .deps {
.settings, .episodes-panel, .results-panel, .deps {
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.result {
.settings, .episodes-panel, .results-panel, .deps {
padding: 18px;
display: grid;
gap: 14px;
}
.results-panel {
min-width: 0;
}
.results-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.result-card {
border: 1px solid var(--line);
border-radius: 20px;
background: linear-gradient(180deg, rgba(32, 24, 54, 0.9) 0%, rgba(17, 13, 29, 0.96) 100%);
padding: 14px;
display: grid;
gap: 6px;
gap: 12px;
min-height: auto;
min-width: 0;
box-shadow: 0 18px 38px rgba(5, 2, 15, 0.32);
text-align: left;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.result:hover {
.result-card:hover {
border-color: var(--line-strong);
transform: translateY(-1px);
background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%);
transform: translateY(-2px);
}
.result.active {
border-color: rgba(157, 123, 255, 0.34);
.result-card.active {
border-color: rgba(157, 123, 255, 0.58);
box-shadow:
inset 0 0 0 1px rgba(157, 123, 255, 0.16),
0 18px 38px rgba(5, 2, 15, 0.32);
background: linear-gradient(180deg, rgba(48, 36, 80, 0.96), rgba(20, 15, 35, 0.96));
}
.cover-tile {
aspect-ratio: 3 / 4;
position: relative;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(157, 123, 255, 0.16);
background:
radial-gradient(circle at top, rgba(157, 123, 255, 0.28), transparent 44%),
linear-gradient(180deg, #221734 0%, #0c0915 100%);
}
.cover-tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.16s ease;
}
.cover-tile.has-image img {
opacity: 1;
}
.cover-fallback {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #d8e5ef;
font-size: 28px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
background: linear-gradient(180deg, rgba(157, 123, 255, 0.24), rgba(9, 7, 15, 0.18));
}
.cover-tile.has-image .cover-fallback {
display: none;
}
.result-title {
text-align: left;
color: var(--text);
font-weight: 700;
overflow-wrap: anywhere;
font-size: 14px;
line-height: 1.35;
min-height: 2.7em;
}
.result-subtitle {
text-align: left;
color: var(--muted);
font-size: 12px;
line-height: 1.3;
min-height: 1.3em;
overflow-wrap: anywhere;
}
.result-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.result-pill {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 6px 10px;
color: var(--muted);
background: rgba(255, 255, 255, 0.04);
font-size: 12px;
white-space: nowrap;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.settings, .episodes-panel, .deps {
padding: 18px;
display: grid;
gap: 14px;
}
.chips {
display: flex;
flex-wrap: wrap;
@@ -304,15 +384,28 @@ INDEX_HTML = r"""<!doctype html>
color: #ddd0ff;
font-size: 13px;
}
.results-panel .toolbar {
align-items: end;
}
.statline {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (max-width: 1400px) {
.results-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 1160px) {
.results-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); }
.controls, .row, .toolbar { align-items: stretch; flex-direction: column; }
.results-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.results-grid { grid-template-columns: 1fr; }
}
</style>
</head>
@@ -346,7 +439,6 @@ INDEX_HTML = r"""<!doctype html>
<button class="primary" id="searchBtn" type="button">Search</button>
</div>
<div class="notice" id="notice"></div>
<div class="results" id="results"></div>
</aside>
<main>
@@ -365,6 +457,12 @@ INDEX_HTML = r"""<!doctype html>
<option value="dropped">Dropped</option>
</select>
</label>
<label style="min-width: 160px;">Media type
<select id="trackMediaType">
<option value="tv">TV</option>
<option value="movie">Movie</option>
</select>
</label>
<button class="ghost" id="trackBtn" type="button" disabled>Add to watchlist</button>
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
</div>
@@ -394,6 +492,15 @@ INDEX_HTML = r"""<!doctype html>
</div>
</div>
</section>
<section class="results-panel">
<div class="toolbar">
<div>
<h2>Select results</h2>
<p class="muted" id="resultsMeta">Poster cards appear here after a search.</p>
</div>
</div>
<div class="results-grid" id="results"></div>
</section>
</main>
</div>
@@ -419,31 +526,77 @@ INDEX_HTML = r"""<!doctype html>
$("search-mode").value = mode;
}
function coverInitials(title) {
const parts = String(title || "")
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!parts.length) return "AN";
return parts.map((part) => part[0]).join("").slice(0, 2);
}
function resultThumbnailUrl(title) {
return `/api/search/thumb?title=${encodeURIComponent(title || "")}`;
}
function renderResults(results) {
const el = $("results");
el.innerHTML = "";
if (!results.length) {
el.innerHTML = '<div class="empty">No matching anime found.</div>';
$("resultsMeta").textContent = "No matching anime found.";
return;
}
for (const item of results) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "result";
btn.className = "result-card";
btn.innerHTML = `
<span class="result-title"></span>
<span class="muted"></span>
<div class="cover-tile">
<img alt="" loading="lazy" decoding="async">
<span class="cover-fallback"></span>
</div>
<div class="result-title"></div>
<div class="result-subtitle"></div>
<div class="result-meta">
<span class="result-pill"></span>
<span class="result-pill"></span>
</div>
`;
btn.querySelector(".result-title").textContent = item.title;
btn.querySelector(".muted").textContent = `${item.episodes} episodes · result ${item.index}`;
const subtitle = btn.querySelector(".result-subtitle");
const alternativeTitle = Array.isArray(item.alternative_titles) && item.alternative_titles.length
? String(item.alternative_titles[0].value || "").trim()
: "";
subtitle.textContent = alternativeTitle ? `Also known as: ${alternativeTitle}` : "";
subtitle.hidden = !alternativeTitle;
btn.querySelector(".cover-fallback").textContent = coverInitials(item.title);
const pills = btn.querySelectorAll(".result-pill");
pills[0].textContent = `${item.episodes} episodes`;
pills[1].textContent = `Result ${item.index}`;
const img = btn.querySelector("img");
const tile = btn.querySelector(".cover-tile");
img.alt = item.title;
img.src = resultThumbnailUrl(item.title);
if (img.complete && img.naturalWidth > 0) {
tile.classList.add("has-image");
}
img.addEventListener("load", () => {
if (img.naturalWidth > 0) tile.classList.add("has-image");
});
img.addEventListener("error", () => {
tile.classList.remove("has-image");
});
btn.addEventListener("click", () => selectResult(item, btn));
el.appendChild(btn);
}
$("resultsMeta").textContent = `${results.length} matching anime`;
}
async function selectResult(item, button) {
const requestId = ++state.episodeRequestId;
document.querySelectorAll(".result").forEach((node) => node.classList.remove("active"));
document.querySelectorAll(".result-card").forEach((node) => node.classList.remove("active"));
button.classList.add("active");
state.selected = item;
state.episodes = [];
@@ -489,6 +642,7 @@ INDEX_HTML = r"""<!doctype html>
if (!query) return setNotice("Type a title first.");
setMode($("search-mode").value);
setNotice("Searching...");
$("resultsMeta").textContent = "Searching...";
state.selected = null;
state.episodeRequestId += 1;
$("addBtn").disabled = true;
@@ -502,6 +656,7 @@ INDEX_HTML = r"""<!doctype html>
} catch (error) {
if (requestId !== state.searchRequestId) return;
setNotice(error.message);
$("resultsMeta").textContent = "Search failed.";
}
}
@@ -516,7 +671,9 @@ INDEX_HTML = r"""<!doctype html>
body: JSON.stringify({
show_id: state.selected.id,
title: state.selected.title,
category: $("trackCategory").value
category: $("trackCategory").value,
media_type: $("trackMediaType").value,
auto_download_series: $("seasonInput").value || "1"
})
});
setNotice(data.message || "Added to watchlist.");
@@ -529,11 +686,11 @@ INDEX_HTML = r"""<!doctype html>
if (!state.selected) return;
const payload = {
show_id: state.selected.id,
query: state.selected.query,
query: state.selected.title,
title: state.selected.title,
anime_name: $("animeNameInput").value || state.selected.title,
media_type: $("trackMediaType").value,
season: $("seasonInput").value || "1",
index: state.selected.index,
mode: state.config.mode,
quality: $("search-quality").value,
episodes: $("episodesInput").value,
+1830 -161
View File
File diff suppressed because it is too large Load Diff
+27 -10
View File
@@ -45,10 +45,14 @@ def resolve_job_watchlist_identity(
title = str(job.get("title") or job.get("anime_name") or "").strip()
if show_id:
return _resolution_result(show_id, title, "", return_reason)
try:
index = max(1, int(job.get("result_index") or 1))
except (TypeError, ValueError):
return _resolution_result("", title, "invalid queued result index", return_reason)
raw_index = job.get("result_index")
if raw_index in (None, ""):
index = None
else:
try:
index = max(1, int(raw_index))
except (TypeError, ValueError):
return _resolution_result("", title, "invalid queued result index", return_reason)
query = str(job.get("query") or title).strip()
if not query or not title:
return _resolution_result("", title, "missing queued search query or title", return_reason)
@@ -56,13 +60,26 @@ def resolve_job_watchlist_identity(
results = search_fn(query, job.get("mode") or default_mode)
except Exception as exc:
return _resolution_result("", title, f"search fallback failed: {exc}", return_reason)
if index > len(results):
return _resolution_result("", title, "queued result index no longer exists in search results", return_reason)
result = results[index - 1]
candidate_title = str(result.get("title") or "").strip()
if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn):
matches = []
for position, result in enumerate(results, start=1):
candidate_title = str(result.get("title") or "").strip()
if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn):
continue
resolved_show_id = str(result.get("id") or "").strip()
if not resolved_show_id:
continue
matches.append((position, result, candidate_title, resolved_show_id))
if not matches:
candidate_title = str((results[0].get("title") if results else "") or "").strip()
return _resolution_result("", title, f"title mismatch with fallback search result: {candidate_title or 'unknown'}", return_reason)
resolved_show_id = str(result.get("id") or "").strip()
if index is not None:
indexed = next((match for match in matches if match[0] == index), None)
if indexed is not None:
_position, result, candidate_title, resolved_show_id = indexed
return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason)
if len(matches) != 1:
return _resolution_result("", title, "ambiguous fallback search results", return_reason)
_position, result, candidate_title, resolved_show_id = matches[0]
if not resolved_show_id:
return _resolution_result("", title, "fallback search result did not include a show_id", return_reason)
return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason)
+425 -126
View File
@@ -413,24 +413,6 @@ WATCHLIST_HTML = r"""<!doctype html>
flex: 1;
min-width: 0;
}
.alt-toggle {
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.alt-titles {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
margin-top: 8px;
overflow-wrap: anywhere;
display: none;
}
.alt-titles.visible {
display: block;
}
.card-title a {
color: inherit;
text-decoration: none;
@@ -519,10 +501,133 @@ WATCHLIST_HTML = r"""<!doctype html>
line-height: 1.35;
white-space: pre-line;
}
.alt-titles-toggle {
width: 100%;
min-height: 34px;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
justify-content: center;
}
.alt-titles-modal {
width: min(640px, 100%);
display: grid;
gap: 14px;
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(180deg, rgba(20, 27, 40, 0.98), rgba(12, 17, 28, 0.98));
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
padding: 18px;
}
.alt-titles-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.alt-titles-modal-header p {
margin: 4px 0 0;
}
.alt-titles-modal-close {
min-width: 36px;
min-height: 36px;
border-radius: 999px;
font-size: 18px;
line-height: 1;
}
.alt-titles-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.alt-title-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 9px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(157, 123, 255, 0.08);
font-size: 11px;
line-height: 1.3;
color: var(--text);
}
.alt-title-pill strong {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.category-editor {
display: grid;
gap: 6px;
}
.overlay[hidden] {
display: none;
}
.overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(7, 10, 16, 0.74);
backdrop-filter: blur(10px);
z-index: 1200;
}
.auto-download-modal {
width: min(640px, 100%);
display: grid;
gap: 14px;
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(180deg, rgba(20, 27, 40, 0.98), rgba(12, 17, 28, 0.98));
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
padding: 18px;
}
.auto-download-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.auto-download-modal-header p {
margin: 4px 0 0;
}
.auto-download-modal-close {
min-width: 36px;
min-height: 36px;
border-radius: 999px;
font-size: 18px;
line-height: 1;
}
.auto-download-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.auto-download-grid label {
grid-column: span 2;
}
.auto-download-grid .wide-half {
grid-column: span 3;
}
.auto-download-modal label {
font-size: 10px;
}
.auto-download-modal input,
.auto-download-modal select {
min-height: 36px;
padding: 0 10px;
font-size: 13px;
}
.auto-download-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.category-editor label {
font-size: 10px;
letter-spacing: 0.08em;
@@ -534,13 +639,22 @@ WATCHLIST_HTML = r"""<!doctype html>
}
.card-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.card-actions button {
min-height: 34px;
padding: 0 10px;
}
.download-picker {
display: none;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.download-picker.visible {
display: grid;
}
.pager {
display: flex;
justify-content: space-between;
@@ -576,6 +690,9 @@ WATCHLIST_HTML = r"""<!doctype html>
}
@media (max-width: 560px) {
.watchlist-grid { grid-template-columns: 1fr; }
.auto-download-grid { grid-template-columns: 1fr; }
.auto-download-grid label,
.auto-download-grid .wide-half { grid-column: span 1; }
}
</style>
</head>
@@ -610,6 +727,12 @@ WATCHLIST_HTML = r"""<!doctype html>
<option value="dropped">Dropped</option>
</select>
</label>
<label>Media type
<select id="watchlist-media-type">
<option value="tv">TV</option>
<option value="movie">Movie</option>
</select>
</label>
<div class="field-hint">Tip: you can also add entries directly from the Search page.</div>
<button class="primary" type="submit">Add to watchlist</button>
</form>
@@ -666,6 +789,67 @@ WATCHLIST_HTML = r"""<!doctype html>
</main>
</div>
<div class="overlay" id="altTitlesOverlay" hidden>
<div class="alt-titles-modal" role="dialog" aria-modal="true" aria-labelledby="altTitlesTitle">
<div class="alt-titles-modal-header">
<div>
<h2 id="altTitlesTitle">Alternative titles</h2>
<p class="muted" id="altTitlesSubtitle">AniDB English titles for the selected show.</p>
</div>
<button class="ghost alt-titles-modal-close" id="altTitlesCloseBtn" type="button" aria-label="Close alternative titles">&times;</button>
</div>
<div class="alt-titles-list" id="altTitlesList"></div>
<div class="muted" id="altTitlesEmpty">AniDB English alternative titles were not available for this show.</div>
</div>
</div>
<div class="overlay" id="autoDownloadOverlay" hidden>
<div class="auto-download-modal" role="dialog" aria-modal="true" aria-labelledby="autoDownloadTitle">
<div class="auto-download-modal-header">
<div>
<h2 id="autoDownloadTitle">Auto-download</h2>
<p class="muted" id="autoDownloadSubtitle">Used only while this anime is in the Watching tab. Manual add does not trigger it; later refreshes do.</p>
</div>
<button class="ghost auto-download-modal-close" id="autoDownloadCloseBtn" type="button" aria-label="Close auto-download settings">&times;</button>
</div>
<div class="auto-download-grid">
<label>Language
<select class="auto-download-mode" id="autoDownloadMode">
<option value="dub">Dub</option>
<option value="sub">Sub</option>
</select>
</label>
<label>Quality
<select class="auto-download-quality" id="autoDownloadQuality">
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
</select>
</label>
<label>Series
<input class="auto-download-series" id="autoDownloadSeries" type="number" min="1" step="1" value="1">
</label>
<label>Episode offset
<input class="auto-download-offset" id="autoDownloadOffset" type="number" min="1" step="1" placeholder="Optional">
</label>
<label class="wide-half">Source name
<input class="auto-download-source-name" id="autoDownloadSourceName" type="text">
</label>
<label class="wide-half">Name source
<select class="auto-download-name-select" id="autoDownloadNameSelect"></select>
</label>
<label class="wide-half">Name
<input class="auto-download-name" id="autoDownloadName" type="text">
</label>
</div>
<div class="auto-download-actions">
<div class="muted" id="autoDownloadStatus">Manual add does not trigger it; later refreshes do.</div>
<button class="ghost auto-download-save" id="autoDownloadSaveBtn" type="button">Save auto-download</button>
</div>
</div>
</div>
<script>
const state = {
watchlistPage: 1,
@@ -674,14 +858,14 @@ WATCHLIST_HTML = r"""<!doctype html>
watchlistPerPage: 30,
queuedCount: 0,
watchlistCategory: "watching",
altTitleTokens: {},
altTitlePending: new Set(),
thumbnailNonce: Date.now(),
thumbnailWarmupTokens: {},
thumbnailWarmupPending: new Set(),
refreshRunning: false,
refreshJobId: null,
watchlistRequestId: 0
watchlistRequestId: 0,
altTitlesDraft: null,
autoDownloadDraft: null
};
const categoryLabels = {
watching: "Watching",
@@ -689,6 +873,27 @@ WATCHLIST_HTML = r"""<!doctype html>
finished: "Finished",
dropped: "Dropped"
};
const mediaTypeLabels = {
tv: "TV",
movie: "Movie"
};
const autoDownloadOverlay = document.getElementById("autoDownloadOverlay");
const altTitlesOverlay = document.getElementById("altTitlesOverlay");
const altTitlesCloseBtn = document.getElementById("altTitlesCloseBtn");
const altTitlesSubtitle = document.getElementById("altTitlesSubtitle");
const altTitlesList = document.getElementById("altTitlesList");
const altTitlesEmpty = document.getElementById("altTitlesEmpty");
const autoDownloadCloseBtn = document.getElementById("autoDownloadCloseBtn");
const autoDownloadSubtitle = document.getElementById("autoDownloadSubtitle");
const autoDownloadStatus = document.getElementById("autoDownloadStatus");
const autoDownloadMode = document.getElementById("autoDownloadMode");
const autoDownloadQuality = document.getElementById("autoDownloadQuality");
const autoDownloadSeries = document.getElementById("autoDownloadSeries");
const autoDownloadOffset = document.getElementById("autoDownloadOffset");
const autoDownloadSourceName = document.getElementById("autoDownloadSourceName");
const autoDownloadNameSelect = document.getElementById("autoDownloadNameSelect");
const autoDownloadName = document.getElementById("autoDownloadName");
const autoDownloadSaveBtn = document.getElementById("autoDownloadSaveBtn");
const $ = (id) => document.getElementById(id);
""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r"""
@@ -753,6 +958,12 @@ WATCHLIST_HTML = r"""<!doctype html>
.join("");
}
function mediaTypeOptions(selected) {
return Object.entries(mediaTypeLabels)
.map(([value, label]) => `<option value="${value}"${value === selected ? " selected" : ""}>${label}</option>`)
.join("");
}
function renderSummary(summary) {
const totals = summary || { shows: 0, sub: 0, dub: 0 };
const cards = $("watchlist-summary").querySelectorAll(".summary-card");
@@ -808,6 +1019,146 @@ WATCHLIST_HTML = r"""<!doctype html>
controls.append(prev, next);
}
function closeAltTitlesOverlay() {
state.altTitlesDraft = null;
altTitlesOverlay.hidden = true;
}
function openAltTitlesOverlay(item) {
state.altTitlesDraft = { ...item };
altTitlesSubtitle.textContent = `${item.title} · AniDB English titles for the selected show.`;
altTitlesList.innerHTML = "";
const alternativeTitles = Array.isArray(item.alternative_titles) ? item.alternative_titles : [];
if (alternativeTitles.length) {
for (const title of alternativeTitles) {
const pill = document.createElement("span");
pill.className = "alt-title-pill";
const label = document.createElement("strong");
label.textContent = title.label;
const value = document.createElement("span");
value.textContent = title.value;
pill.append(label, value);
altTitlesList.appendChild(pill);
}
altTitlesEmpty.textContent = "Fetched from AniDB English titles.";
altTitlesList.hidden = false;
} else {
altTitlesEmpty.textContent = "AniDB English alternative titles were not available for this show.";
altTitlesList.hidden = true;
}
altTitlesEmpty.hidden = false;
altTitlesOverlay.hidden = false;
}
altTitlesCloseBtn.addEventListener("click", closeAltTitlesOverlay);
altTitlesOverlay.addEventListener("click", (event) => {
if (event.target === altTitlesOverlay) {
closeAltTitlesOverlay();
}
});
function closeAutoDownloadOverlay() {
state.autoDownloadDraft = null;
autoDownloadOverlay.hidden = true;
}
function populateAutoDownloadNameChoices(item) {
const selectedName = item.auto_download_name || "";
const nameSuggestions = [];
for (const name of [item.title, ...(Array.isArray(item.alternative_titles) ? item.alternative_titles.map((entry) => entry.value) : [])]) {
if (name && !nameSuggestions.includes(name)) nameSuggestions.push(name);
}
autoDownloadNameSelect.innerHTML = "";
for (const name of nameSuggestions) {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
autoDownloadNameSelect.appendChild(option);
}
const customOption = document.createElement("option");
customOption.value = "__custom__";
customOption.textContent = "Custom";
autoDownloadNameSelect.appendChild(customOption);
const matchedSuggestion = nameSuggestions.find((name) => name === selectedName);
autoDownloadNameSelect.value = matchedSuggestion || "__custom__";
autoDownloadName.value = selectedName;
}
function openAutoDownloadOverlay(item) {
state.autoDownloadDraft = { ...item };
autoDownloadSubtitle.textContent = `${item.title} · Used only while this anime is in the Watching tab. Manual add does not trigger it; later refreshes do.`;
autoDownloadStatus.textContent = "Manual add does not trigger it; later refreshes do.";
autoDownloadMode.value = item.auto_download_mode || "dub";
autoDownloadQuality.value = item.auto_download_quality || "best";
autoDownloadSeries.value = item.auto_download_series || "1";
autoDownloadOffset.value = item.auto_download_offset || "";
autoDownloadSourceName.value = item.auto_download_source_name || item.title || "";
populateAutoDownloadNameChoices(item);
autoDownloadOverlay.hidden = false;
}
autoDownloadNameSelect.addEventListener("change", () => {
if (autoDownloadNameSelect.value !== "__custom__") {
autoDownloadName.value = autoDownloadNameSelect.value;
}
});
autoDownloadCloseBtn.addEventListener("click", closeAutoDownloadOverlay);
autoDownloadOverlay.addEventListener("click", (event) => {
if (event.target === autoDownloadOverlay) {
closeAutoDownloadOverlay();
}
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !altTitlesOverlay.hidden) {
closeAltTitlesOverlay();
return;
}
if (event.key === "Escape" && !autoDownloadOverlay.hidden) {
closeAutoDownloadOverlay();
}
});
autoDownloadSaveBtn.addEventListener("click", async () => {
if (!state.autoDownloadDraft) return;
autoDownloadSaveBtn.disabled = true;
try {
const data = await api("/api/watchlist/update-auto-download", {
method: "POST",
body: JSON.stringify({
show_id: state.autoDownloadDraft.show_id,
mode: autoDownloadMode.value,
quality: autoDownloadQuality.value,
name: autoDownloadName.value,
source_name: autoDownloadSourceName.value,
series: autoDownloadSeries.value,
episode_offset: autoDownloadOffset.value
})
});
const savedItem = data.item || {};
state.autoDownloadDraft = {
...state.autoDownloadDraft,
auto_download_mode: savedItem.auto_download_mode || autoDownloadMode.value,
auto_download_quality: savedItem.auto_download_quality || autoDownloadQuality.value,
auto_download_name: savedItem.auto_download_name || autoDownloadName.value,
auto_download_source_name: savedItem.auto_download_source_name || autoDownloadSourceName.value,
auto_download_series: savedItem.auto_download_series || autoDownloadSeries.value,
auto_download_offset: savedItem.auto_download_offset || ""
};
populateAutoDownloadNameChoices(state.autoDownloadDraft);
autoDownloadSeries.value = state.autoDownloadDraft.auto_download_series || autoDownloadSeries.value;
autoDownloadOffset.value = state.autoDownloadDraft.auto_download_offset || "";
autoDownloadSourceName.value = state.autoDownloadDraft.auto_download_source_name || autoDownloadSourceName.value;
autoDownloadStatus.textContent = data.message || "Auto-download settings saved.";
setNotice(data.message || "Auto-download settings saved.");
closeAutoDownloadOverlay();
await fetchWatchlist(state.watchlistPage, { silent: true });
} catch (error) {
autoDownloadStatus.textContent = error.message;
setNotice(error.message);
} finally {
autoDownloadSaveBtn.disabled = false;
}
});
function applyThumbnail(card, item, force = false) {
const tile = card.querySelector(".cover-tile");
const img = card.querySelector("img");
@@ -865,44 +1216,6 @@ WATCHLIST_HTML = r"""<!doctype html>
.find((card) => card.dataset.showId === showId) || null;
}
function altTitleToken(item) {
return [
item.english_title || "",
...(item.display_alt_titles || []),
].join("|");
}
function applyAltTitles(card, item) {
if (!card) return;
const altTitlesNode = card.querySelector(".alt-titles");
const oldToggle = card.querySelector(".alt-toggle");
if (!altTitlesNode || !oldToggle) return;
const altTitles = item.display_alt_titles || [];
const wasVisible = altTitlesNode.classList.contains("visible");
altTitlesNode.classList.remove("visible");
altTitlesNode.textContent = "";
altTitlesNode.hidden = true;
const newToggle = oldToggle.cloneNode(true);
oldToggle.replaceWith(newToggle);
newToggle.hidden = true;
newToggle.textContent = "Alternative titles";
newToggle.setAttribute("aria-expanded", "false");
if (!altTitles.length) return;
altTitlesNode.hidden = false;
altTitlesNode.textContent = altTitles.join(" · ");
newToggle.hidden = false;
if (wasVisible) {
altTitlesNode.classList.add("visible");
newToggle.textContent = "Hide alternative titles";
newToggle.setAttribute("aria-expanded", "true");
}
newToggle.addEventListener("click", () => {
const visible = altTitlesNode.classList.toggle("visible");
newToggle.setAttribute("aria-expanded", visible ? "true" : "false");
newToggle.textContent = visible ? "Hide alternative titles" : "Alternative titles";
});
}
function thumbnailWarmupToken(item) {
return [
item.thumbnail_ready ? "1" : "0",
@@ -949,42 +1262,6 @@ WATCHLIST_HTML = r"""<!doctype html>
}
}
async function hydrateAltTitlesOnce(items) {
const missing = [];
for (const item of items) {
const token = altTitleToken(item);
if ((item.display_alt_titles || []).length) {
state.altTitlePending.delete(item.show_id);
state.altTitleTokens[item.show_id] = token;
continue;
}
if (state.altTitlePending.has(item.show_id)) continue;
if (state.altTitleTokens[item.show_id] === token) continue;
state.altTitlePending.add(item.show_id);
state.altTitleTokens[item.show_id] = token;
missing.push(item.show_id);
if (missing.length >= 6) break;
}
if (!missing.length) return;
try {
const data = await api("/api/watchlist/ensure-alt-titles", {
method: "POST",
body: JSON.stringify({ show_ids: missing, limit: 6, force: false })
});
for (const item of data.items || []) {
state.altTitleTokens[item.show_id] = altTitleToken(item);
const card = findCardByShowId(item.show_id);
if (!card) continue;
applyAltTitles(card, item);
}
} catch (_error) {
} finally {
for (const showId of missing) {
state.altTitlePending.delete(showId);
}
}
}
async function reloadWatchlistThumbnail(showId, title) {
const card = findCardByShowId(showId);
if (card) setThumbnailReloading(card, true);
@@ -1085,11 +1362,7 @@ WATCHLIST_HTML = r"""<!doctype html>
<input class="thumb-upload-input" type="file" accept="image/*" hidden>
</div>
<div class="title-row">
<div>
<div class="card-title"></div>
<button class="ghost alt-toggle" type="button" hidden>Alternative titles</button>
<div class="alt-titles" hidden></div>
</div>
<div class="card-title"></div>
<span class="finish-check ${item.finished_badge || ""}" ${item.finished_badge ? "" : "hidden"}>${item.finished_badge ? "&check;" : ""}</span>
</div>
<div class="count-row">
@@ -1097,16 +1370,25 @@ WATCHLIST_HTML = r"""<!doctype html>
<span class="count-pill${item.dub_complete ? " complete" : ""}">Dub <strong>${formatModeProgress(item.dub_count, item.expected_count)}</strong></span>
</div>
<div class="status-row"></div>
<button class="ghost alt-titles-toggle" type="button">Alternative titles</button>
<div class="category-editor">
<label>Category
<select class="category-select">${categoryOptions(item.category)}</select>
</label>
<label>Media
<select class="media-type-select">${mediaTypeOptions(item.media_type || "tv")}</select>
</label>
</div>
<div class="muted card-date"></div>
<div class="card-actions">
<button class="primary" type="button">Refresh</button>
<button class="ghost" type="button">Download all</button>
<button class="danger" type="button">Remove</button>
<button class="ghost" type="button">Download all</button>
<button class="ghost" type="button">Auto-download</button>
<div class="download-picker">
<button class="ghost" type="button">Sub</button>
<button class="ghost" type="button">Dub</button>
</div>
</div>
`;
card.querySelector(".cover-fallback").textContent = coverInitials(item.title);
@@ -1117,7 +1399,6 @@ WATCHLIST_HTML = r"""<!doctype html>
titleLink.rel = "noopener noreferrer";
titleLink.textContent = item.title;
titleNode.replaceChildren(titleLink);
applyAltTitles(card, item);
const finishCheck = card.querySelector(".finish-check");
if (finishCheck && item.finished_badge) {
finishCheck.title = item.finished_badge === "downloaded"
@@ -1126,6 +1407,7 @@ WATCHLIST_HTML = r"""<!doctype html>
}
const statusRow = card.querySelector(".status-row");
appendStatusTag(statusRow, item.category_label || categoryLabels[item.category] || "Watching");
appendStatusTag(statusRow, item.media_type_label || mediaTypeLabels[item.media_type] || "TV");
if (item.airing_status) {
appendStatusTag(statusRow, item.airing_status);
}
@@ -1135,6 +1417,8 @@ WATCHLIST_HTML = r"""<!doctype html>
if (item.dub_complete) {
appendStatusTag(statusRow, "Dub ready", "complete");
}
const altTitlesToggle = card.querySelector(".alt-titles-toggle");
altTitlesToggle.addEventListener("click", () => openAltTitlesOverlay(item));
card.querySelector(".card-date").textContent = item.status_message
? `Last checked: ${checked}\n${item.status_message}`
: `Last checked: ${checked}`;
@@ -1150,9 +1434,14 @@ WATCHLIST_HTML = r"""<!doctype html>
});
const actionButtons = card.querySelector(".card-actions").querySelectorAll("button");
const refreshBtn = actionButtons[0];
const searchBtn = actionButtons[1];
const removeBtn = actionButtons[2];
const removeBtn = actionButtons[1];
const downloadBtn = actionButtons[2];
const autoDownloadBtn = actionButtons[3];
const subBtn = actionButtons[4];
const dubBtn = actionButtons[5];
const downloadPicker = card.querySelector(".download-picker");
const categorySelect = card.querySelector(".category-select");
const mediaTypeSelect = card.querySelector(".media-type-select");
categorySelect.addEventListener("change", async () => {
categorySelect.disabled = true;
try {
@@ -1169,14 +1458,37 @@ WATCHLIST_HTML = r"""<!doctype html>
categorySelect.disabled = false;
}
});
mediaTypeSelect.addEventListener("change", async () => {
mediaTypeSelect.disabled = true;
try {
const data = await api("/api/watchlist/update-media-type", {
method: "POST",
body: JSON.stringify({ show_id: item.show_id, media_type: mediaTypeSelect.value })
});
setNotice(data.message || "Media type updated.");
await fetchWatchlist(state.watchlistPage);
} catch (error) {
setNotice(error.message);
mediaTypeSelect.value = item.media_type || "tv";
} finally {
mediaTypeSelect.disabled = false;
}
});
refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id));
searchBtn.addEventListener("click", () => downloadWatchlistItem(item));
downloadBtn.addEventListener("click", () => {
const visible = downloadPicker.classList.toggle("visible");
downloadBtn.textContent = visible ? "Cancel download" : "Download all";
});
autoDownloadBtn.addEventListener("click", () => {
openAutoDownloadOverlay(item);
});
subBtn.addEventListener("click", () => downloadWatchlistItem(item, "sub", downloadPicker, downloadBtn));
dubBtn.addEventListener("click", () => downloadWatchlistItem(item, "dub", downloadPicker, downloadBtn));
removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title));
container.appendChild(card);
applyThumbnail(card, item, false);
}
renderPager();
window.setTimeout(() => hydrateAltTitlesOnce(items), 120);
window.setTimeout(() => hydrateThumbnailsOnce(items), 150);
}
@@ -1241,11 +1553,13 @@ WATCHLIST_HTML = r"""<!doctype html>
body: JSON.stringify({
show_id: showId,
title,
category: $("watchlist-category").value
category: $("watchlist-category").value,
media_type: $("watchlist-media-type").value
})
});
$("watchlist-form").reset();
$("watchlist-category").value = "watching";
$("watchlist-media-type").value = "tv";
setNotice(data.message || "Added to watchlist.");
await fetchWatchlist();
} catch (error) {
@@ -1266,30 +1580,15 @@ WATCHLIST_HTML = r"""<!doctype html>
}
}
function chooseWatchlistDownloadMode(item) {
const fallback = item.dub_count > 0 && item.sub_count < 1 ? "dub" : "sub";
const answer = window.prompt(
`Download all available episodes for ${item.title || "this anime"}.\nEnter sub or dub:`,
fallback,
);
if (answer === null) return "";
const mode = String(answer || "").trim().toLowerCase();
if (mode === "sub" || mode === "dub") {
return mode;
}
setNotice("Enter sub or dub to queue a watchlist download.");
return "";
}
async function downloadWatchlistItem(item) {
const mode = chooseWatchlistDownloadMode(item);
if (!mode) return;
async function downloadWatchlistItem(item, mode, picker = null, toggleBtn = null) {
setNotice(`Queueing ${item.title || "selected anime"} for ${mode} download...`);
try {
const data = await api("/api/watchlist/download-all", {
method: "POST",
body: JSON.stringify({ show_id: item.show_id, mode })
});
if (picker) picker.classList.remove("visible");
if (toggleBtn) toggleBtn.textContent = "Download all";
setNotice(data.message || "Added to queue.");
} catch (error) {
setNotice(error.message);