Compare commits

...
68 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
Dymas 8aa490adf8 Backfill watchlist alternative titles lazily 2026-05-23 19:09:38 +02:00
Dymas 0c1b46e395 Collapse watchlist alternative titles 2026-05-23 19:01:30 +02:00
Dymas 84c2e533d0 Show AllManga alternative watchlist titles 2026-05-23 18:34:24 +02:00
Dymas c6ba7fdbe2 Improve remote auth bootstrap flow 2026-05-23 14:37:48 +02:00
Dymas 91691244d7 Add watchlist download-all action 2026-05-23 14:30:03 +02:00
Dymas 5bec8713a5 Add collapsible queue job cards 2026-05-23 13:59:46 +02:00
Dymas 948753fe28 Stack sidebar navigation vertically 2026-05-23 11:52:58 +02:00
Dymas fe094a7112 Split queue into dedicated page 2026-05-22 23:17:00 +02:00
Dymas 9d4baea057 Notify completed downloads and finish watchlist entries 2026-05-21 19:36:39 +02:00
Dymas bb4983c928 Install latest yt-dlp release in Docker image 2026-05-21 19:05:21 +02:00
Dymas 9648b4f6e4 Add Discord webhook refresh notifications 2026-05-21 18:56:49 +02:00
Dymas a95b4c343f updated docker-compose.yaml 2026-05-17 22:19:23 +02:00
Dymas 70a8acfb18 Simplify README structure and wording 2026-05-17 22:14:10 +02:00
Dymas 5c4f251fa6 Fix ready border condition on watchlist cards 2026-05-17 21:49:27 +02:00
Dymas 91b1e36a94 Highlight fully ready finished watchlist cards 2026-05-17 21:43:11 +02:00
Dymas 2a0da335b2 Fix watchlist download sync for resolved IDs 2026-05-17 21:35:49 +02:00
Dymas 9e918a50ab Show newest queue log lines first 2026-05-17 21:29:21 +02:00
18 changed files with 7532 additions and 964 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
+244
View File
@@ -1,5 +1,249 @@
# Changelog # Changelog
## 0.47.1 - 2026-07-09
- Fixed `anipy-cli` fallback renaming for queued TV downloads so retried jobs now keep the requested episode numbers from the queue, avoiding cases like a retried `S02E11` being renamed as `S02E01`.
## 0.47.0 - 2026-07-09
- Added an optional `anipy-cli` fallback toggle on the Config page so failed `ani-cli` queue jobs can automatically retry once with `anipy-cli`.
- Exposed `anipy-cli` availability and version in Config page runtime checks, and updated queue processing so fallback downloads still finalize into the same TV or movie library layout.
## 0.46.0 - 2026-06-14
- Changed manual Jellyfin handoff to run as a persisted background job instead of a single blocking request, so the Config page can show live progress while files are being moved.
- Added a Jellyfin sync status API plus restart-safe SQLite-backed job tracking with per-entry and per-file progress details for current and recent handoff runs.
- Updated Jellyfin moves to transfer files individually into the target library, which keeps progress counts accurate while preserving the existing final folder layout.
## 0.45.18 - 2026-06-13
- Added a `Source name` field to the watchlist auto-download editor so per-show jobs can send a more specific anime title to `ani-cli` when provider searches return multiple similarly named results.
- Migrated existing watchlist databases by adding an `auto_download_source_name` column and defaulting stored entries to the tracked title, preserving current behavior until you override it.
- Changed both watchlist `Download all` and automatic watchlist episode queueing to use the saved `Source name` for the `ani-cli` query while keeping the separate library `Name` field for folder and file naming.
## 0.45.17 - 2026-06-07
- Added a lightweight `/api/watchlist/homepage` endpoint for Homepage's Custom API widget, returning simple watchlist category counts for `watchlist`, `planned`, `finished`, `dropped`, and `total`.
## 0.45.16 - 2026-06-02
- Show the first English AniDB alternative title beneath each Search result when one exists, reusing the watchlist-backed alternative-title extraction path instead of duplicating title matching in the browser.
- Included alternative titles in the Search API payload so the result cards can render the subtitle without doing extra per-card network requests.
## 0.45.15 - 2026-06-02
- Moved Search page results out of the sidebar and into a poster-style grid below the selection panel so hits now read like watchlist cards instead of a compact list.
- Added a dedicated search thumbnail route that reuses AnimeSchedule or AniDB artwork, letting search results show cover images without needing to add the title to the watchlist first.
## 0.45.14 - 2026-05-26
- Added a full-width `Changelog` button under Config page Runtime info that opens a floating scrollable panel and loads the contents of the project's `CHANGELOG.md`.
- Added a new `/api/changelog` route for the Config page modal and kept `CHANGELOG.md` inside the Docker runtime image so the in-container changelog viewer works after deployment.
## 0.45.13 - 2026-05-26
- Trimmed the Docker runtime image by removing repo-only files from `/app` after cloning, so the container no longer carries development docs, Compose/build files, or the test suite it does not use at runtime.
- Kept the checked-in `VERSION` file inside the image so Runtime info and startup logging still have the release metadata they now read at runtime.
## 0.45.12 - 2026-05-26
- Changed `ani-cli-web` runtime version reporting to load directly from the project `VERSION` file instead of a duplicated hardcoded constant, which keeps Runtime info, API version responses, and startup logs in sync with release metadata.
- Added regression coverage to ensure the loaded runtime version matches the checked-in `VERSION` file.
## 0.45.11 - 2026-05-26
- Fixed watchlist auto-download queueing so the first manual or scheduled refresh after adding a `Watching` series can pick up already-available missing episodes instead of waiting for a later refresh cycle.
- Kept add-time `initial` watchlist refreshes non-downloading, so tracking a show still seeds metadata first and only later refreshes are allowed to enqueue backlog or newly released episodes.
## 0.45.10 - 2026-05-26
- Fixed watchlist-synced movie downloads so successful movie jobs move existing watchlist entries into `Finished` instead of leaving them stuck in `Watching` or `Planned`.
- Preserved queued job media type when creating missing watchlist entries from completed downloads, which prevents synced movies from being recreated as TV entries and keeps Jellyfin routing correct.
## 0.45.9 - 2026-05-26
- Clarified queue cleanup wording so the UI and README now match the real `Clear finished` behavior, which removes both finished and canceled jobs.
- Fixed successful detached queue jobs to log that watchlist sync was skipped instead of incorrectly claiming that watchlist download state synced.
## 0.45.8 - 2026-05-26
- Made watchlist removal sticky by recording removed show IDs, detaching related queue jobs from watchlist sync, and preventing later completed downloads from silently recreating deleted entries.
- Cleared watchlist queue bindings when a show is removed so old job history no longer suppresses future auto-download coverage if that anime is tracked again later.
- Updated manual watchlist `Download all` queueing to honor the per-anime auto-download quality first, then fall back to the global auto-download quality before the general queue default.
## 0.45.7 - 2026-05-25
- Removed the remaining backend `ani-cli -S` dependency so queued downloads no longer trust caller-supplied search result ordinals, including older persisted jobs that still carry a legacy `result_index`.
- Simplified watchlist `Download all` and auto-download queueing to use the stored watchlist `show_id` plus direct episode lookups, avoiding unnecessary `search_anime()` match failures when provider search results drift.
- Restricted queue retries to failed or canceled jobs, which prevents finished downloads from being retried and accidentally redownloading episodes or resending completion side effects.
## 0.45.6 - 2026-05-25
- Hardened Search page queueing so explicit downloads now submit the selected anime title directly instead of replaying a fragile web-result ordinal as `ani-cli -S`, which avoids cross-search mismatches between the API and `ani-cli`.
- Tightened watchlist download identity fallback so jobs without a stored result index now sync back only when the fallback search produces one unique confident title match, skipping ambiguous matches instead of silently picking result `1`.
- Tightened Jellyfin handoff recovery so a missing source folder with an existing target now counts as `already moved` only when the target library actually looks complete; incomplete targets are surfaced as failures for retry and notification handling.
## 0.45.5 - 2026-05-25
- Reworked the watchlist card action layout into two rows so `Refresh` and `Remove` sit on the first line, with `Download all` and `Auto-download` underneath for a less cluttered button group.
## 0.45.4 - 2026-05-25
- Fixed watchlist-triggered downloads to stop forcing `ani-cli` search result indexes from the web API result order; explicit search-page selections still keep their chosen result, but watchlist and auto-download jobs now avoid bad `-S` mismatches when `ani-cli` resolves the title differently.
## 0.45.3 - 2026-05-25
- Improved queue failure handling for no-op `ani-cli` runs: when the command exits successfully but downloads no files, the job now fails with a clear message explaining that nothing was downloaded and the empty staging folder is cleaned up.
## 0.45.2 - 2026-05-25
- Fixed watchlist completion so a successful partial dub or sub download no longer moves a TV series to `Finished`; the category now changes only when the downloaded mode has every expected episode both available and downloaded.
- Fixed Jellyfin TV handoff to use that same mode-aware completion rule, preventing partial dub libraries such as `8/12` from being moved early.
## 0.45.1 - 2026-05-25
- Expanded the `download_completed` Discord webhook so it includes the full saved file list instead of only the first few paths, splitting the list across extra embeds when needed.
- Added `jellyfin_sync_success` and `jellyfin_sync_failed` Discord webhook events for finished-library handoffs, with success and actionable failure notifications emitted from Jellyfin moves.
## 0.45.0 - 2026-05-25
- Added an optional per-anime auto-download episode offset for split-part seasons, so queued watchlist downloads can rename files as `S02E13`, `S02E14`, and onward while still downloading the underlying source episodes normally.
- Added a migration-safe `auto_download_offset` watchlist column, exposed it in the watchlist auto-download editor, and applied it to both manual watchlist `Download all` jobs and automatic watchlist episode downloads.
## 0.44.1 - 2026-05-25
- Limited bulk watchlist refreshes to entries marked `Watching` or `Planned`, so manual `Refresh All` runs and scheduled refreshes skip `Finished` and `Dropped` shows.
## 0.44.0 - 2026-05-25
- Added host filesystem browse buttons to the Config page path fields so default download and Jellyfin library folders can be selected from a built-in browser modal instead of typed manually.
- Added an authenticated `/api/fs/browse` endpoint for directory and file browsing to support the new host path picker workflow.
## 0.43.0 - 2026-05-25
- Added a Jellyfin section on the Config page with enable or disable control, separate TV and movie destination folders, and a manual `Run now` handoff trigger.
- Added automatic Jellyfin handoff for completed watchlist entries, moving TV libraries only once the show is finished and fully downloaded in at least one language, while downloaded movies move into the configured Jellyfin movie library.
## 0.42.0 - 2026-05-25
- Added a watchlist `TV` or `Movie` media tag with a migration-safe SQLite update for existing installs, plus Search and Watchlist controls to set or change that tag.
- Changed completed library layout so TV downloads now go under `.../tv/<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
- Replaced the first-visit remote-access 403 dead end with a browser sign-in page and persistent auth cookie, so remote users no longer need to know the `?token=...` bootstrap URL format up front.
## 0.36.0 - 2026-05-23
- Replaced the watchlist card `Open` action with `Download all`, which prompts for `sub` or `dub` and then queues all currently available episodes directly from the watchlist.
## 0.35.2 - 2026-05-23
- Made queue job cards collapsible and defaulted finished jobs to collapsed so long queue histories take less space while running and pending jobs stay expanded.
## 0.35.1 - 2026-05-23
- Changed the shared sidebar navigation to a vertical one-link-per-line stack so the menu stays balanced after adding the dedicated Queue page.
## 0.35.0 - 2026-05-22
- Split the download queue out of the Search page into a dedicated `/queue` page so searching and queue management are separate workflows.
- Added a Queue navigation tab and moved queue polling, logs, paging, retry, cancel, and finished-job cleanup controls onto the new page.
## 0.34.0 - 2026-05-21
- Added a Discord webhook notification event for completed downloads so successful queue jobs can report the resolved anime title, saved files, and cached thumbnail.
- Fixed watchlist download sync so successfully downloaded series are moved into the `Finished` category instead of staying in `Watching`, `Planned`, or another earlier category.
## 0.33.1 - 2026-05-21
- Updated the Docker image build to install `yt-dlp` directly from the latest upstream GitHub release and verify the binary during the image build.
## 0.33.0 - 2026-05-21
- Added Discord webhook notifications with Config page controls for the webhook URL, selectable notification events, and a one-click test action.
- Added refresh result notifications that report each anime with newly found episodes, including cached thumbnails plus current sub and dub counts.
- Added Discord notifications for refresh failures, interrupted refresh runs, and unexpected runtime errors so background problems are easier to notice without watching the web UI.
## 0.32.5 - 2026-05-17
- Fixed the green ready-border highlight so it applies to any watchlist card with both subbed and dubbed episodes complete, instead of only cards filed under the `Finished` category.
## 0.32.4 - 2026-05-17
- Highlighted fully ready finished watchlist entries with a thin green card border when both subbed and dubbed episodes are complete, making download-complete anime easier to scan quickly.
## 0.32.3 - 2026-05-17
- Fixed watchlist download sync so a successful download now reuses the existing tracked entry when the resolved `show_id` changes, preserving its category and marking it downloaded instead of creating a duplicate finished-only row.
## 0.32.2 - 2026-05-17
- Changed Search page queue log rendering to show the newest lines first instead of oldest-first.
## 0.32.1 - 2026-05-17 ## 0.32.1 - 2026-05-17
- Suppressed stdout request logging for routine polling endpoints such as `/api/queue` and `/api/watchlist/refresh-status`, reducing noisy access-log spam during normal browser use. - Suppressed stdout request logging for routine polling endpoints such as `/api/queue` and `/api/watchlist/refresh-status`, reducing noisy access-log spam during normal browser use.
+21 -2
View File
@@ -3,11 +3,14 @@ FROM python:3.12-slim
ARG ANI_CLI=https://github.com/pystardust/ani-cli.git ARG ANI_CLI=https://github.com/pystardust/ani-cli.git
ARG ANI_CLI_BRANCH=master ARG ANI_CLI_BRANCH=master
ARG ANI_CLI_WEB=https://gitea.coreplay.eu/Dymas/ani-cli-web.git ARG ANI_CLI_WEB=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 \ ENV DEBIAN_FRONTEND=noninteractive \
ANI_CLI_REPO=${ANI_CLI} \ ANI_CLI_REPO=${ANI_CLI} \
ANI_CLI_BRANCH=${ANI_CLI_BRANCH} \ ANI_CLI_BRANCH=${ANI_CLI_BRANCH} \
ANI_CLI_WEB_REPO=${ANI_CLI_WEB} \ ANI_CLI_WEB_REPO=${ANI_CLI_WEB} \
ANI_CLI_WEB_BRANCH=${ANI_CLI_WEB_BRANCH} \
ANI_CLI_BIN=ani-cli \ ANI_CLI_BIN=ani-cli \
ANI_CLI_DOWNLOAD_DIR=/downloads \ ANI_CLI_DOWNLOAD_DIR=/downloads \
ANI_CLI_WEB_HOST=0.0.0.0 \ ANI_CLI_WEB_HOST=0.0.0.0 \
@@ -41,8 +44,24 @@ RUN git clone --depth 1 --branch "${ANI_CLI_BRANCH}" "${ANI_CLI}" /tmp/ani-cli-s
esac \ esac \
&& rm -rf /tmp/ani-cli-src && rm -rf /tmp/ani-cli-src
RUN git clone --depth 1 "${ANI_CLI_WEB}" /app \ RUN curl -fL "${YT_DLP_RELEASE_URL}" -o /usr/local/bin/yt-dlp \
&& rm -rf /app/.git && chmod 0755 /usr/local/bin/yt-dlp \
&& yt-dlp --version
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 COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+144 -322
View File
@@ -1,92 +1,77 @@
# ani-cli web # ani-cli web
A local web UI for a system-wide `ani-cli` install. Local web UI for a system-wide `ani-cli` install.
Current version: `0.32.1` Current version: `0.47.1`
## Project Layout ## What it does
```text - Search anime in `sub` or `dub`, with poster-style results shown below the selection panel and the first English alternate title when available
ani-cli-web/ - Queue downloads with folder, quality, media type, season, and episode controls
app.py Public entrypoint, runtime wiring, watchlist domain logic, and CLI startup. - Optionally retry failed `ani-cli` downloads with `anipy-cli`
app_support.py Shared runtime/state/config helpers plus queue and library file utilities. - Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
Dockerfile Container image build for system-wide ani-cli plus /app ani-cli-web. - Expose a small JSON summary endpoint for Homepage dashboard widgets
docker-compose.yaml Compose service for building and running the container with persistent mounts. - Refresh watchlist episode counts manually or on a schedule
queue_jobs.py Download queue and background watchlist-refresh workers. - Auto-download missing episodes for `Watching` entries after later refreshes, with a per-show `Source name` override for `ani-cli` search
watchlist_identity.py Safer watchlist identity resolution for queued download jobs. - Move fully completed libraries into Jellyfin TV or movie folders with a tracked background handoff job
title_matching.py Shared anime-title normalization and matching helpers. - Send optional Discord webhook notifications
http_handler.py HTTP route handling, request parsing, and remote-access guard.
web_templates.py Shared Search, Config, and Watchlist page markup aggregation.
docker-entrypoint.sh Container startup wrapper with optional ani-cli self-update.
search_page.py Search page template.
config_page.py Config page template.
watchlist_page.py Watchlist page template.
template_helpers.py Shared page-shell navigation and sidebar brand helpers.
ani-cli-web Launcher script.
test_app.py Regression tests with isolated temp-state setup.
VERSION Project version.
CHANGELOG.md Change history.
README.md Project documentation.
```
## Run ## Pages
Start the web app with: - `/`: 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 ```sh
./ani-cli-web ./ani-cli-web
``` ```
For thumbnail debugging, run: Open:
```sh
./ani-cli-web --debug
```
The launcher also accepts `---debug`. You can also enable debug logging with `ANI_CLI_WEB_DEBUG=1`.
Then open:
```text ```text
http://127.0.0.1:8421/ http://127.0.0.1:8421/
``` ```
By default the server binds to `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 ## Docker
The container image installs `ani-cli` system-wide and clones `ani-cli-web` into `/app` during `docker build`. Build:
Default build arguments:
```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 the image:
```sh ```sh
docker build -t ani-cli-web . docker build -t ani-cli-web .
``` ```
Override the `ani-cli` branch when needed: Build a specific `ani-cli-web` branch:
```sh ```sh
docker build \ docker build \
--build-arg ANI_CLI_BRANCH=my-feature-branch \ --build-arg ANI_CLI_WEB_BRANCH=anipy_backup \
--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 \
-t ani-cli-web . -t ani-cli-web .
``` ```
Run the container with the requested persistent mounts: Run:
```sh ```sh
docker run --rm \ docker run --rm \
-p 8421:8421 \ -p 8421:8421 \
-e UPDATE_ON_START=true \
-e USER_UID="$(id -u)" \ -e USER_UID="$(id -u)" \
-e USER_GID="$(id -g)" \ -e USER_GID="$(id -g)" \
-v "$(pwd)/downloads:/downloads" \ -v "$(pwd)/downloads:/downloads" \
@@ -94,315 +79,152 @@ docker run --rm \
ani-cli-web ani-cli-web
``` ```
Or start the same setup with Compose: Compose:
```sh
docker compose up --build -d
```
To keep mounted downloads owned by your host user, start Compose with:
```sh ```sh
USER_UID="$(id -u)" USER_GID="$(id -g)" docker compose up --build -d USER_UID="$(id -u)" USER_GID="$(id -g)" docker compose up --build -d
``` ```
The included `docker-compose.yaml` uses the same defaults as the `Dockerfile`, mounts `./downloads` to `/downloads`, mounts `./.ani-cli-web` to `/app/.ani-cli-web`, and lets you override build/runtime settings through environment variables such as: Docker notes:
- 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
## Key behavior
### 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
### Auto-download
- 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
### Download fallback
- 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
### Jellyfin handoff
- 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
## Homepage widget API
For Homepage's Custom API widget, use:
```text ```text
ANI_CLI=https://github.com/pystardust/ani-cli.git http://127.0.0.1:8421/api/watchlist/homepage
ANI_CLI_BRANCH=master
ANI_CLI_WEB=https://gitea.coreplay.eu/Dymas/ani-cli-web.git
ANI_CLI_WEB_PORT=8421
UPDATE_ON_START=false
USER_UID=
USER_GID=
ANI_CLI_WEB_ALLOW_REMOTE=false
ANI_CLI_WEB_REMOTE_TOKEN=
``` ```
Container defaults: Example response:
- `ani-cli` is installed at `/usr/local/bin/ani-cli`. ```json
- `ani-cli-web` is cloned into `/app` and started from there. {
- Container startup now runs `python3 /app/app.py` directly, so it does not depend on the `ani-cli-web` launcher file having executable permissions inside the image. "watchlist": 12,
- Downloads are written to `/downloads`, so bind-mount `./downloads:/downloads`. "planned": 4,
- App state stays under `/app/.ani-cli-web`, so bind-mount `./.ani-cli-web:/app/.ani-cli-web`. "finished": 31,
- The image sets `ANI_CLI_WEB_HOST=0.0.0.0` and exposes port `8421`. "dropped": 2,
- `UPDATE_ON_START=true` runs `sudo ani-cli --update` before the web app starts. "total": 49
- Set `USER_UID` and `USER_GID` together to run the web app as a specific numeric host user/group, which keeps new files on mounted folders such as `./downloads` from being owned by `root`. }
```
The installed `ani-cli` update URL is rewritten during the image build so `ani-cli --update` follows the same `ANI_CLI_BRANCH` you built into the image when the `ANI_CLI` remote is a GitHub repository. This endpoint is intentionally small so Homepage can map the returned values directly into its widget fields.
## Overview ## Runtime info
`ani-cli-web` keeps search and download management on `/`, saved defaults on `/config`, and the watchlist on `/watchlist`, all with the same sidebar navigation layout and a Tsuki-inspired visual style. The Config page runtime panel shows:
The app currently includes: - `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`
- Search against AllAnime with `sub` or `dub` mode selection. ## Remote access
- Episode loading for the selected result.
- Queueing downloads with editable library name, season, episode range, quality, and target folder.
- A dedicated Config page for saved default folder, mode, and quality settings.
- Malformed config save requests now return `400 Bad Request` when the JSON body is not an object, instead of resetting values back to defaults.
- A paged SQLite-backed download queue with retry, cancel, remove, retry-failed, and clear-finished actions.
- A paged SQLite-backed watchlist with summary cards, per-show refresh, bulk refresh, source links, and thumbnail tools.
- Duplicate watchlist adds now return `200 OK` with `created: false`, keeping the API status aligned with the no-op result.
- Malformed watchlist add requests now also return `400 Bad Request` when the JSON body is not an object.
- Watchlist category tabs for `Watching`, `Planned`, `Finished`, and `Dropped`, with per-anime category editing.
- AnimeSchedule-backed watchlist completion tracking that compares live sub/dub availability with the expected total episode count when known.
- Automatic watchlist sync after successful downloads, including downloaded-vs-manual finished markers.
- Per-show background watchlist refresh queueing so adding a show or syncing a finished download does not block the request on upstream episode, schedule, and thumbnail lookups.
- Queued per-show watchlist refresh work now survives app restarts and resumes from rows still marked `queued`.
- Per-show watchlist refresh queueing now also deduplicates in-flight refreshes, so the same show is not fetched twice back-to-back while one refresh is already running.
- The watchlist page now silently refreshes itself while queued per-show refresh work is still being processed, so queued entries settle into their updated state without a manual reload.
- The watchlist page now also avoids re-triggering the same unresolved thumbnail warm-up request on every silent poll cycle while a show's thumbnail state is unchanged.
- Background `Refresh All` execution with status polling, no inline thumbnail downloads during the bulk job, and a configurable delay between shows to reduce upstream request bursts.
- Optional scheduled background watchlist refreshes with Config page controls for enable/disable and refresh period.
- Scheduled background refresh runs now log queue, start, and completion status to stdout so container logs show whether they finished successfully, with errors, or failed.
- Routine browser polling endpoints such as the search-page queue poll and watchlist refresh-status poll now stay quiet in stdout request logs to reduce log spam.
- Search and Watchlist polling now use a shared serial browser polling helper so slow responses do not cause overlapping poll requests to pile up in the background.
- That shared browser polling helper now also tears itself down on page unload and uses chained `setTimeout` scheduling instead of a bare repeating interval.
- Controlled shutdown and runtime reset now cancel active download workers when waiting for teardown, reducing the chance of orphaned background downloads outliving the web process.
- Runtime reset now also refuses to clear the active global runtime when background workers fail to stop cleanly, preventing stale workers from outliving the runtime object that created them.
- Watchlist refresh networking now uses a shorter dedicated timeout, which helps per-show and bulk watchlist shutdown paths settle more predictably when upstream metadata services are slow.
- Persistent watchlist refresh history that survives restarts and marks interrupted bulk refresh jobs clearly.
- Local thumbnail caching plus manual thumbnail upload when automatic cover lookup fails.
- A refreshed glassy dark UI inspired by the Tsuki frontend design language.
- Shared page templates and backend services split into dedicated modules while keeping `app.py` and `web_templates.py` as stable public aggregation points.
- Broader regression coverage for higher-risk queue, watchlist, remote-access, and handler flows.
- Search and watchlist requests guard against stale out-of-order async responses during rapid repeated actions.
- Search-page queue polling also guards against stale out-of-order refresh responses now.
- Queue log writes are batched during active downloads to reduce SQLite churn on noisy jobs.
- Queue persistence now stores core job metadata and logs in structured SQLite columns instead of treating the whole job as one large JSON blob.
- Queue worker startup failures now degrade into a normal failed job state instead of crashing the background queue thread.
- Unexpected post-launch queue worker failures now also fail the active job cleanly instead of leaving it stuck in `running`.
- Failed and canceled downloads now clean their project-local staging directories automatically so partial files do not accumulate under `.ani-cli-web/staging/`.
- Failed thumbnail refresh attempts now record a retry timestamp, so unresolved covers back off instead of immediately requerying upstream sources on every later warm-up.
- Download-to-watchlist fallback matching now keeps season and part identity intact while still tolerating equivalent season notation such as `2nd Season` vs `Season 2`.
- Project-local runtime state under `.ani-cli-web/`.
## Search Page By default the app only serves loopback clients.
The main page at `/` is where you search and queue downloads. To allow LAN or remote access:
Workflow:
1. Search by anime title or keyword.
2. Pick a result to load available episodes.
3. Adjust library name, season, episode range, quality, and download folder.
4. Add the selected show either to the watchlist or directly to the queue.
The page also includes:
- A live queue view that refreshes automatically.
## Config Page
The Config page at `/config` is where saved defaults now live.
Use it to:
1. Set the default download folder.
2. Choose the default `sub` or `dub` search mode.
3. Choose the default search quality used to prefill the Search page.
4. Enable or disable periodic watchlist refresh checks.
5. Set the refresh period in minutes for that scheduled watchlist job.
6. Set the delay in seconds between shows during bulk watchlist refresh runs.
Those saved defaults are written into the project-local `config.json` and loaded automatically when the Search page opens.
The Config page also shows the current app version plus dependency status in a compact centered layout.
## Access Guard
By default, `ani-cli-web` only serves loopback clients such as `127.0.0.1` and `::1`.
If you intentionally want LAN or remote access, set:
```sh ```sh
ANI_CLI_WEB_ALLOW_REMOTE=1 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 then send either: Optional remote path restriction:
- `Authorization: Bearer <token>` ```sh
- `X-AniCli-Web-Token: <token>` ANI_CLI_WEB_REMOTE_PATH_ROOTS=/downloads:/srv/jellyfin-tv:/srv/jellyfin-movies
For browser access from another machine, you can also open the app once with:
```text
http://YOUR-HOST:8421/?token=your-token-here
``` ```
The server will set an HTTP-only cookie and redirect to the same page without the token in the URL, so normal browser navigation and API polling continue to work afterward. ## State and storage
When the app is placed behind a loopback reverse proxy, forwarded external client IP headers such as `X-Forwarded-For` are also treated as non-local, so proxied internet or LAN traffic still needs the token. Project state is stored in:
This keeps the queue, config, and watchlist APIs safer when the app is intentionally exposed beyond localhost.
Malformed watchlist action payloads now return `400 Bad Request` when required JSON fields are missing, instead of surfacing those client mistakes as `404 Not Found`.
## Download Queue
The queue is stored in SQLite and shown 10 jobs at a time.
Core queue fields and logs are now stored in structured SQLite columns, while JSON payload storage is kept only for extra fields that do not have dedicated columns yet.
Queue actions currently available:
- `Retry` for finished or failed jobs.
- `Cancel` for pending or running jobs.
- `Remove` for completed or failed jobs.
- `Retry failed` for all failed jobs.
- `Remove finished` for completed jobs.
Downloads are staged inside the project-local app state first. After `ani-cli` finishes, files are moved into a Plex/Jellyfin-friendly layout:
```text
ANI_CLI_DOWNLOAD_DIR/anime_name/Season 01/anime_name - S01E01.mp4
```
## Watchlist
The watchlist stores one row per show in SQLite, keeps separate `sub` and `dub` episode counts, and assigns each show to one of four categories:
- `Watching`
- `Planned`
- `Finished`
- `Dropped`
You can add entries in two ways:
1. From the Search page with `Add to watchlist`, choosing the category first.
2. Manually on `/watchlist` with a title, AllAnime `show_id`, and category.
Watchlist features:
1. `Refresh` updates one tracked show.
2. `Refresh All` runs in the background, rejects duplicate queueing while a refresh is already pending or running, preserves interrupted job status across restarts, and refreshes shows one by one with a configurable delay without doing inline thumbnail downloads for every entry.
3. `Open` jumps back to the Search page with the title pre-filled.
4. Clicking the anime title opens the AllManga source page at `bangumi/<show_id>` in a new tab.
5. `Remove` deletes the watchlist entry and clears its cached poster file.
6. Poster cards use a compact multi-column grid with pagination at 30 cards per page.
7. Summary cards show tracked shows plus total `sub` and `dub` episode counts.
8. Episode pills show `current / total` progress when AnimeSchedule exposes the planned episode count.
9. `Sub` and `Dub` pills turn green and gain `Sub ready` or `Dub ready` tags when that release track appears fully finished and ready for download.
10. Each category has its own tab on the watchlist page.
11. Every card includes an inline category selector so you can move tracked anime between tabs at any time.
12. Finished shows display a small check badge: green means the anime was downloaded through the app, gray means it was marked finished manually.
### Download Sync
Successful downloads now feed back into the watchlist automatically.
Current behavior:
- If the anime is already on the watchlist, a successful download marks it as downloaded and keeps its current category.
- If the anime is not yet on the watchlist, `ani-cli-web` creates a new watchlist entry in `Finished` after the download succeeds.
- Those add and download-sync paths now queue a background refresh for episode counts, AnimeSchedule metadata, and thumbnails instead of waiting for all upstream fetches before returning.
- If a queued job ever reaches the older search-based fallback path for `show_id` resolution, the app now only accepts that fallback when the queued title still matches the returned result confidently, preferring to fail safe over marking the wrong anime as downloaded.
- That fallback now also preserves season and part distinctions, so `Season 2` downloads do not silently sync against a different season with the same base title.
- When that safer fallback still cannot resolve a `show_id`, the failure now keeps the specific reason so logs and error messages are easier to diagnose.
- Existing watchlist entries from older versions are migrated into the `Watching` category automatically.
### Thumbnail Behavior
Watchlist thumbnails use the local `/api/watchlist/thumb/...` route, not direct remote image URLs.
Current thumbnail behavior:
- Cached thumbnails render from the local project cache when available.
- The page adds a fresh nonce to thumbnail URLs to avoid stale browser-cached misses.
- Missing visible covers are warmed up in small batches instead of one request per card all at once.
- The thumbnail warm-up API now requires `show_ids` to be a JSON array and accepts explicit boolean-style `force` values such as `true` and `false`.
- The page only retries an unresolved cover warm-up once per thumbnail state change during the current browser session, so silent watchlist polling does not keep hammering the same missing poster.
- If a show disappears while a thumbnail warm-up batch is running, that entry is skipped cleanly instead of failing the whole batch request.
- Thumbnail image tags only hit the local route for already-cached files, so opening the watchlist no longer triggers a per-card remote lookup burst.
- AnimeSchedule is tried first for cover lookup.
- Stale stored AnimeSchedule routes and AniDB IDs now fall back to fresh title-based lookups instead of aborting the whole thumbnail refresh attempt.
- If a season-labeled title fails as-is, lookup retries with simplified base-title queries.
- AniDB is used as a fallback when AnimeSchedule does not resolve a cover.
- AniDB fallback reuses the cached parsed title list directly to keep repeated lookups lighter.
- Failed automatic fetch attempts now start the retry window, so bad lookups back off instead of hammering AnimeSchedule and AniDB on every warm-up pass.
### Watchlist Pagination
Watchlist listing responses now clamp out-of-range page requests to the last available page before querying rows, so the returned `page` number and `items` stay consistent even when the browser or API client asks for a page past the end.
Per-card thumbnail tools:
1. `Reload` forces a fresh redownload attempt for that show's poster.
2. `Upload` appears when no thumbnail is currently available and lets you assign a local image manually.
3. Hovering the poster tile or card shows the current thumbnail source hint.
Manual uploads are saved into the same local thumbnail cache used by automatic cover downloads.
### Completion Detection
Watchlist refresh still uses AllAnime, the same upstream source that `ani-cli` uses, to count which `sub` and `dub` episodes are actually available right now.
To decide whether a release track looks complete, `ani-cli-web` also checks AnimeSchedule for:
- the anime's expected total episode count
- the upstream airing status such as `Finished` or `Ongoing`
When AnimeSchedule reports a known total and `Finished`, the watchlist compares that total with the live AllAnime `sub` and `dub` counts:
- `Sub` becomes ready when available sub episodes reach the expected total.
- `Dub` becomes ready when available dub episodes reach the expected total.
If AnimeSchedule does not know the total yet, the watchlist keeps showing the plain available-count value without a completion highlight.
## Runtime State
All runtime state is stored under:
```text ```text
ani-cli-web/.ani-cli-web/ ani-cli-web/.ani-cli-web/
``` ```
That folder currently holds: Main contents:
- `config.json` - `config.json`
- `remote-sessions.json`
- `state.sqlite3` - `state.sqlite3`
- legacy `queue.json` migration input when present
- legacy `watchlist.json` migration input when present
- `thumbnails/` - `thumbnails/`
- AniDB title cache data
- staging files for active downloads
Legacy files from `~/.config/ani-cli-web/` and `~/.local/state/ani-cli-web/` are migrated into the project-local folder automatically on startup. Downloaded files are organized like this:
The earlier project-local `queue.sqlite3` filename is also migrated automatically into `state.sqlite3`.
```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 ## Configuration
Environment variables: Most users only need these environment variables:
- `ANI_CLI_BIN`: path or command name for `ani-cli`; defaults to `ani-cli` from `PATH`. - `ANI_CLI_BIN`
- `ANI_CLI_DOWNLOAD_DIR`: initial default download folder. - `ANIPY_CLI_BIN`
- `ANI_CLI_MODE`: initial mode, `sub` or `dub`. - `ANI_CLI_WEB_HOST`
- `ANI_CLI_QUALITY`: initial quality such as `best`, `1080`, or `720`. - `ANI_CLI_WEB_PORT`
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`. - `ANI_CLI_WEB_ALLOW_REMOTE`
- `ANI_CLI_WEB_PORT`: server port, default `8421`. - `ANI_CLI_WEB_AUTH_USERNAME`
- `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients when set to `1`, `true`, `yes`, or `on`. - `ANI_CLI_WEB_AUTH_PASSWORD`
- `ANI_CLI_WEB_REMOTE_TOKEN`: required shared secret for non-loopback clients when remote access is enabled. - `ANI_CLI_WEB_REMOTE_PATH_ROOTS`
- `ANI_CLI_WEB_DEBUG`: enable debug logging when set to `1`, `true`, `yes`, or `on`. - `ANI_CLI_WEB_DEBUG`
- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory path; useful for isolated test runs or custom storage locations. - `ANI_CLI_WEB_STATE_ROOT`
- `UPDATE_ON_START`: when set to `1`, `true`, `yes`, or `on` in the container, runs `sudo ani-cli --update` before `ani-cli-web` starts.
- `USER_UID` and `USER_GID`: optional numeric UID/GID pair for running the container process as a specific host user/group so mounted files are created with that ownership.
Saved defaults from the UI are written into the project-local `config.json`. Container-specific variables:
The saved config now also includes `watchlist_auto_refresh_enabled`, `watchlist_auto_refresh_minutes`, and `watchlist_refresh_delay_seconds`, which control the periodic background watchlist refresh schedule and the per-show pacing used by bulk refresh jobs. - `UPDATE_ON_START`
- `USER_UID`
- `USER_GID`
## Notes Everything else can usually be configured from `/config`.
- Importing `app.py` no longer boots the runtime worker threads automatically; runtime setup is deferred until startup or first use. ## Testing
- Importing `app_support.py` no longer migrates or creates runtime state on its own; migrations now happen during runtime bootstrap.
- Cheap routes such as the page shells, `/api/version`, `/api/dependencies`, and `/api/config` do not need to boot the full runtime before responding. Run the regression suite with:
- The CLI startup path itself also stays lazy now; runtime bootstrap happens when the first runtime-backed request arrives.
- Watchlist and queue data are stored in the same project-local SQLite database file, `state.sqlite3`. ```sh
- Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode cache directories at any depth are ignored by git. python3 -m unittest test_app.py
- The Docker image clones both upstream repositories during build, so the app source inside the container comes from the configured git remotes rather than the local build context. ```
- Focused regression tests live in `test_app.py`, run against an isolated temporary state root, and can be started with `python3 -m unittest test_app.py`.
+1 -1
View File
@@ -1 +1 @@
0.32.1 0.47.1
+1096 -68
View File
File diff suppressed because it is too large Load Diff
+778 -25
View File
@@ -2,21 +2,31 @@
"""Shared constants and helper utilities for ani-cli-web.""" """Shared constants and helper utilities for ani-cli-web."""
import hashlib
import hmac import hmac
import ipaddress import ipaddress
import json import json
import mimetypes
import os import os
import re import re
import secrets
import shutil import shutil
import threading
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import urllib.error
import urllib.request
from urllib.parse import urlparse
from uuid import uuid4 from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" 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" APP_NAME = "ani-cli-web"
VERSION = "0.32.1" VERSION_FILE = PROJECT_ROOT / "VERSION"
CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md"
ALLANIME_BASE = "allanime.day" ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to" ALLANIME_REFERER = "https://allmanga.to"
@@ -31,6 +41,7 @@ DEBUG_MODE = False
QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"} QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"}
MODE_CHOICES = {"sub", "dub"} MODE_CHOICES = {"sub", "dub"}
MEDIA_TYPE_CHOICES = {"tv", "movie"}
WATCHLIST_CATEGORY_LABELS = { WATCHLIST_CATEGORY_LABELS = {
"watching": "Watching", "watching": "Watching",
"planned": "Planned", "planned": "Planned",
@@ -48,6 +59,44 @@ WATCHLIST_AUTO_REFRESH_MAX_MINUTES = 7 * 24 * 60
WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS = 5 WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS = 5
WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0 WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0
WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300 WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300
DISCORD_WEBHOOK_EVENT_CHOICES = (
"download_completed",
"jellyfin_sync_success",
"jellyfin_sync_failed",
"watchlist_refresh_new_episodes",
"watchlist_refresh_failed",
"watchlist_refresh_interrupted",
"runtime_error",
)
DISCORD_WEBHOOK_EVENT_LABELS = {
"download_completed": "Download completed",
"jellyfin_sync_success": "Jellyfin move succeeded",
"jellyfin_sync_failed": "Jellyfin move failed",
"watchlist_refresh_new_episodes": "Refresh finished with new episodes found",
"watchlist_refresh_failed": "Refresh failed or finished with refresh errors",
"watchlist_refresh_interrupted": "Refresh interrupted by shutdown or restart",
"runtime_error": "Unexpected runtime errors",
}
REMOTE_ACCESS_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
REMOTE_SESSION_STORE_LOCK = threading.RLock()
def load_app_version(default="0.0.0"):
try:
version = VERSION_FILE.read_text(encoding="utf-8").strip()
except OSError:
return default
return version or default
def load_project_text_file(path, default=""):
try:
return Path(path).read_text(encoding="utf-8")
except OSError:
return default
VERSION = load_app_version()
def env_flag(name, default=False): def env_flag(name, default=False):
@@ -65,18 +114,140 @@ def remote_access_allowed():
return env_flag("ANI_CLI_WEB_ALLOW_REMOTE") return env_flag("ANI_CLI_WEB_ALLOW_REMOTE")
def remote_access_token(): def remote_access_username(config=None):
return str(os.environ.get("ANI_CLI_WEB_REMOTE_TOKEN") or "").strip() 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(): def remote_access_password(config=None):
return bool(remote_access_token()) 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() provided = str(value or "").strip()
expected = remote_access_token() fingerprint = remote_access_session_fingerprint(config)
return bool(expected and provided and hmac.compare_digest(provided, expected)) 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(): def background_workers_enabled():
@@ -120,10 +291,22 @@ DEFAULT_CONFIG = {
"download_dir": default_download_dir(), "download_dir": default_download_dir(),
"mode": os.environ.get("ANI_CLI_MODE", "sub"), "mode": os.environ.get("ANI_CLI_MODE", "sub"),
"quality": os.environ.get("ANI_CLI_QUALITY", "best"), "quality": os.environ.get("ANI_CLI_QUALITY", "best"),
"anipy_cli_fallback_enabled": False,
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES, "watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
"watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS, "watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS,
"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(): def now_iso():
@@ -216,6 +399,7 @@ WATCHLIST_JSON_PATH = project_state_path("watchlist.json")
THUMBNAIL_ROOT = project_state_dir("thumbnails") THUMBNAIL_ROOT = project_state_dir("thumbnails")
THUMBNAIL_RETRY_SECONDS = 15 * 60 THUMBNAIL_RETRY_SECONDS = 15 * 60
ANIDB_TITLES_PATH = project_state_path("anidb-anime-titles.xml.gz") ANIDB_TITLES_PATH = project_state_path("anidb-anime-titles.xml.gz")
REMOTE_SESSIONS_PATH = project_state_path("remote-sessions.json")
ANIDB_TITLES_CACHE = { ANIDB_TITLES_CACHE = {
"mtime": None, "mtime": None,
"entries": None, "entries": None,
@@ -263,14 +447,39 @@ def normalize_config(data):
if quality.endswith("p") and quality[:-1].isdigit(): if quality.endswith("p") and quality[:-1].isdigit():
quality = quality[:-1] quality = quality[:-1]
download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"]) download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"])
anipy_cli_fallback_enabled = config.get("anipy_cli_fallback_enabled")
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled") auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes") auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds") refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds")
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): if isinstance(auto_refresh_enabled, str):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"} auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
else: else:
auto_refresh_enabled = bool(auto_refresh_enabled) 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: try:
auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES) auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES)
@@ -292,16 +501,481 @@ def normalize_config(data):
config["mode"] = mode if mode in MODE_CHOICES else "sub" config["mode"] = mode if mode in MODE_CHOICES else "sub"
config["quality"] = quality if quality in QUALITY_CHOICES else "best" config["quality"] = quality if quality in QUALITY_CHOICES else "best"
config["download_dir"] = str(Path(download_dir).expanduser()) config["download_dir"] = str(Path(download_dir).expanduser())
config["anipy_cli_fallback_enabled"] = anipy_cli_fallback_enabled
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes
config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds
config["auto_download_enabled"] = auto_download_enabled
config["auto_download_mode"] = auto_download_mode if auto_download_mode in MODE_CHOICES else "dub"
config["auto_download_quality"] = auto_download_quality if auto_download_quality in QUALITY_CHOICES else "best"
config["jellyfin_sync_enabled"] = jellyfin_sync_enabled
config["jellyfin_tv_dir"] = str(Path(jellyfin_tv_dir).expanduser()) if jellyfin_tv_dir else ""
config["jellyfin_movie_dir"] = str(Path(jellyfin_movie_dir).expanduser()) if jellyfin_movie_dir else ""
if isinstance(webhook_events, str):
webhook_events = [part.strip() for part in webhook_events.split(",")]
elif not isinstance(webhook_events, list):
webhook_events = []
normalized_events = []
seen_events = set()
for event_name in webhook_events:
normalized_name = str(event_name or "").strip()
if normalized_name in DISCORD_WEBHOOK_EVENT_CHOICES and normalized_name not in seen_events:
seen_events.add(normalized_name)
normalized_events.append(normalized_name)
config["discord_webhook_url"] = webhook_url
config["discord_webhook_events"] = normalized_events
config["auth_username"] = auth_username
config["auth_password"] = auth_password
return config return config
def sanitize_public_config(config):
source = normalize_config(config or {})
return {key: value for key, value in source.items() if key not in PRIVATE_CONFIG_KEYS}
def _env_remote_path_roots():
raw = str(os.environ.get("ANI_CLI_WEB_REMOTE_PATH_ROOTS") or "").strip()
if not raw:
return []
roots = []
for part in raw.split(os.pathsep):
text = str(part or "").strip()
if not text:
continue
try:
resolved = Path(text).expanduser().resolve()
except OSError:
continue
if resolved not in roots:
roots.append(resolved)
return roots
def configured_remote_path_roots(config=None):
normalized = normalize_config(config or {})
roots = list(_env_remote_path_roots())
for raw_path in (
normalized.get("download_dir"),
normalized.get("jellyfin_tv_dir"),
normalized.get("jellyfin_movie_dir"),
):
text = str(raw_path or "").strip()
if not text:
continue
try:
resolved = Path(text).expanduser().resolve()
except OSError:
continue
if resolved not in roots:
roots.append(resolved)
return roots
def path_is_within_roots(path, roots):
try:
candidate = Path(path).expanduser().resolve()
except OSError:
return False
for root in roots or []:
try:
root_path = Path(root).expanduser().resolve()
except OSError:
continue
if candidate == root_path or root_path in candidate.parents:
return True
return False
def validate_discord_webhook_url(url):
text = str(url or "").strip()
if not text:
return ""
parsed = urlparse(text)
if parsed.scheme != "https":
raise ValueError("Discord webhook URL must use https.")
host = str(parsed.netloc or "").split("@", 1)[-1].split(":", 1)[0].strip().lower()
if host not in {"discord.com", "ptb.discord.com", "canary.discord.com", "discordapp.com"}:
raise ValueError("Discord webhook URL must point to an official Discord webhook host.")
if not str(parsed.path or "").startswith("/api/webhooks/"):
raise ValueError("Discord webhook URL must use the Discord /api/webhooks/ path.")
return text
def discord_webhook_enabled(config, event_name, force=False):
if not isinstance(config, dict):
return False
webhook_url = str(config.get("discord_webhook_url") or "").strip()
if not webhook_url:
return False
if force:
return True
return str(event_name or "").strip() in {
str(name).strip() for name in config.get("discord_webhook_events") or []
}
def thumbnail_file_path(name):
filename = Path(str(name or "")).name
if not filename:
return None
project_path = THUMBNAIL_ROOT / filename
if project_path.exists():
return project_path
legacy_path = LEGACY_THUMBNAIL_ROOT / filename
if legacy_path.exists():
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
try:
shutil.move(str(legacy_path), str(project_path))
debug_log("thumbnail.cache.migrated", from_path=legacy_path, to_path=project_path)
return project_path
except OSError as exc:
debug_log("thumbnail.cache.migrate_failed", from_path=legacy_path, to_path=project_path, error=exc)
return legacy_path
return project_path
def _discord_file_part(boundary, field_name, attachment_name, content_type, payload):
safe_name = str(attachment_name or "attachment.bin").replace('"', "")
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field_name}"; filename="{safe_name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
return header + payload + b"\r\n"
def _discord_text_part(boundary, field_name, value):
return (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field_name}"\r\n\r\n'
f"{value}\r\n"
).encode("utf-8")
def _post_discord_webhook(url, payload, attachments=None, timeout=10):
attachments = attachments or []
headers = {"User-Agent": AGENT}
if attachments:
boundary = f"ani-cli-web-{uuid4().hex}"
body = bytearray()
body.extend(_discord_text_part(boundary, "payload_json", json.dumps(payload)))
for index, attachment in enumerate(attachments):
path = attachment.get("path")
if path is None or not Path(path).exists():
continue
data = Path(path).read_bytes()
if not data:
continue
filename = str(attachment.get("filename") or Path(path).name or f"attachment-{index}")
content_type = str(attachment.get("content_type") or mimetypes.guess_type(filename)[0] or "application/octet-stream")
body.extend(_discord_file_part(boundary, f"files[{index}]", filename, content_type, data))
body.extend(f"--{boundary}--\r\n".encode("utf-8"))
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
request_data = bytes(body)
else:
headers["Content-Type"] = "application/json"
request_data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=request_data, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
response.read()
return getattr(response, "status", 204)
except urllib.error.HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace").strip()
if details:
raise RuntimeError(f"Discord webhook rejected the request: HTTP {exc.code} {details}") from exc
raise RuntimeError(f"Discord webhook rejected the request: HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Could not reach Discord webhook: {exc}") from exc
except TimeoutError as exc:
raise RuntimeError("Discord webhook request timed out") from exc
def _chunk_discord_lines(lines, max_chars=3800):
chunks = []
current = []
current_length = 0
for raw_line in lines or []:
line = str(raw_line or "").strip()
if not line:
continue
added_length = len(line) + (1 if current else 0)
if current and current_length + added_length > max_chars:
chunks.append(current)
current = [line]
current_length = len(line)
continue
current.append(line)
current_length += added_length
if current:
chunks.append(current)
return chunks
def _post_discord_embeds_with_file_list(webhook_url, content, summary_embed, file_lines, attachments=None):
chunks = _chunk_discord_lines(file_lines)
if not chunks:
_post_discord_webhook(webhook_url, {"content": content, "embeds": [summary_embed]}, attachments=attachments)
return
total_chunks = len(chunks)
first_batch = chunks[:9]
request_embeds = [summary_embed]
for index, chunk in enumerate(first_batch, start=1):
request_embeds.append(
{
"title": f"Saved files ({index}/{total_chunks})",
"description": "\n".join(chunk)[:4096],
"color": summary_embed.get("color", 5763719),
}
)
_post_discord_webhook(webhook_url, {"content": content, "embeds": request_embeds}, attachments=attachments)
sent_chunks = len(first_batch)
while sent_chunks < total_chunks:
batch = chunks[sent_chunks : sent_chunks + 10]
embeds = []
for offset, chunk in enumerate(batch, start=1):
chunk_index = sent_chunks + offset
embeds.append(
{
"title": f"Saved files ({chunk_index}/{total_chunks})",
"description": "\n".join(chunk)[:4096],
"color": summary_embed.get("color", 5763719),
}
)
_post_discord_webhook(webhook_url, {"content": content, "embeds": embeds})
sent_chunks += len(batch)
def send_discord_webhook_event(config, event_name, payload=None, force=False):
normalized = normalize_config(config or {})
event = str(event_name or "").strip()
if not discord_webhook_enabled(normalized, event, force=force):
return {"sent": False, "reason": "disabled"}
webhook_url = validate_discord_webhook_url(normalized["discord_webhook_url"])
payload = payload or {}
if event == "test":
request_payload = {
"content": "ani-cli-web webhook test",
"embeds": [
{
"title": "Webhook test",
"description": "The Discord webhook is configured and reachable from ani-cli-web.",
"color": 5814783,
"footer": {"text": f"{APP_NAME} {VERSION}"},
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
if event == "watchlist_refresh_new_episodes":
items = payload.get("items") if isinstance(payload.get("items"), list) else []
if not items:
return {"sent": False, "reason": "no_items"}
embeds = []
attachments = []
for index, item in enumerate(items[:10]):
title = str(item.get("title") or item.get("show_id") or "Unknown anime").strip()
sub_count = int(item.get("sub_count") or 0)
dub_count = int(item.get("dub_count") or 0)
sub_delta = int(item.get("sub_delta") or 0)
dub_delta = int(item.get("dub_delta") or 0)
expected_count = int(item.get("expected_count") or 0)
airing_status = str(item.get("airing_status") or "").strip()
description_lines = [
f"Sub: {sub_count}" + (f" (+{sub_delta})" if sub_delta > 0 else ""),
f"Dub: {dub_count}" + (f" (+{dub_delta})" if dub_delta > 0 else ""),
]
if expected_count > 0:
description_lines.append(f"Expected episodes: {expected_count}")
if airing_status:
description_lines.append(f"Status: {airing_status}")
embed = {
"title": title[:256],
"description": "\n".join(description_lines)[:4096],
"color": 5763719,
}
thumb_path = thumbnail_file_path(item.get("thumbnail_path"))
if thumb_path and thumb_path.exists():
attachment_name = f"thumb-{index}{thumb_path.suffix or '.jpg'}"
attachments.append({"path": thumb_path, "filename": attachment_name})
embed["thumbnail"] = {"url": f"attachment://{attachment_name}"}
embeds.append(embed)
extra_count = max(0, len(items) - len(embeds))
request_payload = {
"content": (
f"Watchlist refresh found new episodes for {len(items)} anime."
+ (f" Showing the first {len(embeds)} entries." if extra_count else "")
),
"embeds": embeds,
}
_post_discord_webhook(webhook_url, request_payload, attachments=attachments)
return {"sent": True, "event": event, "count": len(items)}
if event == "download_completed":
title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip()
moved_files = payload.get("moved_files") if isinstance(payload.get("moved_files"), list) else []
description_lines = []
if payload.get("mode"):
description_lines.append(f"Mode: {str(payload.get('mode')).strip()}")
if payload.get("episodes"):
description_lines.append(f"Episodes: {str(payload.get('episodes')).strip()}")
if payload.get("category"):
description_lines.append(f"Watchlist category: {str(payload.get('category')).strip()}")
if moved_files:
description_lines.append(f"Saved files: {len(moved_files)}")
embed = {
"title": title[:256],
"description": "\n".join(description_lines)[:4096] or "Download completed.",
"color": 5763719,
}
attachments = []
thumb_path = thumbnail_file_path(payload.get("thumbnail_path"))
if thumb_path and thumb_path.exists():
attachment_name = f"download-thumb{thumb_path.suffix or '.jpg'}"
attachments.append({"path": thumb_path, "filename": attachment_name})
embed["thumbnail"] = {"url": f"attachment://{attachment_name}"}
_post_discord_embeds_with_file_list(
webhook_url,
"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_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":
error_lines = []
for item in (payload.get("items") if isinstance(payload.get("items"), list) else [])[:8]:
title = str(item.get("title") or item.get("show_id") or "Unknown anime").strip()
error_text = str(item.get("error") or "").strip()
if title and error_text:
error_lines.append(f"{title}: {error_text}")
if payload.get("error") and not error_lines:
error_lines.append(str(payload["error"]).strip())
request_payload = {
"content": "Watchlist refresh reported errors.",
"embeds": [
{
"title": "Refresh errors",
"description": "\n".join(error_lines[:8])[:4096] or "The refresh completed with errors.",
"color": 15158332,
"fields": [
{"name": "Completed", "value": str(int(payload.get("completed") or 0)), "inline": True},
{"name": "Total", "value": str(int(payload.get("total") or 0)), "inline": True},
{"name": "Errors", "value": str(int(payload.get("errors") or 0)), "inline": True},
],
"footer": {"text": f"Source: {str(payload.get('source') or 'manual')}"},
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
if event == "watchlist_refresh_interrupted":
request_payload = {
"content": "Watchlist refresh was interrupted.",
"embeds": [
{
"title": "Refresh interrupted",
"description": str(payload.get("message") or "The watchlist refresh did not finish.").strip()[:4096],
"color": 16763904,
"fields": [
{"name": "Completed", "value": str(int(payload.get("completed") or 0)), "inline": True},
{"name": "Total", "value": str(int(payload.get("total") or 0)), "inline": True},
],
"footer": {"text": f"Source: {str(payload.get('source') or 'manual')}"},
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
if event == "runtime_error":
source = str(payload.get("source") or "runtime").strip()
error_text = str(payload.get("error") or "Unknown error").strip()
details = str(payload.get("details") or "").strip()
description = error_text if not details else f"{error_text}\n{details}"
request_payload = {
"content": "ani-cli-web logged an unexpected error.",
"embeds": [
{
"title": source[:256],
"description": description[:4096],
"color": 15158332,
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
raise ValueError(f"Unsupported Discord webhook event: {event}")
def strip_control(value): def strip_control(value):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n") 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): def printable_command(command):
return " ".join(sh_quote(part) for part in command) return " ".join(sh_quote(part) for part in command)
@@ -338,9 +1012,30 @@ def normalize_season(value):
return str(number) return str(number)
def normalize_episode_offset(value):
text = str(value or "").strip()
if not text:
return None
if not re.match(r"^[0-9]+$", text):
raise ValueError("Episode offset must be a positive number")
number = int(text, 10)
if number < 1:
raise ValueError("Episode offset must be a positive number")
return str(number)
def normalize_media_type(value, default="tv"):
media_type = str(value or default).strip().lower()
return media_type if media_type in MEDIA_TYPE_CHOICES else default
def job_output_dir(job): def job_output_dir(job):
anime_name = sanitize_path_component(job.get("anime_name") or job.get("title")) 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): def job_staging_dir(job):
@@ -361,11 +1056,46 @@ def episode_label(value):
return re.sub(r"[^0-9A-Za-z.]+", "-", text).strip("-") or "00" 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): def extract_episode_from_filename(path):
match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem) match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem)
return match.group(1) if match else None return match.group(1) if match else None
def episode_values_from_spec(value):
spec = str(value or "").strip()
if not spec:
return []
values = []
for part in re.split(r"\s+", spec):
if not part:
continue
if "-" in part:
start_text, end_text = [piece.strip() for piece in part.split("-", 1)]
if start_text.isdigit() and end_text.isdigit():
start = int(start_text, 10)
end = int(end_text, 10)
if start <= end:
values.extend(str(number) for number in range(start, end + 1))
continue
values.append(part)
return values
def unique_destination(path): def unique_destination(path):
if not path.exists(): if not path.exists():
return path return path
@@ -381,31 +1111,40 @@ def finalize_library_files(job):
staging_dir = job_staging_dir(job) staging_dir = job_staging_dir(job)
target_dir = job_output_dir(job) target_dir = job_output_dir(job)
library_name = sanitize_path_component(job.get("anime_name") or job.get("title")) 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) season = season_label(job)
episode_offset = normalize_episode_offset(job.get("episode_offset"))
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
files = sorted( files = sorted(
[path for path in staging_dir.iterdir() if path.is_file()], [path for path in staging_dir.rglob("*") if path.is_file()],
key=lambda path: (path.stat().st_mtime, path.name), key=lambda path: (path.stat().st_mtime, str(path.relative_to(staging_dir)).lower()),
) )
if not files: if not files:
raise RuntimeError("No downloaded files were found in the staging folder") raise RuntimeError("No downloaded files were found in the staging folder")
moved = [] moved = []
fallback_episode = 1 fallback_episode = 1
requested_episode_values = episode_values_from_spec(job.get("episodes"))
backend = str(job.get("download_backend") or "").strip().lower()
for source in files: for source in files:
if media_type == "movie":
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) ep = extract_episode_from_filename(source)
if ep is None: if ep is None:
ep = str(fallback_episode) ep = str(fallback_episode)
fallback_episode += 1 fallback_episode += 1
ep = apply_episode_offset(ep, episode_offset)
final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}" final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}"
destination = unique_destination(target_dir / final_name) destination = unique_destination(target_dir / final_name)
shutil.move(str(source), str(destination)) shutil.move(str(source), str(destination))
moved.append(str(destination)) moved.append(str(destination))
try: shutil.rmtree(staging_dir, ignore_errors=True)
staging_dir.rmdir()
except OSError:
pass
return moved return moved
@@ -417,16 +1156,11 @@ def build_job(payload, config):
anime_name = sanitize_path_component(payload.get("anime_name") or title) anime_name = sanitize_path_component(payload.get("anime_name") or title)
if not query: if not query:
raise ValueError("Missing search 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() mode = str(payload.get("mode") or config["mode"]).lower()
quality = str(payload.get("quality") or config["quality"]).lower() quality = str(payload.get("quality") or config["quality"]).lower()
download_dir = str(payload.get("download_dir") or config["download_dir"]).strip() 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: if mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub") raise ValueError("Mode must be sub or dub")
if quality not in QUALITY_CHOICES: if quality not in QUALITY_CHOICES:
@@ -437,9 +1171,11 @@ def build_job(payload, config):
"show_id": str(payload.get("show_id") or "").strip(), "show_id": str(payload.get("show_id") or "").strip(),
"title": title, "title": title,
"anime_name": anime_name, "anime_name": anime_name,
"media_type": media_type,
"season": normalize_season(payload.get("season")), "season": normalize_season(payload.get("season")),
"episode_offset": normalize_episode_offset(payload.get("episode_offset")),
"query": query, "query": query,
"result_index": index, "result_index": None,
"mode": mode, "mode": mode,
"quality": quality, "quality": quality,
"episodes": validate_episode_spec(payload.get("episodes")), "episodes": validate_episode_spec(payload.get("episodes")),
@@ -455,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 = [ command = [
ANI_CLI, ANI_CLI,
"-d", "-d",
@@ -463,8 +1218,6 @@ def command_for_job(job):
job["quality"], job["quality"],
"-e", "-e",
job["episodes"], job["episodes"],
"-S",
str(job["result_index"]),
] ]
if job["mode"] == "dub": if job["mode"] == "dub":
command.append("--dub") command.append("--dub")
+652 -30
View File
@@ -2,9 +2,19 @@
"""Config page template for ani-cli-web.""" """Config page template for ani-cli-web."""
from app_support import DISCORD_WEBHOOK_EVENT_CHOICES, DISCORD_WEBHOOK_EVENT_LABELS
from template_helpers import BROWSER_API_HELPER, render_page_links, render_sidebar_brand from template_helpers import BROWSER_API_HELPER, render_page_links, render_sidebar_brand
WEBHOOK_EVENT_OPTIONS = "\n".join(
(
f' <label class="check-item"><input class="webhook-event" type="checkbox" value="{event_name}"> '
f"<span>{DISCORD_WEBHOOK_EVENT_LABELS[event_name]}</span></label>"
)
for event_name in DISCORD_WEBHOOK_EVENT_CHOICES
)
CONFIG_HTML = r"""<!doctype html> CONFIG_HTML = r"""<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -49,7 +59,7 @@ CONFIG_HTML = r"""<!doctype html>
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%); background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
opacity: 0.5; opacity: 0.5;
} }
button, input, select { button, input, select, textarea {
font: inherit; font: inherit;
} }
button { button {
@@ -74,7 +84,7 @@ CONFIG_HTML = r"""<!doctype html>
font-weight: 700; font-weight: 700;
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26); box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
} }
input, select { input, select, textarea {
width: 100%; width: 100%;
min-height: 42px; min-height: 42px;
border-radius: 14px; border-radius: 14px;
@@ -85,7 +95,12 @@ CONFIG_HTML = r"""<!doctype html>
outline: none; outline: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
} }
input:focus, select:focus { textarea {
min-height: 108px;
padding: 12px 13px;
resize: vertical;
}
input:focus, select:focus, textarea:focus {
border-color: var(--focus); border-color: var(--focus);
box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12); box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12);
} }
@@ -119,7 +134,7 @@ CONFIG_HTML = r"""<!doctype html>
gap: 20px; gap: 20px;
min-width: 0; min-width: 0;
align-content: start; align-content: start;
justify-items: start; justify-items: stretch;
} }
h1, h2, p { margin: 0; } h1, h2, p { margin: 0; }
h1 { h1 {
@@ -154,18 +169,18 @@ CONFIG_HTML = r"""<!doctype html>
} }
.row > * { flex: 1; } .row > * { flex: 1; }
.page-links { .page-links {
display: flex; display: grid;
gap: 8px; gap: 8px;
flex-wrap: wrap;
padding: 6px; padding: 6px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.page-link { .page-link {
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
width: 100%;
min-height: 40px; min-height: 40px;
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
@@ -208,6 +223,9 @@ CONFIG_HTML = r"""<!doctype html>
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.wide-button {
width: 100%;
}
.chips { .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -228,21 +246,196 @@ CONFIG_HTML = r"""<!doctype html>
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
} }
.path-input {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
.check-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.check-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
text-transform: none;
letter-spacing: 0;
font-size: 13px;
font-weight: 600;
}
.check-item input {
width: 18px;
min-height: 18px;
margin: 0;
accent-color: var(--accent);
}
.inline-actions {
display: flex;
gap: 10px;
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 { .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) { @media (max-width: 900px) {
.app { grid-template-columns: 1fr; } .app { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); } aside { border-right: 0; border-bottom: 1px solid var(--line); }
.toolbar, .row { align-items: stretch; flex-direction: column; } .toolbar, .row { align-items: stretch; flex-direction: column; }
} }
@media (max-width: 1080px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.form-grid { .form-grid, .check-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -267,12 +460,18 @@ CONFIG_HTML = r"""<!doctype html>
<h2>Runtime info</h2> <h2>Runtime info</h2>
<div class="stack muted"> <div class="stack muted">
<p id="versionLine">Loading version...</p> <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> <p>Saved settings live in `.ani-cli-web/config.json` inside this project.</p>
</div> </div>
<button class="ghost wide-button" id="openChangelogBtn" type="button">Changelog</button>
</section> </section>
</aside> </aside>
<main> <main>
<section class="deps" id="deps"></section>
<div class="notice" id="notice"></div>
<div class="settings-grid">
<section class="settings settings-main"> <section class="settings settings-main">
<div class="toolbar"> <div class="toolbar">
<div> <div>
@@ -281,10 +480,12 @@ CONFIG_HTML = r"""<!doctype html>
</div> </div>
<button class="primary" id="saveConfigBtn" type="button">Save</button> <button class="primary" id="saveConfigBtn" type="button">Save</button>
</div> </div>
<div class="notice" id="notice"></div>
<div class="stack"> <div class="stack">
<label>Default folder <label>Default folder
<div class="path-input">
<input id="downloadDir" placeholder="/home/me/Downloads/Anime"> <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> </label>
</div> </div>
<div class="form-grid"> <div class="form-grid">
@@ -306,6 +507,24 @@ CONFIG_HTML = r"""<!doctype html>
<div class="field-hint">Tip: these are the saved defaults only. The active search form can still be adjusted per download.</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>
<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"> <section class="settings settings-main">
<div class="toolbar"> <div class="toolbar">
<div> <div>
@@ -330,65 +549,420 @@ CONFIG_HTML = r"""<!doctype html>
<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> <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>
<section class="deps" id="deps"></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> </main>
</div> </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> <script>
const state = { const state = {
config: { config: {
mode: "sub", mode: "sub",
quality: "best", quality: "best",
download_dir: "", download_dir: "",
anipy_cli_fallback_enabled: false,
watchlist_auto_refresh_enabled: false, watchlist_auto_refresh_enabled: false,
watchlist_auto_refresh_minutes: 60, watchlist_auto_refresh_minutes: 60,
watchlist_refresh_delay_seconds: 5 watchlist_refresh_delay_seconds: 5,
} 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); const $ = (id) => document.getElementById(id);
""" + BROWSER_API_HELPER + r""" """ + 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) { function setNotice(text) {
$("notice").textContent = text || ""; $("notice").textContent = text || "";
} }
async function loadConfig() { function setJellyfinSyncButton(running) {
state.config = await api("/api/config"); $("runJellyfinSyncBtn").disabled = !!running;
$("downloadDir").value = state.config.download_dir; $("runJellyfinSyncBtn").textContent = running ? "Running..." : "Run now";
$("configMode").value = state.config.mode;
$("configQuality").value = state.config.quality;
$("watchlistAutoRefreshEnabled").value = String(Boolean(state.config.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = state.config.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = state.config.watchlist_refresh_delay_seconds;
} }
async function saveConfig() { function setJellyfinSyncStatus(job) {
try { const el = $("jellyfinSyncStatus");
const data = await api("/api/config", { if (!job) {
method: "POST", el.textContent = "No recent Jellyfin handoff job.";
body: JSON.stringify({ 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);
}
function formConfigPayload() {
return {
download_dir: $("downloadDir").value, download_dir: $("downloadDir").value,
mode: $("configMode").value, mode: $("configMode").value,
quality: $("configQuality").value, quality: $("configQuality").value,
anipy_cli_fallback_enabled: $("anipyCliFallbackEnabled").value === "true",
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true", watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value, watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value,
}) 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()
};
}
function applyConfig(data) {
state.config = data; state.config = data;
$("downloadDir").value = data.download_dir; $("downloadDir").value = data.download_dir;
$("configMode").value = data.mode; $("configMode").value = data.mode;
$("configQuality").value = data.quality; $("configQuality").value = data.quality;
$("anipyCliFallbackEnabled").value = String(Boolean(data.anipy_cli_fallback_enabled));
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled)); $("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes; $("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds; $("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
$("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")) {
input.checked = selected.has(input.value);
}
}
async function loadConfig() {
applyConfig(await api("/api/config"));
}
async function saveConfig() {
try {
applyConfig(await api("/api/config", {
method: "POST",
body: JSON.stringify(formConfigPayload())
}));
setNotice("Defaults saved."); setNotice("Defaults saved.");
} catch (error) { } catch (error) {
setNotice(error.message); setNotice(error.message);
} }
} }
async function testWebhook() {
try {
const data = await api("/api/config/webhook/test", {
method: "POST",
body: JSON.stringify(formConfigPayload())
});
setNotice(data.message || "Discord webhook test notification sent.");
} catch (error) {
setNotice(error.message);
}
}
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() { async function loadDeps() {
const data = await api("/api/dependencies"); const data = await api("/api/dependencies");
const el = $("deps"); const el = $("deps");
@@ -407,15 +981,63 @@ CONFIG_HTML = r"""<!doctype html>
async function loadVersion() { async function loadVersion() {
const data = await api("/api/version"); const data = await api("/api/version");
$("versionLine").textContent = `${data.name} ${data.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); $("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() { (async function init() {
try { try {
await loadConfig(); await loadConfig();
await pollJellyfinSyncStatus(false);
await loadDeps(); await loadDeps();
await loadVersion(); await loadVersion();
window.setInterval(() => {
pollJellyfinSyncStatus();
}, 2500);
} catch (error) { } catch (error) {
setNotice(error.message); setNotice(error.message);
} }
+3 -1
View File
@@ -8,6 +8,7 @@ services:
ANI_CLI_BRANCH: ${ANI_CLI_BRANCH:-master} ANI_CLI_BRANCH: ${ANI_CLI_BRANCH:-master}
ANI_CLI_WEB: ${ANI_CLI_WEB:-https://gitea.coreplay.eu/Dymas/ani-cli-web.git} ANI_CLI_WEB: ${ANI_CLI_WEB:-https://gitea.coreplay.eu/Dymas/ani-cli-web.git}
image: ani-cli-web:latest image: ani-cli-web:latest
container_name: ani-cli-web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${ANI_CLI_WEB_PORT:-8421}:8421" - "${ANI_CLI_WEB_PORT:-8421}:8421"
@@ -18,7 +19,8 @@ services:
ANI_CLI_WEB_HOST: 0.0.0.0 ANI_CLI_WEB_HOST: 0.0.0.0
ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421} ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421}
ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false} ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false}
ANI_CLI_WEB_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 ANI_CLI_DOWNLOAD_DIR: /downloads
volumes: volumes:
- ./downloads:/downloads - ./downloads:/downloads
+543 -55
View File
@@ -6,48 +6,74 @@ import json
import mimetypes import mimetypes
import os import os
import shutil import shutil
import subprocess
import traceback import traceback
from base64 import b64decode
from binascii import Error as BinasciiError
from dataclasses import dataclass from dataclasses import dataclass
from http.cookies import SimpleCookie from functools import lru_cache
from http import HTTPStatus from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler
from html import escape as html_escape
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
from app_support import ( from app_support import (
ANI_CLI, ANI_CLI,
ANIPY_CLI,
APP_NAME, APP_NAME,
CHANGELOG_FILE,
CLIENT_DISCONNECT_ERRORS, CLIENT_DISCONNECT_ERRORS,
MAX_JSON_BODY_BYTES, MAX_JSON_BODY_BYTES,
MODE_CHOICES, MODE_CHOICES,
VERSION, VERSION,
client_address_is_local, client_address_is_local,
configured_remote_path_roots,
debug_enabled, debug_enabled,
debug_log, debug_log,
load_project_text_file,
cli_executable_exists,
normalize_config, normalize_config,
path_is_within_roots,
remote_access_allowed, remote_access_allowed,
remote_access_token_configured, remote_access_credentials_configured,
remote_access_token_matches, 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) @dataclass(frozen=True)
class HandlerContext: class HandlerContext:
add_to_watchlist: object add_to_watchlist: object
config_html: str config_html: str
download_watchlist_item: object
ensure_runtime: object ensure_runtime: object
episode_list: object episode_list: object
get_config_snapshot: object get_config_snapshot: object
get_jellyfin_sync_status: object
get_watchlist_homepage_summary: object
get_watchlist: object get_watchlist: object
get_watchlist_refresh_status: object get_watchlist_refresh_status: object
http_error: object http_error: object
index_html: str index_html: str
queue_html: str
remove_from_watchlist: object remove_from_watchlist: object
run_jellyfin_sync: object
runtime_state: object runtime_state: object
save_runtime_config: object save_runtime_config: object
search_anime: object search_anime: object
resolve_search_thumbnail: object
start_jellyfin_sync: object
test_discord_webhook_config: object
thumbnail_file_path: object thumbnail_file_path: object
update_all_watchlist_statuses: object update_all_watchlist_statuses: object
update_watchlist_auto_download: object
update_watchlist_category: object update_watchlist_category: object
update_watchlist_media_type: object
update_watchlist_status: object update_watchlist_status: object
upload_watchlist_thumbnail: object upload_watchlist_thumbnail: object
watchlist_html: str watchlist_html: str
@@ -63,14 +89,107 @@ def build_handler_class(context):
def dependency_status(): def dependency_status():
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"] checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
result = {name: bool(shutil.which(name)) for name in checks} result = {name: bool(shutil.which(name)) for name in checks}
result["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 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): class Handler(BaseHTTPRequestHandler):
server_version = "AniCliWeb/1.0" server_version = "AniCliWeb/1.0"
remote_token_cookie_name = "ani_cli_web_token" remote_session_cookie_name = "ani_cli_web_session"
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status"} 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): def _context(self):
context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None) context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None)
@@ -114,15 +233,57 @@ class Handler(BaseHTTPRequestHandler):
return candidate return candidate
return peer_host return peer_host
def _remote_token(self): def _authorization_header(self):
return ( return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip()
self.headers.get("X-AniCli-Web-Token")
or self.headers.get("x-ani-cli-web-token") def _host_header(self):
or Handler._bearer_token(self) host = str(self.headers.get("X-Forwarded-Host") or self.headers.get("x-forwarded-host") or "").strip()
or Handler._cookie_token(self) if host:
or Handler._query_token(self) return host.split(",", 1)[0].strip()
or "" 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): def _bearer_token(self):
header = str(self.headers.get("Authorization") or "").strip() header = str(self.headers.get("Authorization") or "").strip()
@@ -130,7 +291,7 @@ class Handler(BaseHTTPRequestHandler):
return "" return ""
return header[len("Bearer ") :].strip() 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() raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip()
if not raw: if not raw:
return "" return ""
@@ -139,51 +300,197 @@ class Handler(BaseHTTPRequestHandler):
cookie.load(raw) cookie.load(raw)
except Exception: except Exception:
return "" return ""
morsel = cookie.get(Handler.remote_token_cookie_name) morsel = cookie.get(name)
if not morsel: if not morsel:
return "" return ""
return morsel.value.strip() return morsel.value.strip()
def _query_token(self): def _session_cookie(self):
parsed = urlparse(self.path) return Handler._cookie_value(self, Handler.remote_session_cookie_name)
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 _remote_cookie_bootstrap_response(self, parsed): def _remote_credentials_valid(self):
client_host = Handler._effective_client_host(self) session = Handler._session_cookie(self)
if client_address_is_local(client_host): if session and remote_access_session_matches(session):
return False return True
if not remote_access_allowed() or not remote_access_token_configured(): username, password = Handler._basic_auth_credentials(self)
return False return remote_access_credentials_match(username, password)
if Handler._cookie_token(self):
return False def _normalized_next_path(self, value):
token = Handler._query_token(self) raw = str(value or "").strip()
if not token or not remote_access_token_matches(token): if not raw:
return False return "/"
parsed = urlparse(raw)
if parsed.scheme or parsed.netloc:
return "/"
path = parsed.path or "/"
if not path.startswith("/"):
return "/"
if path == "/auth/login":
path = "/"
query = parsed.query or ""
return urlunparse(("", "", path, "", query, ""))
def _current_path_without_auth_query(self, parsed):
params = parse_qs(parsed.query, keep_blank_values=True) params = parse_qs(parsed.query, keep_blank_values=True)
params.pop("token", None) params.pop("token", None)
params.pop("remote_token", None) params.pop("remote_token", None)
query = urlencode(params, doseq=True) query = urlencode(params, doseq=True)
location = urlunparse(("", "", parsed.path or "/", "", query, "")) return urlunparse(("", "", parsed.path or "/", "", query, ""))
cookie = (
f"{Handler.remote_token_cookie_name}={token}; " def _remote_auth_cookie(self, session_value):
"Path=/; HttpOnly; SameSite=Lax" attributes = (
f"{Handler.remote_session_cookie_name}={session_value}; "
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}"
) )
if Handler._request_is_secure(self):
attributes += "; Secure"
return attributes
def _clear_remote_auth_cookie(self):
attributes = (
f"{Handler.remote_session_cookie_name}=; "
"Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
)
if Handler._request_is_secure(self):
attributes += "; Secure"
return attributes
def _set_remote_auth_cookie_and_redirect(self, session_value, location):
Handler.write_response_bytes( Handler.write_response_bytes(
self, self,
b"", b"",
HTTPStatus.SEE_OTHER, HTTPStatus.SEE_OTHER,
{ {
"Location": location, "Location": Handler._normalized_next_path(self, location),
"Set-Cookie": cookie, "Set-Cookie": Handler._remote_auth_cookie(self, session_value),
}, },
) )
def _is_browser_page_request(self, parsed):
if getattr(self, "command", "") != "GET":
return False
return not str(parsed.path or "").startswith("/api/")
def _render_remote_login_page(self, parsed, message=""):
next_path = Handler._current_path_without_auth_query(self, parsed)
error_html = ""
if message:
error_html = f'<p class="error">{html_escape(message)}</p>'
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{APP_NAME} sign in</title>
<style>
:root {{
color-scheme: dark;
--bg: #0b0813;
--panel: rgba(25, 18, 41, 0.94);
--line: rgba(255, 255, 255, 0.08);
--text: #f6f2ff;
--muted: #b8b0cf;
--accent: #9d7bff;
--error: #ffb4c2;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
color: var(--text);
font: 15px/1.5 "Segoe UI", Inter, system-ui, sans-serif;
}}
.panel {{
width: min(100%, 420px);
display: grid;
gap: 14px;
padding: 22px;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--panel);
}}
h1, p {{ margin: 0; }}
h1 {{ font-size: 28px; line-height: 1.05; }}
.muted {{ color: var(--muted); }}
.error {{
color: var(--error);
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 144, 164, 0.12);
border: 1px solid rgba(255, 144, 164, 0.18);
}}
form {{
display: grid;
gap: 12px;
}}
label {{
display: grid;
gap: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}}
input {{
width: 100%;
min-height: 44px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(10, 7, 18, 0.82);
color: var(--text);
padding: 0 13px;
}}
button {{
min-height: 42px;
border: 0;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent), #7b5cff);
color: #fcfaff;
font: inherit;
font-weight: 700;
cursor: pointer;
}}
code {{
font-size: 13px;
color: var(--text);
}}
</style>
</head>
<body>
<section class="panel">
<div>
<h1>{APP_NAME}</h1>
<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="{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 also use <code>Authorization: Basic &lt;base64(username:password)&gt;</code>.</p>
</section>
</body>
</html>
"""
Handler.write_response_bytes(
self,
html.encode("utf-8"),
HTTPStatus.UNAUTHORIZED,
{"Content-Type": "text/html; charset=utf-8"},
)
return True return True
def ensure_client_access(self): def ensure_client_access(self):
@@ -194,31 +501,82 @@ class Handler(BaseHTTPRequestHandler):
raise PermissionError( raise PermissionError(
"Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients." "Remote access is disabled. Set 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( 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)): if not Handler._remote_credentials_valid(self):
raise PermissionError("Remote access token missing or invalid.") 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): def do_GET(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
try: try:
if Handler._remote_cookie_bootstrap_response(self, parsed): if parsed.path == "/auth/login":
if Handler._session_cookie(self) and remote_access_session_matches(Handler._session_cookie(self)):
params = parse_qs(parsed.query, keep_blank_values=True)
next_path = (params.get("next") or ["/"])[0]
Handler._set_remote_auth_cookie_and_redirect(self, Handler._session_cookie(self), next_path)
return
if remote_access_allowed() and remote_access_credentials_configured():
Handler._render_remote_login_page(self, parsed)
return return
self.ensure_client_access() self.ensure_client_access()
if parsed.path == "/": if parsed.path == "/":
self.html(Handler._context(self).index_html) self.html(Handler._context(self).index_html)
elif parsed.path == "/queue":
self.html(Handler._context(self).queue_html)
elif parsed.path == "/config": elif parsed.path == "/config":
self.html(Handler._context(self).config_html) self.html(Handler._context(self).config_html)
elif parsed.path == "/watchlist": elif parsed.path == "/watchlist":
self.html(Handler._context(self).watchlist_html) self.html(Handler._context(self).watchlist_html)
elif parsed.path == "/api/config": 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": 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": elif parsed.path == "/api/dependencies":
self.json(dependency_status()) 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": elif parsed.path == "/api/search":
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
config = runtime["config"] config = runtime["config"]
@@ -230,6 +588,14 @@ class Handler(BaseHTTPRequestHandler):
if mode not in MODE_CHOICES: if mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub") raise ValueError("Mode must be sub or dub")
self.json({"results": Handler._context(self).search_anime(query, mode)}) 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"): elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"):
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
config = runtime["config"] config = runtime["config"]
@@ -245,6 +611,14 @@ class Handler(BaseHTTPRequestHandler):
page = (params.get("page") or ["1"])[0] page = (params.get("page") or ["1"])[0]
per_page = (params.get("per_page") or ["10"])[0] per_page = (params.get("per_page") or ["10"])[0]
self.json(runtime["download_queue"].list(page=page, per_page=per_page)) self.json(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/"): elif parsed.path.startswith("/api/watchlist/thumb/"):
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip() show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
@@ -257,6 +631,9 @@ class Handler(BaseHTTPRequestHandler):
return return
debug_log("thumbnail.route.hit", show_id=show_id, resolved_path=path) debug_log("thumbnail.route.hit", show_id=show_id, resolved_path=path)
self.file(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"}: elif parsed.path in {"/api/watchlist", "/api/watchlist/get"}:
Handler._runtime(self) Handler._runtime(self)
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
@@ -267,6 +644,9 @@ class Handler(BaseHTTPRequestHandler):
elif parsed.path == "/api/watchlist/refresh-status": elif parsed.path == "/api/watchlist/refresh-status":
Handler._runtime(self) Handler._runtime(self)
self.json(Handler._context(self).get_watchlist_refresh_status()) 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: else:
self.error(HTTPStatus.NOT_FOUND, "Not found") self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc: except Exception as exc:
@@ -275,12 +655,49 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
try: try:
if parsed.path == "/auth/login":
if not remote_access_allowed() or not remote_access_credentials_configured():
raise PermissionError("Remote access sign-in is not available.")
payload = Handler.body_form(self)
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "").strip()
next_path = payload.get("next") or "/"
if not remote_access_credentials_match(username, password):
Handler._render_remote_login_page(
self,
urlparse(Handler._normalized_next_path(self, next_path)),
"Username or password missing or invalid.",
)
return
Handler._set_remote_auth_cookie_and_redirect(self, remote_access_session_value(), next_path)
return
self.ensure_client_access() self.ensure_client_access()
Handler.enforce_same_origin(self)
if parsed.path == "/api/config": if parsed.path == "/api/config":
Handler.update_config(self, Handler.require_json_object(self, self.body_json())) 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": elif parsed.path == "/api/queue":
runtime = Handler._runtime(self) 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": elif parsed.path == "/api/queue/retry-failed":
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
self.json(runtime["download_queue"].retry_all_failed()) self.json(runtime["download_queue"].retry_all_failed())
@@ -322,10 +739,32 @@ class Handler(BaseHTTPRequestHandler):
Handler._runtime(self) Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id") payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(Handler._context(self).update_watchlist_status(payload["show_id"], payload.get("mode"))) self.json(Handler._context(self).update_watchlist_status(payload["show_id"], payload.get("mode")))
elif parsed.path == "/api/watchlist/download-all":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id", "mode")
self.json(Handler._context(self).download_watchlist_item(payload["show_id"], payload["mode"]), HTTPStatus.CREATED)
elif parsed.path == "/api/watchlist/update-category": elif parsed.path == "/api/watchlist/update-category":
Handler._runtime(self) Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id") payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category"))) 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": elif parsed.path == "/api/watchlist/update-all":
Handler._runtime(self) Handler._runtime(self)
result = Handler._context(self).update_all_watchlist_statuses() result = Handler._context(self).update_all_watchlist_statuses()
@@ -345,11 +784,24 @@ class Handler(BaseHTTPRequestHandler):
self.exception(exc) self.exception(exc)
def update_config(self, payload): 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) Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
context = Handler._context(self) context = Handler._context(self)
context.save_runtime_config(config) context.save_runtime_config(payload)
self.json(context.get_config_snapshot()) self.json(sanitize_public_config(context.get_config_snapshot()))
def require_fields(self, payload, *names): def require_fields(self, payload, *names):
payload = Handler.require_json_object(self, payload) payload = Handler.require_json_object(self, payload)
@@ -390,6 +842,9 @@ class Handler(BaseHTTPRequestHandler):
raise ValueError(f"Field '{name}' must be a boolean") raise ValueError(f"Field '{name}' must be a boolean")
def body_json(self): 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: try:
length = int(self.headers.get("Content-Length", "0") or "0") length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError as exc: except ValueError as exc:
@@ -410,6 +865,23 @@ class Handler(BaseHTTPRequestHandler):
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise ValueError("Invalid JSON body") from exc raise ValueError("Invalid JSON body") from exc
def body_form(self):
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError as exc:
raise ValueError("Invalid Content-Length header") from exc
if length < 0:
raise ValueError("Invalid Content-Length header")
if length > MAX_JSON_BODY_BYTES:
raise Handler._context(self).http_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.")
raw_bytes = self.rfile.read(length) if length else b""
try:
raw = raw_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("Request body must be valid UTF-8 form data.") from exc
params = parse_qs(raw, keep_blank_values=True)
return {key: values[-1] if values else "" for key, values in params.items()}
def html(self, content): def html(self, content):
data = content.encode("utf-8") data = content.encode("utf-8")
self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=utf-8"}) self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=utf-8"})
@@ -456,6 +928,10 @@ class Handler(BaseHTTPRequestHandler):
elif isinstance(exc, KeyError): elif isinstance(exc, KeyError):
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'")) self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
elif isinstance(exc, PermissionError): elif isinstance(exc, PermissionError):
parsed = urlparse(getattr(self, "path", "") or "/")
if remote_access_allowed() and remote_access_credentials_configured() and Handler._is_browser_page_request(self, parsed):
Handler._render_remote_login_page(self, parsed, str(exc))
else:
self.error(HTTPStatus.FORBIDDEN, str(exc)) self.error(HTTPStatus.FORBIDDEN, str(exc))
elif isinstance(exc, ValueError): elif isinstance(exc, ValueError):
self.error(HTTPStatus.BAD_REQUEST, str(exc)) self.error(HTTPStatus.BAD_REQUEST, str(exc))
@@ -465,6 +941,18 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc() traceback.print_exc()
else: else:
debug_log("handler.exception", path=getattr(self, "path", ""), error=exc) debug_log("handler.exception", path=getattr(self, "path", ""), error=exc)
try:
send_discord_webhook_event(
Handler._context(self).get_config_snapshot(),
"runtime_error",
{
"source": "http_handler",
"error": str(exc),
"details": f"path={getattr(self, 'path', '')}",
},
)
except Exception as notify_exc:
debug_log("discord_webhook.runtime_error_failed", source="http_handler", error=notify_exc)
self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.") self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.")
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
+624 -23
View File
@@ -10,22 +10,27 @@ import sqlite3
import subprocess import subprocess
import threading import threading
import time import time
import re
from app_support import ( from app_support import (
ANIPY_CLI,
MAX_LOG_LINES, MAX_LOG_LINES,
PROJECT_ROOT, PROJECT_ROOT,
QUEUE_PATH, QUEUE_PATH,
STATE_DB_PATH, STATE_DB_PATH,
WORKER_SHUTDOWN_TIMEOUT_SECONDS, WORKER_SHUTDOWN_TIMEOUT_SECONDS,
build_job, build_job,
cli_executable_exists,
command_for_job, command_for_job,
debug_log, debug_log,
finalize_library_files, finalize_library_files,
job_output_dir, job_output_dir,
job_staging_dir, job_staging_dir,
load_json, load_json,
normalize_config,
now_iso, now_iso,
printable_command, printable_command,
send_discord_webhook_event,
strip_control, strip_control,
) )
@@ -36,6 +41,7 @@ JOB_STRUCTURED_COLUMNS = (
"show_id", "show_id",
"title", "title",
"anime_name", "anime_name",
"media_type",
"season", "season",
"query", "query",
"result_index", "result_index",
@@ -262,8 +268,10 @@ class WatchlistRefreshJobs:
def _run_job(self, job): def _run_job(self, job):
interrupted = False interrupted = False
source_name = str(job.get("source") or "manual").strip().lower() or "manual" source_name = str(job.get("source") or "manual").strip().lower() or "manual"
new_episode_items = []
refresh_error_items = []
try: try:
show_ids = self.store.all_show_ids() show_ids = self.store.all_show_ids(categories=("watching", "planned"))
delay_seconds = self._refresh_delay_seconds() delay_seconds = self._refresh_delay_seconds()
started_at = now_iso() started_at = now_iso()
with self.lock: with self.lock:
@@ -288,6 +296,11 @@ class WatchlistRefreshJobs:
skipped = False skipped = False
error_message = "" error_message = ""
try: try:
try:
item = self.store.refresh(show_id, include_thumbnail=False, refresh_source=source_name)
except TypeError as exc:
if "refresh_source" not in str(exc):
raise
item = self.store.refresh(show_id, include_thumbnail=False) item = self.store.refresh(show_id, include_thumbnail=False)
if item is None: if item is None:
skipped = True skipped = True
@@ -295,6 +308,20 @@ class WatchlistRefreshJobs:
title = item.get("title") or title title = item.get("title") or title
errored = item.get("status") == "error" errored = item.get("status") == "error"
error_message = str(item.get("status_message") or "").strip() if errored else "" error_message = str(item.get("status_message") or "").strip() if errored else ""
if item.get("had_new_episodes"):
new_episode_items.append(
{
"show_id": item.get("show_id"),
"title": title,
"sub_count": item.get("sub_count"),
"dub_count": item.get("dub_count"),
"sub_delta": item.get("sub_delta"),
"dub_delta": item.get("dub_delta"),
"expected_count": item.get("expected_count"),
"airing_status": item.get("airing_status"),
"thumbnail_path": item.get("thumbnail_path"),
}
)
except Exception as exc: except Exception as exc:
errored = True errored = True
error_message = str(exc) error_message = str(exc)
@@ -306,6 +333,7 @@ class WatchlistRefreshJobs:
job["updated_at"] = now_iso() job["updated_at"] = now_iso()
if errored and error_message: if errored and error_message:
job["last_error"] = error_message job["last_error"] = error_message
refresh_error_items.append({"show_id": show_id, "title": title, "error": error_message})
if skipped: if skipped:
job["message"] = f"Refreshing {index}/{len(show_ids)}: removed entry" job["message"] = f"Refreshing {index}/{len(show_ids)}: removed entry"
else: else:
@@ -344,6 +372,42 @@ class WatchlistRefreshJobs:
self._stdout_log( self._stdout_log(
f"Scheduled refresh finished successfully (job={job['id']}, completed={job['completed']}/{job['total']})." f"Scheduled refresh finished successfully (job={job['id']}, completed={job['completed']}/{job['total']})."
) )
config = self.config_getter() or {}
if interrupted:
send_discord_webhook_event(
config,
"watchlist_refresh_interrupted",
{
"source": source_name,
"completed": job.get("completed"),
"total": job.get("total"),
"message": job.get("message"),
},
)
else:
if new_episode_items:
send_discord_webhook_event(
config,
"watchlist_refresh_new_episodes",
{
"source": source_name,
"job_id": job.get("id"),
"items": new_episode_items,
},
)
if job.get("errors"):
send_discord_webhook_event(
config,
"watchlist_refresh_failed",
{
"source": source_name,
"completed": job.get("completed"),
"total": job.get("total"),
"errors": job.get("errors"),
"items": refresh_error_items,
"error": job.get("last_error"),
},
)
except Exception as exc: except Exception as exc:
finished_at = now_iso() finished_at = now_iso()
with self.lock: with self.lock:
@@ -352,9 +416,22 @@ class WatchlistRefreshJobs:
job["updated_at"] = finished_at job["updated_at"] = finished_at
job["current_title"] = None job["current_title"] = None
job["message"] = f"Watchlist refresh failed: {exc}" job["message"] = f"Watchlist refresh failed: {exc}"
job["last_error"] = str(exc)
self._save_job_locked(job) self._save_job_locked(job)
if source_name == "scheduled": if source_name == "scheduled":
self._stdout_log(f"Scheduled refresh failed (job={job['id']}): {exc}") self._stdout_log(f"Scheduled refresh failed (job={job['id']}): {exc}")
send_discord_webhook_event(
self.config_getter() or {},
"watchlist_refresh_failed",
{
"source": source_name,
"completed": job.get("completed"),
"total": job.get("total"),
"errors": max(1, int(job.get("errors") or 0)),
"items": refresh_error_items,
"error": str(exc),
},
)
finally: finally:
with self.lock: with self.lock:
self.current_executor = None self.current_executor = None
@@ -427,6 +504,14 @@ class WatchlistAutoRefreshScheduler:
debug_log("watchlist.auto_refresh.tick", result=result) debug_log("watchlist.auto_refresh.tick", result=result)
except Exception as exc: except Exception as exc:
debug_log("watchlist.auto_refresh.error", error=exc) debug_log("watchlist.auto_refresh.error", error=exc)
send_discord_webhook_event(
self.config_getter() or {},
"runtime_error",
{
"source": "watchlist.auto_refresh",
"error": str(exc),
},
)
return interval_seconds return interval_seconds
def _run(self): def _run(self):
@@ -436,6 +521,326 @@ class WatchlistAutoRefreshScheduler:
self.wakeup.clear() 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: class DownloadQueue:
def __init__(self, config_getter, watchlist_sync_fn=None, job_prepare_fn=None, start_worker=True): def __init__(self, config_getter, watchlist_sync_fn=None, job_prepare_fn=None, start_worker=True):
self.config_getter = config_getter self.config_getter = config_getter
@@ -515,6 +920,7 @@ class DownloadQueue:
show_id TEXT, show_id TEXT,
title TEXT, title TEXT,
anime_name TEXT, anime_name TEXT,
media_type TEXT,
season TEXT, season TEXT,
query TEXT, query TEXT,
result_index INTEGER, result_index INTEGER,
@@ -540,6 +946,7 @@ class DownloadQueue:
("show_id", "ALTER TABLE jobs ADD COLUMN show_id TEXT"), ("show_id", "ALTER TABLE jobs ADD COLUMN show_id TEXT"),
("title", "ALTER TABLE jobs ADD COLUMN title TEXT"), ("title", "ALTER TABLE jobs ADD COLUMN title TEXT"),
("anime_name", "ALTER TABLE jobs ADD COLUMN anime_name 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"), ("season", "ALTER TABLE jobs ADD COLUMN season TEXT"),
("query", "ALTER TABLE jobs ADD COLUMN query TEXT"), ("query", "ALTER TABLE jobs ADD COLUMN query TEXT"),
("result_index", "ALTER TABLE jobs ADD COLUMN result_index INTEGER"), ("result_index", "ALTER TABLE jobs ADD COLUMN result_index INTEGER"),
@@ -641,17 +1048,18 @@ class DownloadQueue:
""" """
INSERT INTO jobs ( INSERT INTO jobs (
id, status, created_at, updated_at, 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, started_at, finished_at, exit_code, pid, command, staging_dir, target_dir, cancel_requested,
log_payload, payload log_payload, payload
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
status = excluded.status, status = excluded.status,
updated_at = excluded.updated_at, updated_at = excluded.updated_at,
show_id = excluded.show_id, show_id = excluded.show_id,
title = excluded.title, title = excluded.title,
anime_name = excluded.anime_name, anime_name = excluded.anime_name,
media_type = excluded.media_type,
season = excluded.season, season = excluded.season,
query = excluded.query, query = excluded.query,
result_index = excluded.result_index, result_index = excluded.result_index,
@@ -678,6 +1086,7 @@ class DownloadQueue:
job.get("show_id"), job.get("show_id"),
job.get("title"), job.get("title"),
job.get("anime_name"), job.get("anime_name"),
job.get("media_type"),
job.get("season"), job.get("season"),
job.get("query"), job.get("query"),
job.get("result_index"), job.get("result_index"),
@@ -723,6 +1132,10 @@ class DownloadQueue:
"pages": pages, "pages": pages,
} }
def get(self, job_id):
with self.lock:
return self._find(job_id)
def add(self, payload): def add(self, payload):
job = build_job(payload, self.config_getter()) job = build_job(payload, self.config_getter())
if self.job_prepare_fn is not None: if self.job_prepare_fn is not None:
@@ -734,17 +1147,87 @@ class DownloadQueue:
self.wakeup.set() self.wakeup.set()
return job 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): def retry(self, job_id):
with self.lock: with self.lock:
job = self._find(job_id) job = self._find(job_id)
if job["status"] == "running": if job["status"] == "running":
raise ValueError("Running jobs cannot be retried") 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["status"] = "pending"
job["exit_code"] = None job["exit_code"] = None
job["pid"] = None job["pid"] = None
job["started_at"] = None job["started_at"] = None
job["finished_at"] = None job["finished_at"] = None
job["updated_at"] = now_iso() job["updated_at"] = now_iso()
job["cancel_requested"] = False
job["log"] = [] job["log"] = []
self._save_job_locked(job) self._save_job_locked(job)
self.wakeup.set() self.wakeup.set()
@@ -892,6 +1375,36 @@ class DownloadQueue:
self._save_job_locked(job) self._save_job_locked(job)
self._cleanup_staging_dir(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): def _run(self):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
job = self._next_pending() job = self._next_pending()
@@ -902,42 +1415,62 @@ class DownloadQueue:
self._run_job(job) self._run_job(job)
def _run_job(self, job): def _run_job(self, job):
command = command_for_job(job)
staging_dir = job_staging_dir(job) staging_dir = job_staging_dir(job)
target_dir = job_output_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) staging_dir.mkdir(parents=True, exist_ok=True)
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
attempts, fallback_enabled, fallback_available = self._command_attempts(job, staging_dir)
primary_command = attempts[0]["command"]
with self.lock: with self.lock:
job["status"] = "running" job["status"] = "running"
job["started_at"] = now_iso() job["started_at"] = now_iso()
job["updated_at"] = job["started_at"] job["updated_at"] = job["started_at"]
job["command"] = printable_command(command) job["command"] = printable_command(primary_command)
job["staging_dir"] = str(staging_dir) job["staging_dir"] = str(staging_dir)
job["target_dir"] = str(target_dir) job["target_dir"] = str(target_dir)
job["download_backend"] = "ani-cli"
job["fallback_used"] = False
job["log"] = [ job["log"] = [
f"Starting: {job['command']}", f"Starting: {job['command']}",
f"Staging in: {job['staging_dir']}", f"Staging in: {job['staging_dir']}",
f"Final folder: {job['target_dir']}", f"Final folder: {job['target_dir']}",
] ]
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["cancel_requested"] = False
job["_dirty_log_lines"] = 0 job["_dirty_log_lines"] = 0
self._save_job_locked(job) self._save_job_locked(job)
try:
exit_code = 1
successful_backend = None
finalize_error = None
moved_files = []
watchlist_sync_error = None
synced_watchlist_item = None
cleanup_staging = False
for index, attempt in enumerate(attempts):
backend = attempt["backend"]
command = attempt["command"]
should_fallback = index + 1 < len(attempts)
with self.lock:
job["download_backend"] = backend
job["command"] = printable_command(command)
if index > 0:
job["fallback_used"] = True
job["pid"] = None
job["updated_at"] = now_iso()
if index > 0:
job.setdefault("log", []).append(f"Retrying with {backend}: {job['command']}")
self._save_job_locked(job)
try: try:
process = subprocess.Popen( process = subprocess.Popen(
command, command,
cwd=str(PROJECT_ROOT), cwd=str(PROJECT_ROOT),
env=env, env=attempt["env"],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
@@ -945,6 +1478,12 @@ class DownloadQueue:
start_new_session=True, start_new_session=True,
) )
except Exception as exc: except Exception as 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) self._fail_job_startup(job, exc)
return return
with self.lock: with self.lock:
@@ -954,26 +1493,51 @@ class DownloadQueue:
job["pid"] = process.pid job["pid"] = process.pid
self._save_job_locked(job) self._save_job_locked(job)
try:
assert process.stdout is not None assert process.stdout is not None
for line in process.stdout: for line in process.stdout:
self._append_log(job, line) self._append_log(job, line)
exit_code = process.wait() exit_code = process.wait()
finalize_error = None with self.lock:
moved_files = [] job["pid"] = None
watchlist_sync_error = None self.current_process = None
cleanup_staging = False self.current_job_id = None
if exit_code == 0 and not job.get("cancel_requested"): 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: try:
moved_files = finalize_library_files(job) moved_files = finalize_library_files(job)
except Exception as exc: except Exception as exc:
finalize_error = exc finalize_error = exc
no_files_downloaded = "No downloaded files were found in the staging folder" in str(exc)
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: else:
successful_backend = backend
if self.watchlist_sync_fn is not None: if self.watchlist_sync_fn is not None:
try: try:
self.watchlist_sync_fn(job) synced_watchlist_item = self.watchlist_sync_fn(job)
except Exception as exc: except Exception as exc:
watchlist_sync_error = exc watchlist_sync_error = exc
break
break
with self.lock: with self.lock:
canceled = job.get("cancel_requested") canceled = job.get("cancel_requested")
@@ -986,19 +1550,33 @@ class DownloadQueue:
job.setdefault("log", []).append("Canceled.") job.setdefault("log", []).append("Canceled.")
elif finalize_error: elif finalize_error:
job["status"] = "failed" job["status"] = "failed"
message = str(finalize_error).strip()
if "No downloaded files were found in the staging folder" in message:
job.setdefault("log", []).append(
f"{job.get('download_backend') or 'Downloader'} exited without downloading any files. The selected result or episodes may not be downloadable."
)
else:
job.setdefault("log", []).append(f"Library rename failed: {finalize_error}") job.setdefault("log", []).append(f"Library rename failed: {finalize_error}")
cleanup_staging = True
elif exit_code == 0: elif exit_code == 0:
job["status"] = "done" job["status"] = "done"
if successful_backend == "anipy-cli":
job.setdefault("log", []).append("Fallback download completed with anipy-cli.")
for path in moved_files: for path in moved_files:
job.setdefault("log", []).append(f"Saved: {path}") job.setdefault("log", []).append(f"Saved: {path}")
job.setdefault("log", []).append("Download completed.") job.setdefault("log", []).append("Download completed.")
if watchlist_sync_error: if watchlist_sync_error:
job.setdefault("log", []).append(f"Watchlist sync failed: {watchlist_sync_error}") job.setdefault("log", []).append(f"Watchlist sync failed: {watchlist_sync_error}")
elif self.watchlist_sync_fn is not None: elif self.watchlist_sync_fn is not None:
if job.get("skip_watchlist_sync"):
job.setdefault("log", []).append("Watchlist sync skipped for detached job.")
else:
job.setdefault("log", []).append("Watchlist download state synced.") job.setdefault("log", []).append("Watchlist download state synced.")
else: else:
job["status"] = "failed" 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 cleanup_staging = True
if canceled: if canceled:
cleanup_staging = True cleanup_staging = True
@@ -1008,5 +1586,28 @@ class DownloadQueue:
self._save_job_locked(job) self._save_job_locked(job)
if cleanup_staging: if cleanup_staging:
self._cleanup_staging_dir(job) self._cleanup_staging_dir(job)
elif exit_code == 0 and not finalize_error and not canceled:
download_title = (
str((synced_watchlist_item or {}).get("title") or "").strip()
or str(job.get("title") or "").strip()
or str(job.get("query") or "").strip()
or "Unknown anime"
)
try:
send_discord_webhook_event(
self.config_getter() or {},
"download_completed",
{
"show_id": (synced_watchlist_item or {}).get("show_id") or job.get("show_id"),
"title": download_title,
"mode": job.get("mode"),
"episodes": job.get("episodes"),
"category": (synced_watchlist_item or {}).get("category") or "finished",
"thumbnail_path": (synced_watchlist_item or {}).get("thumbnail_path"),
"moved_files": moved_files,
},
)
except Exception as exc:
debug_log("discord_webhook.download_completed_failed", title=download_title, error=exc)
except Exception as exc: except Exception as exc:
self._fail_job_runtime(job, exc, process=process) self._fail_job_runtime(job, exc, process=process)
+564
View File
@@ -0,0 +1,564 @@
#!/usr/bin/env python3
"""Queue page template for ani-cli-web."""
from template_helpers import BROWSER_API_HELPER, BROWSER_POLL_HELPER, render_page_links, render_sidebar_brand
QUEUE_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ani-cli web queue</title>
<style>
:root {
color-scheme: dark;
--bg: #090611;
--bg-2: #140d21;
--panel: rgba(25, 18, 41, 0.82);
--panel-strong: rgba(18, 13, 31, 0.92);
--panel-2: rgba(157, 123, 255, 0.12);
--line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(157, 123, 255, 0.26);
--text: #f6f2ff;
--muted: #b8b0cf;
--accent: #9d7bff;
--accent-strong: #7b5cff;
--accent-soft: rgba(157, 123, 255, 0.14);
--good: #95e4ba;
--warn: #f0c67a;
--bad: #ff90a4;
--focus: #b296ff;
--shadow: 0 24px 60px rgba(5, 2, 15, 0.42);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(117, 201, 255, 0.12), transparent 24%),
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
color: var(--text);
font: 15px/1.5 "Segoe UI", Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
opacity: 0.5;
}
button {
font: inherit;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
min-height: 40px;
border-radius: 14px;
padding: 0 14px;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
button:hover {
border-color: var(--line-strong);
background: rgba(157, 123, 255, 0.18);
transform: translateY(-1px);
}
button.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
border-color: transparent;
color: #fcfaff;
font-weight: 700;
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
}
button.ghost {
background: transparent;
}
button.danger {
border-color: rgba(255, 144, 164, 0.3);
background: rgba(255, 144, 164, 0.12);
color: #ffd7df;
}
button.danger:hover {
border-color: rgba(255, 144, 164, 0.48);
background: rgba(255, 144, 164, 0.18);
}
.app {
display: grid;
grid-template-columns: minmax(320px, 380px) 1fr;
min-height: 100vh;
}
aside, main {
padding: 22px;
}
aside {
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(25, 18, 41, 0.94), rgba(14, 10, 24, 0.9));
display: grid;
align-content: start;
gap: 20px;
backdrop-filter: blur(18px) saturate(170%);
}
main {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 20px;
min-width: 0;
}
h1, h2, h3, p { margin: 0; }
h1 {
font-size: 29px;
line-height: 1.05;
letter-spacing: -0.04em;
}
h2 {
font-size: 17px;
letter-spacing: -0.02em;
}
.topline, .row, .toolbar {
display: flex;
gap: 10px;
align-items: center;
}
.topline, .toolbar { justify-content: space-between; }
.brand-wrap {
display: grid;
gap: 6px;
}
.brand-word {
color: var(--accent);
}
.shell-note {
color: var(--muted);
font-size: 13px;
max-width: 28ch;
}
.row > * { flex: 1; }
.page-links {
display: grid;
gap: 8px;
padding: 6px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
}
.page-link {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
min-height: 40px;
border: 0;
border-radius: 12px;
padding: 0 14px;
color: var(--muted);
background: transparent;
font-weight: 700;
text-decoration: none;
transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.page-link.active {
background: var(--accent-soft);
color: var(--text);
box-shadow: inset 0 0 0 1px rgba(157, 123, 255, 0.22);
}
.page-link:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.05);
}
.queue, .highlights {
display: grid;
gap: 10px;
min-width: 0;
}
.panel, .job {
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);
}
.panel {
padding: 18px;
display: grid;
gap: 14px;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
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;
}
.job {
padding: 18px;
display: grid;
gap: 14px;
transition: border-color 0.16s ease, transform 0.16s ease;
}
.job.collapsed {
gap: 0;
}
.job:hover {
border-color: var(--line-strong);
transform: translateY(-1px);
}
.job-head {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: start;
min-width: 0;
}
.job-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
min-width: 40px;
padding: 0;
border-radius: 12px;
font-size: 18px;
line-height: 1;
}
.job-toggle::before {
content: "";
}
.job.collapsed .job-toggle::before {
content: "";
}
.job-title {
font-weight: 800;
overflow-wrap: anywhere;
}
.job-body {
display: grid;
gap: 14px;
}
.job.collapsed .job-body {
display: none;
}
.status {
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(255, 255, 255, 0.06);
color: var(--muted);
}
.status.running {
color: #fcfaff;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
}
.status.done { color: #082312; background: var(--good); }
.status.failed { color: #2d0910; background: var(--bad); }
.status.canceled { color: #2d1c00; background: var(--warn); }
pre {
margin: 0;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(7, 5, 12, 0.86);
color: #d6d0e7;
font-size: 12px;
}
.empty {
border: 1px dashed rgba(157, 123, 255, 0.22);
border-radius: 22px;
color: var(--muted);
padding: 24px;
text-align: center;
background: rgba(157, 123, 255, 0.06);
}
.notice {
min-height: 20px;
color: #ddd0ff;
font-size: 13px;
}
@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; }
.job-head { grid-template-columns: auto 1fr; }
.job-head .status { grid-column: 2; justify-self: start; }
}
</style>
</head>
<body>
<div class="app">
<aside>
""" + render_sidebar_brand(
"Dedicated queue board for active, pending, and finished downloads.",
"Queue",
badge_id="serverBadge",
) + render_page_links("queue") + r"""
<section class="panel">
<h2>Queue controls</h2>
<p class="muted">Monitor active downloads, inspect recent output, and clean up finished jobs without leaving the queue view.</p>
<div class="row">
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button>
<button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
</div>
</section>
<section class="panel highlights">
<div>
<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">Active jobs update every 2.5s</span>
</section>
</aside>
<main>
<section class="toolbar">
<div>
<h2>Download queue</h2>
<p class="muted">Newest log lines are shown first for each job.</p>
</div>
<div class="notice" id="notice"></div>
</section>
<section class="queue" id="queue"></section>
<section class="toolbar" id="queuePager"></section>
</main>
</div>
<script>
const state = {
queuePage: 1,
queuePages: 1,
queueTotal: 0,
queueRequestId: 0,
queueTimer: null,
collapsedJobs: {},
jobCards: {}
};
const $ = (id) => document.getElementById(id);
""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r"""
function setNotice(text) {
const notice = $("notice");
if (notice) notice.textContent = text || "";
}
function actionButton(text, handler, extra = "") {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = text;
btn.className = extra;
btn.addEventListener("click", handler);
return btn;
}
function isCollapsed(job) {
if (Object.prototype.hasOwnProperty.call(state.collapsedJobs, job.id)) {
return state.collapsedJobs[job.id];
}
return job.status === "done";
}
function setCollapsed(jobId, collapsed) {
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) {
el.innerHTML = '<div class="empty">Queue is empty.</div>';
renderQueuePager();
return;
}
for (const job of jobs) {
el.appendChild(createJobCard(job));
}
renderQueuePager();
}
function renderQueuePager() {
const el = $("queuePager");
const start = state.queueTotal ? ((state.queuePage - 1) * 10) + 1 : 0;
const end = Math.min(state.queuePage * 10, state.queueTotal);
el.innerHTML = `
<span class="muted"></span>
<div class="row" style="flex:0 0 auto"></div>
`;
el.querySelector(".muted").textContent =
`${start}-${end} of ${state.queueTotal} · page ${state.queuePage} of ${state.queuePages}`;
const controls = el.querySelector(".row");
const prev = actionButton("Previous", () => {
if (state.queuePage > 1) loadQueue(state.queuePage - 1);
});
const next = actionButton("Next", () => {
if (state.queuePage < state.queuePages) loadQueue(state.queuePage + 1);
});
prev.disabled = state.queuePage <= 1;
next.disabled = state.queuePage >= state.queuePages;
controls.append(prev, next);
}
async function queueAction(id, action) {
try {
await api(`/api/queue/${id}/${action}`, { method: "POST", body: "{}" });
await loadQueue(state.queuePage);
} catch (error) {
setNotice(error.message);
}
}
async function queueBulkAction(path, message) {
try {
const data = await api(path, { method: "POST", body: "{}" });
setNotice(`${message}: ${data.count || 0}`);
await loadQueue(state.queuePage);
} catch (error) {
setNotice(error.message);
}
}
async function loadQueue(page = state.queuePage || 1) {
const requestId = ++state.queueRequestId;
const data = await api(`/api/queue?page=${page}&per_page=10`);
if (requestId !== state.queueRequestId) return data;
if (data.page > data.pages && data.pages > 0) return loadQueue(data.pages);
renderQueue(data);
return data;
}
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(() => pollVisibleJobs(), 2500);
} catch (error) {
setNotice(error.message);
}
})();
</script>
</body>
</html>
"""
+188 -220
View File
@@ -2,7 +2,7 @@
"""Search page template for ani-cli-web.""" """Search page template for ani-cli-web."""
from template_helpers import BROWSER_API_HELPER, BROWSER_POLL_HELPER, render_page_links, render_sidebar_brand from template_helpers import BROWSER_API_HELPER, render_page_links, render_sidebar_brand
INDEX_HTML = r"""<!doctype html> INDEX_HTML = r"""<!doctype html>
@@ -135,9 +135,9 @@ INDEX_HTML = r"""<!doctype html>
} }
main { main {
display: grid; display: grid;
grid-template-rows: auto auto 1fr;
gap: 20px; gap: 20px;
min-width: 0; min-width: 0;
align-content: start;
} }
h1, h2, h3, p { margin: 0; } h1, h2, h3, p { margin: 0; }
h1 { h1 {
@@ -183,18 +183,18 @@ INDEX_HTML = r"""<!doctype html>
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
} }
.page-links { .page-links {
display: flex; display: grid;
gap: 8px; gap: 8px;
flex-wrap: wrap;
padding: 6px; padding: 6px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.page-link { .page-link {
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
width: 100%;
min-height: 40px; min-height: 40px;
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
@@ -232,47 +232,127 @@ INDEX_HTML = r"""<!doctype html>
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
} }
.results, .queue { .settings, .episodes-panel, .results-panel, .deps {
display: grid;
gap: 10px;
min-width: 0;
}
.result, .job, .settings, .episodes-panel, .deps {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 22px; border-radius: 22px;
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94)); background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
box-shadow: var(--shadow); box-shadow: var(--shadow);
backdrop-filter: blur(18px); 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; padding: 14px;
display: grid; 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; 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); 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 { .result-card.active {
border-color: rgba(157, 123, 255, 0.34); 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)); 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 { .result-title {
text-align: left;
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
overflow-wrap: anywhere; 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 { .muted {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
} }
.settings, .episodes-panel, .deps {
padding: 18px;
display: grid;
gap: 14px;
}
.chips { .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -291,57 +371,6 @@ INDEX_HTML = r"""<!doctype html>
} }
.badge.ok { color: var(--good); border-color: rgba(149, 228, 186, 0.24); } .badge.ok { color: var(--good); border-color: rgba(149, 228, 186, 0.24); }
.badge.bad { color: #ffd0da; border-color: rgba(255, 144, 164, 0.22); } .badge.bad { color: #ffd0da; border-color: rgba(255, 144, 164, 0.22); }
.job {
padding: 18px;
display: grid;
gap: 14px;
transition: border-color 0.16s ease, transform 0.16s ease;
}
.job:hover {
border-color: var(--line-strong);
transform: translateY(-1px);
}
.job-head {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: start;
min-width: 0;
}
.job-title {
font-weight: 800;
overflow-wrap: anywhere;
}
.status {
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(255, 255, 255, 0.06);
color: var(--muted);
}
.status.running {
color: #fcfaff;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
}
.status.done { color: #082312; background: var(--good); }
.status.failed { color: #2d0910; background: var(--bad); }
.status.canceled { color: #2d1c00; background: var(--warn); }
pre {
margin: 0;
max-height: 180px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(7, 5, 12, 0.86);
color: #d6d0e7;
font-size: 12px;
}
.empty { .empty {
border: 1px dashed rgba(157, 123, 255, 0.22); border: 1px dashed rgba(157, 123, 255, 0.22);
border-radius: 22px; border-radius: 22px;
@@ -355,16 +384,28 @@ INDEX_HTML = r"""<!doctype html>
color: #ddd0ff; color: #ddd0ff;
font-size: 13px; font-size: 13px;
} }
.results-panel .toolbar {
align-items: end;
}
.statline { .statline {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; 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) { @media (max-width: 900px) {
.app { grid-template-columns: 1fr; } .app { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); } aside { border-right: 0; border-bottom: 1px solid var(--line); }
.controls, .row, .toolbar { align-items: stretch; flex-direction: column; } .controls, .row, .toolbar { align-items: stretch; flex-direction: column; }
.job-head { grid-template-columns: 1fr; } .results-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.results-grid { grid-template-columns: 1fr; }
} }
</style> </style>
</head> </head>
@@ -398,7 +439,6 @@ INDEX_HTML = r"""<!doctype html>
<button class="primary" id="searchBtn" type="button">Search</button> <button class="primary" id="searchBtn" type="button">Search</button>
</div> </div>
<div class="notice" id="notice"></div> <div class="notice" id="notice"></div>
<div class="results" id="results"></div>
</aside> </aside>
<main> <main>
@@ -417,6 +457,12 @@ INDEX_HTML = r"""<!doctype html>
<option value="dropped">Dropped</option> <option value="dropped">Dropped</option>
</select> </select>
</label> </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="ghost" id="trackBtn" type="button" disabled>Add to watchlist</button>
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button> <button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
</div> </div>
@@ -446,17 +492,14 @@ INDEX_HTML = r"""<!doctype html>
</div> </div>
</div> </div>
</section> </section>
<section class="results-panel">
<section>
<div class="toolbar"> <div class="toolbar">
<h2>Download queue</h2> <div>
<div class="row" style="flex:0 0 auto"> <h2>Select results</h2>
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button> <p class="muted" id="resultsMeta">Poster cards appear here after a search.</p>
<button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
</div> </div>
</div> </div>
<div class="queue" id="queue"></div> <div class="results-grid" id="results"></div>
<div class="toolbar" id="queuePager"></div>
</section> </section>
</main> </main>
</div> </div>
@@ -466,17 +509,12 @@ INDEX_HTML = r"""<!doctype html>
config: { mode: "sub", quality: "best", download_dir: "" }, config: { mode: "sub", quality: "best", download_dir: "" },
selected: null, selected: null,
episodes: [], episodes: [],
queuePage: 1,
queuePages: 1,
queueTotal: 0,
queueTimer: null,
queueRequestId: 0,
searchRequestId: 0, searchRequestId: 0,
episodeRequestId: 0 episodeRequestId: 0
}; };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r""" """ + BROWSER_API_HELPER + r"""
function setNotice(text) { function setNotice(text) {
const notice = $("notice"); const notice = $("notice");
@@ -488,31 +526,77 @@ INDEX_HTML = r"""<!doctype html>
$("search-mode").value = mode; $("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) { function renderResults(results) {
const el = $("results"); const el = $("results");
el.innerHTML = ""; el.innerHTML = "";
if (!results.length) { if (!results.length) {
el.innerHTML = '<div class="empty">No matching anime found.</div>'; el.innerHTML = '<div class="empty">No matching anime found.</div>';
$("resultsMeta").textContent = "No matching anime found.";
return; return;
} }
for (const item of results) { for (const item of results) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "result"; btn.className = "result-card";
btn.innerHTML = ` btn.innerHTML = `
<span class="result-title"></span> <div class="cover-tile">
<span class="muted"></span> <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(".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)); btn.addEventListener("click", () => selectResult(item, btn));
el.appendChild(btn); el.appendChild(btn);
} }
$("resultsMeta").textContent = `${results.length} matching anime`;
} }
async function selectResult(item, button) { async function selectResult(item, button) {
const requestId = ++state.episodeRequestId; 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"); button.classList.add("active");
state.selected = item; state.selected = item;
state.episodes = []; state.episodes = [];
@@ -558,6 +642,7 @@ INDEX_HTML = r"""<!doctype html>
if (!query) return setNotice("Type a title first."); if (!query) return setNotice("Type a title first.");
setMode($("search-mode").value); setMode($("search-mode").value);
setNotice("Searching..."); setNotice("Searching...");
$("resultsMeta").textContent = "Searching...";
state.selected = null; state.selected = null;
state.episodeRequestId += 1; state.episodeRequestId += 1;
$("addBtn").disabled = true; $("addBtn").disabled = true;
@@ -571,6 +656,7 @@ INDEX_HTML = r"""<!doctype html>
} catch (error) { } catch (error) {
if (requestId !== state.searchRequestId) return; if (requestId !== state.searchRequestId) return;
setNotice(error.message); setNotice(error.message);
$("resultsMeta").textContent = "Search failed.";
} }
} }
@@ -585,7 +671,9 @@ INDEX_HTML = r"""<!doctype html>
body: JSON.stringify({ body: JSON.stringify({
show_id: state.selected.id, show_id: state.selected.id,
title: state.selected.title, 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."); setNotice(data.message || "Added to watchlist.");
@@ -598,11 +686,11 @@ INDEX_HTML = r"""<!doctype html>
if (!state.selected) return; if (!state.selected) return;
const payload = { const payload = {
show_id: state.selected.id, show_id: state.selected.id,
query: state.selected.query, query: state.selected.title,
title: state.selected.title, title: state.selected.title,
anime_name: $("animeNameInput").value || state.selected.title, anime_name: $("animeNameInput").value || state.selected.title,
media_type: $("trackMediaType").value,
season: $("seasonInput").value || "1", season: $("seasonInput").value || "1",
index: state.selected.index,
mode: state.config.mode, mode: state.config.mode,
quality: $("search-quality").value, quality: $("search-quality").value,
episodes: $("episodesInput").value, episodes: $("episodesInput").value,
@@ -610,128 +698,12 @@ INDEX_HTML = r"""<!doctype html>
}; };
try { try {
await api("/api/queue", { method: "POST", body: JSON.stringify(payload) }); await api("/api/queue", { method: "POST", body: JSON.stringify(payload) });
setNotice("Added to queue."); setNotice("Added to queue. Open Queue to monitor progress.");
await loadQueue();
} catch (error) { } catch (error) {
setNotice(error.message); setNotice(error.message);
} }
} }
function renderQueue(data) {
const jobs = data.jobs || [];
state.queuePage = data.page || 1;
state.queuePages = data.pages || 1;
state.queueTotal = data.total || 0;
const el = $("queue");
el.innerHTML = "";
if (!jobs.length) {
el.innerHTML = '<div class="empty">Queue is empty.</div>';
renderQueuePager();
return;
}
for (const job of jobs) {
const item = document.createElement("article");
item.className = "job";
const log = (job.log || []).slice(-28).join("\n");
item.innerHTML = `
<div class="job-head">
<div>
<div class="job-title"></div>
<p class="muted"></p>
</div>
<span class="status"></span>
</div>
<pre></pre>
<div class="toolbar">
<span class="muted"></span>
<div class="row" style="flex:0 0 auto"></div>
</div>
`;
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);
}
renderQueuePager();
}
function renderQueuePager() {
const el = $("queuePager");
const start = state.queueTotal ? ((state.queuePage - 1) * 10) + 1 : 0;
const end = Math.min(state.queuePage * 10, state.queueTotal);
el.innerHTML = `
<span class="muted"></span>
<div class="row" style="flex:0 0 auto"></div>
`;
el.querySelector(".muted").textContent =
`${start}-${end} of ${state.queueTotal} · page ${state.queuePage} of ${state.queuePages}`;
const controls = el.querySelector(".row");
const prev = actionButton("Previous", () => {
if (state.queuePage > 1) loadQueue(state.queuePage - 1);
});
const next = actionButton("Next", () => {
if (state.queuePage < state.queuePages) loadQueue(state.queuePage + 1);
});
prev.disabled = state.queuePage <= 1;
next.disabled = state.queuePage >= state.queuePages;
controls.append(prev, next);
}
function actionButton(text, handler, extra = "") {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = text;
btn.className = extra;
btn.addEventListener("click", handler);
return btn;
}
async function queueAction(id, action) {
try {
await api(`/api/queue/${id}/${action}`, { method: "POST", body: "{}" });
await loadQueue(state.queuePage);
} catch (error) {
setNotice(error.message);
}
}
async function queueBulkAction(path, message) {
try {
const data = await api(path, { method: "POST", body: "{}" });
setNotice(`${message}: ${data.count || 0}`);
await loadQueue(state.queuePage);
} catch (error) {
setNotice(error.message);
}
}
async function loadQueue(page = state.queuePage || 1) {
const requestId = ++state.queueRequestId;
const data = await api(`/api/queue?page=${page}&per_page=10`);
if (requestId !== state.queueRequestId) return data;
if (data.page > data.pages && data.pages > 0) return loadQueue(data.pages);
renderQueue(data);
return data;
}
async function loadConfig() { async function loadConfig() {
state.config = await api("/api/config"); state.config = await api("/api/config");
$("jobFolder").value = state.config.download_dir; $("jobFolder").value = state.config.download_dir;
@@ -746,8 +718,6 @@ INDEX_HTML = r"""<!doctype html>
$("search-mode").addEventListener("change", (event) => setMode(event.target.value)); $("search-mode").addEventListener("change", (event) => setMode(event.target.value));
$("trackBtn").addEventListener("click", addSelectedToWatchlist); $("trackBtn").addEventListener("click", addSelectedToWatchlist);
$("addBtn").addEventListener("click", addSelected); $("addBtn").addEventListener("click", addSelected);
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs"));
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
$("search-quality").addEventListener("change", () => { state.config.quality = $("search-quality").value; }); $("search-quality").addEventListener("change", () => { state.config.quality = $("search-quality").value; });
$("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; }); $("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; });
$("latestEpBtn").addEventListener("click", () => { $("latestEpBtn").addEventListener("click", () => {
@@ -765,9 +735,7 @@ INDEX_HTML = r"""<!doctype html>
const query = (params.get("query") || "").trim(); const query = (params.get("query") || "").trim();
if (query) $("search-query").value = query; if (query) $("search-query").value = query;
await loadConfig(); await loadConfig();
await loadQueue();
if (query) await search(); if (query) await search();
state.queueTimer = startSerialPoll(() => loadQueue(), 2500);
} catch (error) { } catch (error) {
setNotice(error.message); setNotice(error.message);
} }
+2 -1
View File
@@ -4,6 +4,7 @@
PAGE_LINKS = ( PAGE_LINKS = (
("search", "Search", "/"), ("search", "Search", "/"),
("queue", "Queue", "/queue"),
("config", "Config", "/config"), ("config", "Config", "/config"),
("watchlist", "Watchlist", "/watchlist"), ("watchlist", "Watchlist", "/watchlist"),
) )
@@ -85,4 +86,4 @@ def render_page_links(active_page):
for key, label, href in PAGE_LINKS: for key, label, href in PAGE_LINKS:
class_name = "page-link active" if key == active_page else "page-link" class_name = "page-link active" if key == active_page else "page-link"
links.append(f' <a class="{class_name}" href="{href}">{label}</a>') links.append(f' <a class="{class_name}" href="{href}">{label}</a>')
return " <div class=\"page-links\">\n" + "\n".join(links) + "\n </div>\n" return " <nav class=\"page-links\" aria-label=\"Primary navigation\">\n" + "\n".join(links) + "\n </nav>\n"
+2056 -66
View File
File diff suppressed because it is too large Load Diff
+22 -5
View File
@@ -45,8 +45,12 @@ def resolve_job_watchlist_identity(
title = str(job.get("title") or job.get("anime_name") or "").strip() title = str(job.get("title") or job.get("anime_name") or "").strip()
if show_id: if show_id:
return _resolution_result(show_id, title, "", return_reason) return _resolution_result(show_id, title, "", return_reason)
raw_index = job.get("result_index")
if raw_index in (None, ""):
index = None
else:
try: try:
index = max(1, int(job.get("result_index") or 1)) index = max(1, int(raw_index))
except (TypeError, ValueError): except (TypeError, ValueError):
return _resolution_result("", title, "invalid queued result index", return_reason) return _resolution_result("", title, "invalid queued result index", return_reason)
query = str(job.get("query") or title).strip() query = str(job.get("query") or title).strip()
@@ -56,13 +60,26 @@ def resolve_job_watchlist_identity(
results = search_fn(query, job.get("mode") or default_mode) results = search_fn(query, job.get("mode") or default_mode)
except Exception as exc: except Exception as exc:
return _resolution_result("", title, f"search fallback failed: {exc}", return_reason) return _resolution_result("", title, f"search fallback failed: {exc}", return_reason)
if index > len(results): matches = []
return _resolution_result("", title, "queued result index no longer exists in search results", return_reason) for position, result in enumerate(results, start=1):
result = results[index - 1]
candidate_title = str(result.get("title") or "").strip() candidate_title = str(result.get("title") or "").strip()
if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn): if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn):
return _resolution_result("", title, f"title mismatch with fallback search result: {candidate_title or 'unknown'}", return_reason) continue
resolved_show_id = str(result.get("id") or "").strip() resolved_show_id = str(result.get("id") or "").strip()
if not resolved_show_id:
continue
matches.append((position, result, candidate_title, resolved_show_id))
if not matches:
candidate_title = str((results[0].get("title") if results else "") or "").strip()
return _resolution_result("", title, f"title mismatch with fallback search result: {candidate_title or 'unknown'}", return_reason)
if index is not None:
indexed = next((match for match in matches if match[0] == index), None)
if indexed is not None:
_position, result, candidate_title, resolved_show_id = indexed
return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason)
if len(matches) != 1:
return _resolution_result("", title, "ambiguous fallback search results", return_reason)
_position, result, candidate_title, resolved_show_id = matches[0]
if not resolved_show_id: if not resolved_show_id:
return _resolution_result("", title, "fallback search result did not include a show_id", return_reason) 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) return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason)
+456 -15
View File
@@ -150,18 +150,18 @@ WATCHLIST_HTML = r"""<!doctype html>
max-width: 28ch; max-width: 28ch;
} }
.page-links { .page-links {
display: flex; display: grid;
gap: 8px; gap: 8px;
flex-wrap: wrap;
padding: 6px; padding: 6px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.page-link { .page-link {
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
width: 100%;
min-height: 40px; min-height: 40px;
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
@@ -303,10 +303,19 @@ WATCHLIST_HTML = r"""<!doctype html>
box-shadow: 0 18px 38px rgba(5, 2, 15, 0.32); box-shadow: 0 18px 38px rgba(5, 2, 15, 0.32);
transition: transform 0.18s ease, border-color 0.18s ease; transition: transform 0.18s ease, border-color 0.18s ease;
} }
.anime-card.fully-ready {
border-color: rgba(149, 228, 186, 0.72);
box-shadow:
inset 0 0 0 1px rgba(149, 228, 186, 0.18),
0 18px 38px rgba(5, 2, 15, 0.32);
}
.anime-card:hover { .anime-card:hover {
border-color: var(--line-strong); border-color: var(--line-strong);
transform: translateY(-2px); transform: translateY(-2px);
} }
.anime-card.fully-ready:hover {
border-color: rgba(149, 228, 186, 0.88);
}
.anime-card.error { .anime-card.error {
border-color: rgba(255, 144, 164, 0.3); border-color: rgba(255, 144, 164, 0.3);
} }
@@ -492,10 +501,133 @@ WATCHLIST_HTML = r"""<!doctype html>
line-height: 1.35; line-height: 1.35;
white-space: pre-line; 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 { .category-editor {
display: grid; display: grid;
gap: 6px; 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 { .category-editor label {
font-size: 10px; font-size: 10px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -507,13 +639,22 @@ WATCHLIST_HTML = r"""<!doctype html>
} }
.card-actions { .card-actions {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 8px;
} }
.card-actions button { .card-actions button {
min-height: 34px; min-height: 34px;
padding: 0 10px; 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 { .pager {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -549,6 +690,9 @@ WATCHLIST_HTML = r"""<!doctype html>
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.watchlist-grid { grid-template-columns: 1fr; } .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> </style>
</head> </head>
@@ -583,6 +727,12 @@ WATCHLIST_HTML = r"""<!doctype html>
<option value="dropped">Dropped</option> <option value="dropped">Dropped</option>
</select> </select>
</label> </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> <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> <button class="primary" type="submit">Add to watchlist</button>
</form> </form>
@@ -592,7 +742,7 @@ WATCHLIST_HTML = r"""<!doctype html>
<h2>How it works</h2> <h2>How it works</h2>
<div class="stack muted"> <div class="stack muted">
<p>Use Refresh to pull the latest sub and dub episode counts for a single show.</p> <p>Use Refresh to pull the latest sub and dub episode counts for a single show.</p>
<p>Use Open In Search to jump back to the main page with the title pre-filled.</p> <p>Use Download all to queue every currently available episode after choosing sub or dub.</p>
<p>Refresh All updates your whole tracked list in one pass.</p> <p>Refresh All updates your whole tracked list in one pass.</p>
</div> </div>
</section> </section>
@@ -639,6 +789,67 @@ WATCHLIST_HTML = r"""<!doctype html>
</main> </main>
</div> </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> <script>
const state = { const state = {
watchlistPage: 1, watchlistPage: 1,
@@ -652,7 +863,9 @@ WATCHLIST_HTML = r"""<!doctype html>
thumbnailWarmupPending: new Set(), thumbnailWarmupPending: new Set(),
refreshRunning: false, refreshRunning: false,
refreshJobId: null, refreshJobId: null,
watchlistRequestId: 0 watchlistRequestId: 0,
altTitlesDraft: null,
autoDownloadDraft: null
}; };
const categoryLabels = { const categoryLabels = {
watching: "Watching", watching: "Watching",
@@ -660,6 +873,27 @@ WATCHLIST_HTML = r"""<!doctype html>
finished: "Finished", finished: "Finished",
dropped: "Dropped" 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); const $ = (id) => document.getElementById(id);
""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r""" """ + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r"""
@@ -699,6 +933,11 @@ WATCHLIST_HTML = r"""<!doctype html>
return parts.map((part) => part[0]).join("").slice(0, 2); return parts.map((part) => part[0]).join("").slice(0, 2);
} }
function watchlistCardClassName(item) {
const isFullyReady = item.sub_complete && item.dub_complete;
return `anime-card${item.status === "error" ? " error" : ""}${isFullyReady ? " fully-ready" : ""}`;
}
function formatModeProgress(currentCount, expectedCount) { function formatModeProgress(currentCount, expectedCount) {
const current = Number(currentCount || 0); const current = Number(currentCount || 0);
const expected = Number(expectedCount || 0); const expected = Number(expectedCount || 0);
@@ -719,6 +958,12 @@ WATCHLIST_HTML = r"""<!doctype html>
.join(""); .join("");
} }
function mediaTypeOptions(selected) {
return Object.entries(mediaTypeLabels)
.map(([value, label]) => `<option value="${value}"${value === selected ? " selected" : ""}>${label}</option>`)
.join("");
}
function renderSummary(summary) { function renderSummary(summary) {
const totals = summary || { shows: 0, sub: 0, dub: 0 }; const totals = summary || { shows: 0, sub: 0, dub: 0 };
const cards = $("watchlist-summary").querySelectorAll(".summary-card"); const cards = $("watchlist-summary").querySelectorAll(".summary-card");
@@ -774,6 +1019,146 @@ WATCHLIST_HTML = r"""<!doctype html>
controls.append(prev, next); 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) { function applyThumbnail(card, item, force = false) {
const tile = card.querySelector(".cover-tile"); const tile = card.querySelector(".cover-tile");
const img = card.querySelector("img"); const img = card.querySelector("img");
@@ -965,7 +1350,7 @@ WATCHLIST_HTML = r"""<!doctype html>
for (const item of items) { for (const item of items) {
const card = document.createElement("article"); const card = document.createElement("article");
const checked = item.last_checked ? new Date(item.last_checked).toLocaleString() : "Never"; const checked = item.last_checked ? new Date(item.last_checked).toLocaleString() : "Never";
card.className = `anime-card${item.status === "error" ? " error" : ""}`; card.className = watchlistCardClassName(item);
card.dataset.showId = item.show_id; card.dataset.showId = item.show_id;
card.title = item.thumbnail_debug || "Thumbnail source: unresolved"; card.title = item.thumbnail_debug || "Thumbnail source: unresolved";
card.innerHTML = ` card.innerHTML = `
@@ -985,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> <span class="count-pill${item.dub_complete ? " complete" : ""}">Dub <strong>${formatModeProgress(item.dub_count, item.expected_count)}</strong></span>
</div> </div>
<div class="status-row"></div> <div class="status-row"></div>
<button class="ghost alt-titles-toggle" type="button">Alternative titles</button>
<div class="category-editor"> <div class="category-editor">
<label>Category <label>Category
<select class="category-select">${categoryOptions(item.category)}</select> <select class="category-select">${categoryOptions(item.category)}</select>
</label> </label>
<label>Media
<select class="media-type-select">${mediaTypeOptions(item.media_type || "tv")}</select>
</label>
</div> </div>
<div class="muted card-date"></div> <div class="muted card-date"></div>
<div class="card-actions"> <div class="card-actions">
<button class="primary" type="button">Refresh</button> <button class="primary" type="button">Refresh</button>
<button class="ghost" type="button">Open</button>
<button class="danger" type="button">Remove</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> </div>
`; `;
card.querySelector(".cover-fallback").textContent = coverInitials(item.title); card.querySelector(".cover-fallback").textContent = coverInitials(item.title);
@@ -1013,6 +1407,7 @@ WATCHLIST_HTML = r"""<!doctype html>
} }
const statusRow = card.querySelector(".status-row"); const statusRow = card.querySelector(".status-row");
appendStatusTag(statusRow, item.category_label || categoryLabels[item.category] || "Watching"); appendStatusTag(statusRow, item.category_label || categoryLabels[item.category] || "Watching");
appendStatusTag(statusRow, item.media_type_label || mediaTypeLabels[item.media_type] || "TV");
if (item.airing_status) { if (item.airing_status) {
appendStatusTag(statusRow, item.airing_status); appendStatusTag(statusRow, item.airing_status);
} }
@@ -1022,6 +1417,8 @@ WATCHLIST_HTML = r"""<!doctype html>
if (item.dub_complete) { if (item.dub_complete) {
appendStatusTag(statusRow, "Dub ready", "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 card.querySelector(".card-date").textContent = item.status_message
? `Last checked: ${checked}\n${item.status_message}` ? `Last checked: ${checked}\n${item.status_message}`
: `Last checked: ${checked}`; : `Last checked: ${checked}`;
@@ -1037,9 +1434,14 @@ WATCHLIST_HTML = r"""<!doctype html>
}); });
const actionButtons = card.querySelector(".card-actions").querySelectorAll("button"); const actionButtons = card.querySelector(".card-actions").querySelectorAll("button");
const refreshBtn = actionButtons[0]; const refreshBtn = actionButtons[0];
const searchBtn = actionButtons[1]; const removeBtn = actionButtons[1];
const removeBtn = actionButtons[2]; 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 categorySelect = card.querySelector(".category-select");
const mediaTypeSelect = card.querySelector(".media-type-select");
categorySelect.addEventListener("change", async () => { categorySelect.addEventListener("change", async () => {
categorySelect.disabled = true; categorySelect.disabled = true;
try { try {
@@ -1056,10 +1458,32 @@ WATCHLIST_HTML = r"""<!doctype html>
categorySelect.disabled = false; categorySelect.disabled = false;
} }
}); });
refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id)); mediaTypeSelect.addEventListener("change", async () => {
searchBtn.addEventListener("click", () => { mediaTypeSelect.disabled = true;
window.location.href = `/?query=${encodeURIComponent(item.title || item.show_id)}`; 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));
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)); removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title));
container.appendChild(card); container.appendChild(card);
applyThumbnail(card, item, false); applyThumbnail(card, item, false);
@@ -1129,11 +1553,13 @@ WATCHLIST_HTML = r"""<!doctype html>
body: JSON.stringify({ body: JSON.stringify({
show_id: showId, show_id: showId,
title, title,
category: $("watchlist-category").value category: $("watchlist-category").value,
media_type: $("watchlist-media-type").value
}) })
}); });
$("watchlist-form").reset(); $("watchlist-form").reset();
$("watchlist-category").value = "watching"; $("watchlist-category").value = "watching";
$("watchlist-media-type").value = "tv";
setNotice(data.message || "Added to watchlist."); setNotice(data.message || "Added to watchlist.");
await fetchWatchlist(); await fetchWatchlist();
} catch (error) { } catch (error) {
@@ -1154,6 +1580,21 @@ WATCHLIST_HTML = r"""<!doctype html>
} }
} }
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);
}
}
async function removeWatchlistItem(showId, title) { async function removeWatchlistItem(showId, title) {
if (!window.confirm(`Remove ${title || "this anime"} from the watchlist?`)) { if (!window.confirm(`Remove ${title || "this anime"} from the watchlist?`)) {
return; return;
+2
View File
@@ -3,6 +3,7 @@
"""Aggregated template exports for ani-cli-web.""" """Aggregated template exports for ani-cli-web."""
from config_page import CONFIG_HTML from config_page import CONFIG_HTML
from queue_page import QUEUE_HTML
from search_page import INDEX_HTML from search_page import INDEX_HTML
from template_helpers import PAGE_LINKS, render_page_links, render_sidebar_brand from template_helpers import PAGE_LINKS, render_page_links, render_sidebar_brand
from watchlist_page import WATCHLIST_HTML from watchlist_page import WATCHLIST_HTML
@@ -11,6 +12,7 @@ __all__ = [
"CONFIG_HTML", "CONFIG_HTML",
"INDEX_HTML", "INDEX_HTML",
"PAGE_LINKS", "PAGE_LINKS",
"QUEUE_HTML",
"WATCHLIST_HTML", "WATCHLIST_HTML",
"render_page_links", "render_page_links",
"render_sidebar_brand", "render_sidebar_brand",