Compare commits
81
Commits
d0af0258f2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef2df14aab | ||
|
|
3f4baf21e8 | ||
|
|
363de302a8 | ||
|
|
348d97488a | ||
|
|
02226ad5c9 | ||
|
|
dbc1dc8e3a | ||
|
|
6b3745c7e9 | ||
|
|
6bec0e068a | ||
|
|
604265c44f | ||
|
|
ba408877b5 | ||
|
|
34c77ed9bb | ||
|
|
292bbedd6b | ||
|
|
0257924785 | ||
|
|
bcf9e15d40 | ||
|
|
0c17499efa | ||
|
|
e84ef16f7d | ||
|
|
4e980220b1 | ||
|
|
d8ed64bd64 | ||
|
|
332ec670fc | ||
|
|
6403c98b7d | ||
|
|
ef00ff20a9 | ||
|
|
bff36940ac | ||
|
|
18d82a8678 | ||
|
|
94740ebb76 | ||
|
|
d075aa11f9 | ||
|
|
541cbe83bb | ||
|
|
c757a58f69 | ||
|
|
2c1650d699 | ||
|
|
7a0fa024ed | ||
|
|
bf4fdc79f5 | ||
|
|
4c430e1982 | ||
|
|
83497d8ff0 | ||
|
|
ff605aca03 | ||
|
|
28410b2946 | ||
|
|
f5c476162e | ||
|
|
b72d93268c | ||
|
|
1ceb773f99 | ||
|
|
8fb32a59ba | ||
|
|
5db94cffb1 | ||
|
|
1f50c3c666 | ||
|
|
94ded2725e | ||
|
|
c006fb2d1d | ||
|
|
75262d261d | ||
|
|
d10ae832b3 | ||
|
|
aef3be9d2d | ||
|
|
ece4ce0d5e | ||
|
|
139d7d9e2f | ||
|
|
1487257bed | ||
|
|
8549944be1 | ||
|
|
58f96cb6dd | ||
|
|
1b887269da | ||
|
|
d34fbae7e7 | ||
|
|
cad3cc46c4 | ||
|
|
df4eb39ddf | ||
|
|
e48bc21944 | ||
|
|
baea85e24c | ||
|
|
3d42657837 | ||
|
|
46af0ff47d | ||
|
|
64979d5d32 | ||
|
|
643408b19d | ||
|
|
93446c5e92 | ||
|
|
2ff4e390c4 | ||
|
|
bead7da60a | ||
|
|
5b0f4043ed | ||
|
|
8aa490adf8 | ||
|
|
0c1b46e395 | ||
|
|
84c2e533d0 | ||
|
|
c6ba7fdbe2 | ||
|
|
91691244d7 | ||
|
|
5bec8713a5 | ||
|
|
948753fe28 | ||
|
|
fe094a7112 | ||
|
|
9d4baea057 | ||
|
|
bb4983c928 | ||
|
|
9648b4f6e4 | ||
|
|
a95b4c343f | ||
|
|
70a8acfb18 | ||
|
|
5c4f251fa6 | ||
|
|
91b1e36a94 | ||
|
|
2a0da335b2 | ||
|
|
9e918a50ab |
@@ -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
|
||||
+286
@@ -1,5 +1,291 @@
|
||||
# Changelog
|
||||
|
||||
## 0.49.4 - 2026-07-19
|
||||
|
||||
- Added optional download job stdout mirroring with `ANI_CLI_WEB_JOB_STDOUT`, enabled by default in Docker and Docker Compose so container log collectors can capture downloader output.
|
||||
|
||||
## 0.49.3 - 2026-07-19
|
||||
|
||||
- Changed Docker builds to copy the current local `ani-cli-web` checkout into the image instead of cloning the configured remote branch, so rebuilt images include local Docker/runtime fixes immediately.
|
||||
- Installed Docker `anipy-cli` and `animdl` in separate virtualenvs, with `animdl` pinned under Python 3.11 so its older native dependency pins resolve and remain executable.
|
||||
- Removed the unused `ANI_CLI_WEB` compose build argument and updated Docker documentation to match the local-checkout build flow.
|
||||
|
||||
## 0.49.2 - 2026-07-19
|
||||
|
||||
- Fixed the Config page dependency check so Docker-installed fallback tools are detected from `/usr/local/bin` and `/usr/bin`, matching the runtime command lookup.
|
||||
- Set the Docker image `PATH` explicitly to include system binary directories before the app starts.
|
||||
|
||||
## 0.49.1 - 2026-07-19
|
||||
|
||||
- Fixed Docker fallback tool discovery by exposing `anipy-cli` and `animdl` from fixed system paths and checking common system install locations at runtime.
|
||||
- Changed queue jobs to fail immediately with a clear message when every selected download method is unavailable, instead of trying to launch an unavailable command.
|
||||
|
||||
## 0.49.0 - 2026-07-19
|
||||
|
||||
- Added `animdl` as a third queue download backend and Docker-installed fallback tool.
|
||||
- Replaced the single `anipy-cli` fallback toggle with selectable download methods for `ani-cli`, `anipy-cli`, and `animdl`; queue jobs try the checked methods in that order.
|
||||
- Exposed `animdl` availability and version in the Config page runtime checks.
|
||||
|
||||
## 0.48.3 - 2026-07-17
|
||||
|
||||
- Added Jellyfin handoff jobs to the Queue page so manual copy jobs appear alongside downloads with live progress, moved-file counts, and recent handoff activity.
|
||||
|
||||
## 0.48.2 - 2026-07-09
|
||||
|
||||
- Added support for a checked-in `favicon.png`, serving it from the app and wiring it into the website pages so browsers show the custom favicon.
|
||||
|
||||
## 0.48.1 - 2026-07-09
|
||||
|
||||
- Changed queue inserts so adding a download that exactly matches an existing failed job now reuses that failed entry and resets it to pending instead of creating another duplicate failed row.
|
||||
|
||||
## 0.48.0 - 2026-07-09
|
||||
|
||||
- Added a `Clear failed` bulk action to the Queue page so failed jobs can be removed without touching pending, running, finished, or canceled downloads.
|
||||
|
||||
## 0.47.1 - 2026-07-09
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
+35
-5
@@ -1,17 +1,20 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
ARG ANI_CLI=https://github.com/pystardust/ani-cli.git
|
||||
ARG ANI_CLI_BRANCH=master
|
||||
ARG ANI_CLI_WEB=https://gitea.coreplay.eu/Dymas/ani-cli-web.git
|
||||
ARG YT_DLP_RELEASE_URL=https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
ANI_CLI_REPO=${ANI_CLI} \
|
||||
ANI_CLI_BRANCH=${ANI_CLI_BRANCH} \
|
||||
ANI_CLI_WEB_REPO=${ANI_CLI_WEB} \
|
||||
PATH=/usr/local/bin:/usr/bin:/bin \
|
||||
ANI_CLI_BIN=ani-cli \
|
||||
ANIPY_CLI_BIN=/usr/local/bin/anipy-cli \
|
||||
ANIMDL_BIN=/usr/local/bin/animdl \
|
||||
ANI_CLI_DOWNLOAD_DIR=/downloads \
|
||||
ANI_CLI_WEB_HOST=0.0.0.0 \
|
||||
ANI_CLI_WEB_PORT=8421 \
|
||||
ANI_CLI_WEB_JOB_STDOUT=true \
|
||||
UPDATE_ON_START=false \
|
||||
USER_UID= \
|
||||
USER_GID=
|
||||
@@ -19,14 +22,20 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
aria2 \
|
||||
botan \
|
||||
ca-certificates \
|
||||
curl \
|
||||
ffmpeg \
|
||||
fzf \
|
||||
build-essential \
|
||||
git \
|
||||
grep \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
openssl \
|
||||
patch \
|
||||
python3.11 \
|
||||
python3.11-venv \
|
||||
sed \
|
||||
sudo \
|
||||
util-linux \
|
||||
@@ -41,8 +50,29 @@ RUN git clone --depth 1 --branch "${ANI_CLI_BRANCH}" "${ANI_CLI}" /tmp/ani-cli-s
|
||||
esac \
|
||||
&& rm -rf /tmp/ani-cli-src
|
||||
|
||||
RUN git clone --depth 1 "${ANI_CLI_WEB}" /app \
|
||||
&& rm -rf /app/.git
|
||||
RUN curl -fL "${YT_DLP_RELEASE_URL}" -o /usr/local/bin/yt-dlp \
|
||||
&& chmod 0755 /usr/local/bin/yt-dlp \
|
||||
&& yt-dlp --version
|
||||
|
||||
RUN python3 -m venv /opt/anipy-cli \
|
||||
&& /opt/anipy-cli/bin/pip install --no-cache-dir --upgrade pip \
|
||||
&& /opt/anipy-cli/bin/pip install --no-cache-dir anipy-cli \
|
||||
&& python3.11 -m venv /opt/animdl \
|
||||
&& /opt/animdl/bin/pip install --no-cache-dir --upgrade pip \
|
||||
&& /opt/animdl/bin/pip install --no-cache-dir animdl==1.7.27 \
|
||||
&& test -x /opt/anipy-cli/bin/anipy-cli \
|
||||
&& test -x /opt/animdl/bin/animdl \
|
||||
&& printf '%s\n' '#!/bin/sh' 'exec /opt/anipy-cli/bin/anipy-cli "$@"' > /usr/local/bin/anipy-cli \
|
||||
&& printf '%s\n' '#!/bin/sh' 'exec /opt/animdl/bin/animdl "$@"' > /usr/local/bin/animdl \
|
||||
&& chmod 0755 /usr/local/bin/anipy-cli /usr/local/bin/animdl \
|
||||
&& ln -sf /usr/local/bin/anipy-cli /usr/bin/anipy-cli \
|
||||
&& ln -sf /usr/local/bin/animdl /usr/bin/animdl \
|
||||
&& /usr/local/bin/anipy-cli --version \
|
||||
&& /usr/local/bin/animdl --version
|
||||
|
||||
RUN mkdir -p /app
|
||||
|
||||
COPY app.py app_support.py config_page.py http_handler.py queue_jobs.py queue_page.py search_page.py template_helpers.py title_matching.py watchlist_identity.py watchlist_page.py web_templates.py VERSION CHANGELOG.md favicon.png /app/
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
|
||||
@@ -1,92 +1,74 @@
|
||||
# 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.49.4`
|
||||
|
||||
## Project Layout
|
||||
## What it does
|
||||
|
||||
```text
|
||||
ani-cli-web/
|
||||
app.py Public entrypoint, runtime wiring, watchlist domain logic, and CLI startup.
|
||||
app_support.py Shared runtime/state/config helpers plus queue and library file utilities.
|
||||
Dockerfile Container image build for system-wide ani-cli plus /app ani-cli-web.
|
||||
docker-compose.yaml Compose service for building and running the container with persistent mounts.
|
||||
queue_jobs.py Download queue and background watchlist-refresh workers.
|
||||
watchlist_identity.py Safer watchlist identity resolution for queued download jobs.
|
||||
title_matching.py Shared anime-title normalization and matching helpers.
|
||||
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.
|
||||
```
|
||||
- Search anime in `sub` or `dub`, with poster-style results shown below the selection panel and the first English alternate title when available
|
||||
- Queue downloads with folder, quality, media type, season, and episode controls
|
||||
- Choose which download backends queue jobs may use: `ani-cli`, `anipy-cli`, and `animdl`
|
||||
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
|
||||
- Expose a small JSON summary endpoint for Homepage dashboard widgets
|
||||
- Refresh watchlist episode counts manually or on a schedule
|
||||
- Auto-download missing episodes for `Watching` entries after later refreshes, with a per-show `Source name` override for `ani-cli` search
|
||||
- Move fully completed libraries into Jellyfin TV or movie folders with tracked background handoff jobs that also appear in the Queue page
|
||||
- Send optional Discord webhook notifications
|
||||
- Serve a checked-in `favicon.png` as the site favicon
|
||||
|
||||
## 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 downloads and Jellyfin handoff jobs, inspect logs, retry failed downloads, avoid duplicate re-queues for the same failed download, clear failed jobs, and remove finished jobs
|
||||
- `/watchlist`: Track shows, refresh them, download all available episodes, and manage auto-download settings such as `Source name`, library `Name`, season, and episode offset
|
||||
- `/config`: Save defaults, choose queue download methods and 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` and/or `animdl` installed and available in `PATH` if you want automatic fallback retries outside Docker
|
||||
- Common runtime tools used by `ani-cli`, especially `curl`, `ffmpeg`, `fzf`, `grep`, `openssl`, `sed`, `aria2c`, `botan`, and `yt-dlp`
|
||||
|
||||
Run locally:
|
||||
|
||||
```sh
|
||||
./ani-cli-web
|
||||
```
|
||||
|
||||
For thumbnail debugging, run:
|
||||
|
||||
```sh
|
||||
./ani-cli-web --debug
|
||||
```
|
||||
|
||||
The launcher also accepts `---debug`. You can also enable debug logging with `ANI_CLI_WEB_DEBUG=1`.
|
||||
|
||||
Then open:
|
||||
Open:
|
||||
|
||||
```text
|
||||
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_WEB_JOB_STDOUT=true`
|
||||
- `ANI_CLI_BIN=/path/to/ani-cli`
|
||||
- `ANIPY_CLI_BIN=/path/to/anipy-cli`
|
||||
- `ANIMDL_BIN=/path/to/animdl`
|
||||
|
||||
## Docker
|
||||
|
||||
The container image installs `ani-cli` system-wide and clones `ani-cli-web` into `/app` during `docker 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:
|
||||
Build:
|
||||
|
||||
```sh
|
||||
docker build -t ani-cli-web .
|
||||
```
|
||||
|
||||
Override the `ani-cli` branch when needed:
|
||||
Docker builds the app from the current local checkout.
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--build-arg ANI_CLI_BRANCH=my-feature-branch \
|
||||
--build-arg ANI_CLI=https://github.com/pystardust/ani-cli.git \
|
||||
--build-arg ANI_CLI_WEB=https://gitea.coreplay.eu/Dymas/ani-cli-web.git \
|
||||
-t ani-cli-web .
|
||||
```
|
||||
|
||||
Run the container with the requested persistent mounts:
|
||||
Run:
|
||||
|
||||
```sh
|
||||
docker run --rm \
|
||||
-p 8421:8421 \
|
||||
-e UPDATE_ON_START=true \
|
||||
-e USER_UID="$(id -u)" \
|
||||
-e USER_GID="$(id -g)" \
|
||||
-v "$(pwd)/downloads:/downloads" \
|
||||
@@ -94,315 +76,161 @@ docker run --rm \
|
||||
ani-cli-web
|
||||
```
|
||||
|
||||
Or start the same setup with Compose:
|
||||
|
||||
```sh
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
To keep mounted downloads owned by your host user, start Compose with:
|
||||
Compose:
|
||||
|
||||
```sh
|
||||
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_JOB_STDOUT=true` mirrors download job output to container stdout for log collectors; set it to `false` to keep job output only in the Queue page
|
||||
- `botan` is installed inside the container for providers or scripts that need Botan cryptography tooling
|
||||
- `anipy-cli` and `animdl` are installed systemwide inside the container at `/usr/local/bin`, linked from `/usr/bin`, and included in the runtime `PATH`; `animdl` is isolated in a Python 3.11 virtualenv so its pinned dependencies stay executable
|
||||
|
||||
## Key behavior
|
||||
|
||||
### 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`
|
||||
- Queue jobs can use any checked combination of `ani-cli`, `anipy-cli`, and `animdl`
|
||||
- Checked methods are tried in this order: `ani-cli`, `anipy-cli`, then `animdl`
|
||||
- Selecting one method disables fallback retry; selecting multiple methods makes later methods automatic fallbacks
|
||||
- Fallback retries keep the queued episode numbers when fallback downloader filenames are renumbered from `1`
|
||||
- Docker images install `anipy-cli` and `animdl` automatically; non-Docker installs should provide the selected tools in `PATH` themselves
|
||||
|
||||
### Queue retries
|
||||
|
||||
- Adding a download that exactly matches an existing failed queue job reuses that failed entry and sets it back to `pending`
|
||||
- This avoids stacking multiple failed rows for the same episode when you queue the same job again
|
||||
|
||||
### Jellyfin handoff
|
||||
|
||||
- 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
|
||||
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
|
||||
ANI_CLI_WEB_PORT=8421
|
||||
UPDATE_ON_START=false
|
||||
USER_UID=
|
||||
USER_GID=
|
||||
ANI_CLI_WEB_ALLOW_REMOTE=false
|
||||
ANI_CLI_WEB_REMOTE_TOKEN=
|
||||
http://127.0.0.1:8421/api/watchlist/homepage
|
||||
```
|
||||
|
||||
Container defaults:
|
||||
Example response:
|
||||
|
||||
- `ani-cli` is installed at `/usr/local/bin/ani-cli`.
|
||||
- `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.
|
||||
- Downloads are written to `/downloads`, so bind-mount `./downloads:/downloads`.
|
||||
- App state stays under `/app/.ani-cli-web`, so bind-mount `./.ani-cli-web:/app/.ani-cli-web`.
|
||||
- The image sets `ANI_CLI_WEB_HOST=0.0.0.0` and exposes port `8421`.
|
||||
- `UPDATE_ON_START=true` runs `sudo ani-cli --update` before the web app starts.
|
||||
- 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`.
|
||||
```json
|
||||
{
|
||||
"watchlist": 12,
|
||||
"planned": 4,
|
||||
"finished": 31,
|
||||
"dropped": 2,
|
||||
"total": 49
|
||||
}
|
||||
```
|
||||
|
||||
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` and `animdl` versions and dependency status
|
||||
- A `Changelog` button that opens a scrollable viewer backed by `CHANGELOG.md`
|
||||
|
||||
- Search against AllAnime with `sub` or `dub` mode selection.
|
||||
- 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/`.
|
||||
## Remote access
|
||||
|
||||
## Search Page
|
||||
By default the app only serves loopback clients.
|
||||
|
||||
The main page at `/` is where you search and queue downloads.
|
||||
|
||||
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:
|
||||
To allow LAN or remote access:
|
||||
|
||||
```sh
|
||||
ANI_CLI_WEB_ALLOW_REMOTE=1
|
||||
ANI_CLI_WEB_REMOTE_TOKEN=choose-a-long-random-token
|
||||
ANI_CLI_WEB_AUTH_USERNAME=admin
|
||||
ANI_CLI_WEB_AUTH_PASSWORD=choose-a-long-random-password
|
||||
```
|
||||
|
||||
Non-local clients must then send either:
|
||||
Optional remote path restriction:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
- `X-AniCli-Web-Token: <token>`
|
||||
|
||||
For browser access from another machine, you can also open the app once with:
|
||||
|
||||
```text
|
||||
http://YOUR-HOST:8421/?token=your-token-here
|
||||
```sh
|
||||
ANI_CLI_WEB_REMOTE_PATH_ROOTS=/downloads:/srv/jellyfin-tv:/srv/jellyfin-movies
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
Project state is stored in:
|
||||
|
||||
```text
|
||||
ani-cli-web/.ani-cli-web/
|
||||
```
|
||||
|
||||
That folder currently holds:
|
||||
Main contents:
|
||||
|
||||
- `config.json`
|
||||
- `remote-sessions.json`
|
||||
- `state.sqlite3`
|
||||
- legacy `queue.json` migration input when present
|
||||
- legacy `watchlist.json` migration input when present
|
||||
- `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.
|
||||
The earlier project-local `queue.sqlite3` filename is also migrated automatically into `state.sqlite3`.
|
||||
Downloaded files are organized like this:
|
||||
|
||||
```text
|
||||
ANI_CLI_DOWNLOAD_DIR/tv/Show Name/Season 01/Show Name - S01E01.mp4
|
||||
ANI_CLI_DOWNLOAD_DIR/movie/Movie Name/Movie Name.mp4
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
Most users only need these environment variables:
|
||||
|
||||
- `ANI_CLI_BIN`: path or command name for `ani-cli`; defaults to `ani-cli` from `PATH`.
|
||||
- `ANI_CLI_DOWNLOAD_DIR`: initial default download folder.
|
||||
- `ANI_CLI_MODE`: initial mode, `sub` or `dub`.
|
||||
- `ANI_CLI_QUALITY`: initial quality such as `best`, `1080`, or `720`.
|
||||
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`.
|
||||
- `ANI_CLI_WEB_PORT`: server port, default `8421`.
|
||||
- `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients when set to `1`, `true`, `yes`, or `on`.
|
||||
- `ANI_CLI_WEB_REMOTE_TOKEN`: required shared secret for non-loopback clients when remote access is enabled.
|
||||
- `ANI_CLI_WEB_DEBUG`: enable debug logging when set to `1`, `true`, `yes`, or `on`.
|
||||
- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory path; useful for isolated test runs or custom storage locations.
|
||||
- `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.
|
||||
- `ANI_CLI_BIN`
|
||||
- `ANIPY_CLI_BIN`
|
||||
- `ANIMDL_BIN`
|
||||
- `ANI_CLI_WEB_HOST`
|
||||
- `ANI_CLI_WEB_PORT`
|
||||
- `ANI_CLI_WEB_ALLOW_REMOTE`
|
||||
- `ANI_CLI_WEB_AUTH_USERNAME`
|
||||
- `ANI_CLI_WEB_AUTH_PASSWORD`
|
||||
- `ANI_CLI_WEB_REMOTE_PATH_ROOTS`
|
||||
- `ANI_CLI_WEB_DEBUG`
|
||||
- `ANI_CLI_WEB_STATE_ROOT`
|
||||
|
||||
Saved 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- Runtime folders such as `.ani-cli-web/`, `downloads/`, and Python bytecode cache directories at any depth are ignored by git.
|
||||
- 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`.
|
||||
## Testing
|
||||
|
||||
Run the regression suite with:
|
||||
|
||||
```sh
|
||||
python3 -m unittest test_app.py
|
||||
```
|
||||
|
||||
+836
-26
File diff suppressed because it is too large
Load Diff
+661
-30
@@ -2,15 +2,26 @@
|
||||
|
||||
"""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
|
||||
|
||||
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ani-cli web - config</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -49,7 +60,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
button, input, select {
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
@@ -74,7 +85,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
font-weight: 700;
|
||||
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
|
||||
}
|
||||
input, select {
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 14px;
|
||||
@@ -85,7 +96,12 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
outline: none;
|
||||
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);
|
||||
box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12);
|
||||
}
|
||||
@@ -119,7 +135,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
justify-items: start;
|
||||
justify-items: stretch;
|
||||
}
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 {
|
||||
@@ -154,18 +170,18 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
}
|
||||
.row > * { flex: 1; }
|
||||
.page-links {
|
||||
display: flex;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.page-link {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
@@ -208,6 +224,9 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.wide-button {
|
||||
width: 100%;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -228,21 +247,196 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.path-input {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
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 {
|
||||
width: min(760px, 100%);
|
||||
width: 100%;
|
||||
}
|
||||
.overlay[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 22px;
|
||||
background: rgba(4, 3, 10, 0.78);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.browser-modal {
|
||||
width: min(100%, 860px);
|
||||
max-height: min(84vh, 760px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(29, 22, 47, 0.96), rgba(17, 13, 29, 0.98));
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.browser-header,
|
||||
.browser-actions,
|
||||
.browser-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.browser-path {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(10, 7, 18, 0.82);
|
||||
color: var(--text);
|
||||
padding: 10px 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.browser-list {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 220px;
|
||||
}
|
||||
.browser-roots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.browser-roots .ghost.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--line-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.browser-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.browser-entry .muted {
|
||||
font-size: 12px;
|
||||
}
|
||||
.browser-entry-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.changelog-modal {
|
||||
width: min(100%, 900px);
|
||||
max-height: min(84vh, 820px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(29, 22, 47, 0.96), rgba(17, 13, 29, 0.98));
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.changelog-header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.changelog-content {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 16px;
|
||||
}
|
||||
.changelog-content pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font: 13px/1.55 "Cascadia Code", "Fira Code", Consolas, monospace;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; }
|
||||
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.toolbar, .row { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
@media (max-width: 1080px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.form-grid {
|
||||
.form-grid, .check-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -267,12 +461,19 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
<h2>Runtime info</h2>
|
||||
<div class="stack muted">
|
||||
<p id="versionLine">Loading version...</p>
|
||||
<p id="aniCliVersionLine">Detecting ani-cli version...</p>
|
||||
<p id="anipyCliVersionLine">Detecting anipy-cli fallback...</p>
|
||||
<p id="animdlVersionLine">Detecting animdl fallback...</p>
|
||||
<p>Saved settings live in `.ani-cli-web/config.json` inside this project.</p>
|
||||
</div>
|
||||
<button class="ghost wide-button" id="openChangelogBtn" type="button">Changelog</button>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<section class="deps" id="deps"></section>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings settings-main">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
@@ -281,10 +482,12 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
</div>
|
||||
<button class="primary" id="saveConfigBtn" type="button">Save</button>
|
||||
</div>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="stack">
|
||||
<label>Default folder
|
||||
<div class="path-input">
|
||||
<input id="downloadDir" placeholder="/home/me/Downloads/Anime">
|
||||
<button class="ghost browse-path-btn" type="button" data-target="downloadDir" data-mode="dir">Browse</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
@@ -306,6 +509,21 @@ 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>
|
||||
</section>
|
||||
|
||||
<section class="settings settings-main">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>Download fallback</h2>
|
||||
<p class="muted">Choose the download methods queue jobs may use, in fallback order.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="check-grid">
|
||||
<label class="check-item"><input class="download-method" type="checkbox" value="ani-cli"> <span>ani-cli</span></label>
|
||||
<label class="check-item"><input class="download-method" type="checkbox" value="anipy-cli"> <span>anipy-cli</span></label>
|
||||
<label class="check-item"><input class="download-method" type="checkbox" value="animdl"> <span>animdl</span></label>
|
||||
</div>
|
||||
<div class="field-hint">Jobs try checked methods in this order: ani-cli, anipy-cli, animdl. With one method checked, fallback retry is disabled.</div>
|
||||
</section>
|
||||
|
||||
<section class="settings settings-main">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
@@ -330,65 +548,429 @@ 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>
|
||||
</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>
|
||||
</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">×</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">×</button>
|
||||
</div>
|
||||
<div class="changelog-content">
|
||||
<pre id="changelogContent">Loading changelog...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
config: {
|
||||
mode: "sub",
|
||||
quality: "best",
|
||||
download_dir: "",
|
||||
anipy_cli_fallback_enabled: false,
|
||||
download_methods: ["ani-cli"],
|
||||
watchlist_auto_refresh_enabled: false,
|
||||
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);
|
||||
""" + BROWSER_API_HELPER + r"""
|
||||
|
||||
const pathBrowserOverlay = $("pathBrowserOverlay");
|
||||
const pathBrowserCurrentPath = $("pathBrowserCurrentPath");
|
||||
const pathBrowserList = $("pathBrowserList");
|
||||
const pathBrowserStatus = $("pathBrowserStatus");
|
||||
const changelogOverlay = $("changelogOverlay");
|
||||
const changelogContent = $("changelogContent");
|
||||
|
||||
function setNotice(text) {
|
||||
$("notice").textContent = text || "";
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
state.config = await api("/api/config");
|
||||
$("downloadDir").value = state.config.download_dir;
|
||||
$("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;
|
||||
function setJellyfinSyncButton(running) {
|
||||
$("runJellyfinSyncBtn").disabled = !!running;
|
||||
$("runJellyfinSyncBtn").textContent = running ? "Running..." : "Run now";
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
const data = await api("/api/config", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
function setJellyfinSyncStatus(job) {
|
||||
const el = $("jellyfinSyncStatus");
|
||||
if (!job) {
|
||||
el.textContent = "No recent Jellyfin handoff job.";
|
||||
return;
|
||||
}
|
||||
if (job.status === "running") {
|
||||
const title = job.current_title ? ` · ${job.current_title}` : "";
|
||||
const file = job.current_file ? ` · ${job.current_file}` : "";
|
||||
el.textContent = `${job.completed || 0}/${job.total || 0} processed · ${job.moved || 0} moved · ${job.moved_files || 0} files${title}${file}`;
|
||||
return;
|
||||
}
|
||||
el.textContent = job.message || "Jellyfin handoff idle.";
|
||||
}
|
||||
|
||||
function selectedWebhookEvents() {
|
||||
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
|
||||
}
|
||||
|
||||
function selectedDownloadMethods() {
|
||||
const methods = [...document.querySelectorAll(".download-method:checked")].map((input) => input.value);
|
||||
return methods.length ? methods : ["ani-cli"];
|
||||
}
|
||||
|
||||
function formConfigPayload() {
|
||||
return {
|
||||
download_dir: $("downloadDir").value,
|
||||
mode: $("configMode").value,
|
||||
quality: $("configQuality").value,
|
||||
download_methods: selectedDownloadMethods(),
|
||||
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
|
||||
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;
|
||||
$("downloadDir").value = data.download_dir;
|
||||
$("configMode").value = data.mode;
|
||||
$("configQuality").value = data.quality;
|
||||
const methods = new Set(data.download_methods || ["ani-cli"]);
|
||||
for (const input of document.querySelectorAll(".download-method")) {
|
||||
input.checked = methods.has(input.value);
|
||||
}
|
||||
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
|
||||
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
|
||||
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
|
||||
$("autoDownloadEnabled").value = String(Boolean(data.auto_download_enabled));
|
||||
$("autoDownloadMode").value = data.auto_download_mode;
|
||||
$("autoDownloadQuality").value = data.auto_download_quality;
|
||||
$("jellyfinSyncEnabled").value = String(Boolean(data.jellyfin_sync_enabled));
|
||||
$("jellyfinTvDir").value = data.jellyfin_tv_dir || "";
|
||||
$("jellyfinMovieDir").value = data.jellyfin_movie_dir || "";
|
||||
$("discordWebhookUrl").value = data.discord_webhook_url || "";
|
||||
const selected = new Set(data.discord_webhook_events || []);
|
||||
for (const input of document.querySelectorAll(".webhook-event")) {
|
||||
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.");
|
||||
} catch (error) {
|
||||
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() {
|
||||
const data = await api("/api/dependencies");
|
||||
const el = $("deps");
|
||||
@@ -407,15 +989,64 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
async function loadVersion() {
|
||||
const data = await api("/api/version");
|
||||
$("versionLine").textContent = `${data.name} ${data.version}`;
|
||||
$("aniCliVersionLine").textContent = `ani-cli ${data.ani_cli_version || "Unavailable"}`;
|
||||
$("anipyCliVersionLine").textContent = `anipy-cli ${data.anipy_cli_version || "Unavailable"}`;
|
||||
$("animdlVersionLine").textContent = `animdl ${data.animdl_version || "Unavailable"}`;
|
||||
}
|
||||
|
||||
async function openChangelog() {
|
||||
try {
|
||||
changelogContent.textContent = "Loading changelog...";
|
||||
changelogOverlay.hidden = false;
|
||||
const data = await api("/api/changelog");
|
||||
changelogContent.textContent = data.content || "Changelog unavailable.";
|
||||
} catch (error) {
|
||||
changelogContent.textContent = error.message || "Changelog unavailable.";
|
||||
}
|
||||
}
|
||||
|
||||
$("saveConfigBtn").addEventListener("click", saveConfig);
|
||||
$("testWebhookBtn").addEventListener("click", testWebhook);
|
||||
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
|
||||
$("openChangelogBtn").addEventListener("click", openChangelog);
|
||||
$("closeChangelogBtn").addEventListener("click", closeChangelog);
|
||||
$("pathBrowserCloseBtn").addEventListener("click", closePathBrowser);
|
||||
$("pathBrowserCancelBtn").addEventListener("click", closePathBrowser);
|
||||
$("pathBrowserSelectBtn").addEventListener("click", selectCurrentBrowserPath);
|
||||
$("pathBrowserUpBtn").addEventListener("click", browseParentPath);
|
||||
$("pathBrowserHomeBtn").addEventListener("click", browseHomePath);
|
||||
$("pathBrowserRefreshBtn").addEventListener("click", () => loadPathBrowser());
|
||||
pathBrowserOverlay.addEventListener("click", (event) => {
|
||||
if (event.target === pathBrowserOverlay) {
|
||||
closePathBrowser();
|
||||
}
|
||||
});
|
||||
changelogOverlay.addEventListener("click", (event) => {
|
||||
if (event.target === changelogOverlay) {
|
||||
closeChangelog();
|
||||
}
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !pathBrowserOverlay.hidden) {
|
||||
closePathBrowser();
|
||||
}
|
||||
if (event.key === "Escape" && !changelogOverlay.hidden) {
|
||||
closeChangelog();
|
||||
}
|
||||
});
|
||||
for (const button of document.querySelectorAll(".browse-path-btn")) {
|
||||
button.addEventListener("click", () => openPathBrowser(button.dataset.target || "", button.dataset.mode || "dir"));
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
await loadConfig();
|
||||
await pollJellyfinSyncStatus(false);
|
||||
await loadDeps();
|
||||
await loadVersion();
|
||||
window.setInterval(() => {
|
||||
pollJellyfinSyncStatus();
|
||||
}, 2500);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
|
||||
+4
-2
@@ -6,8 +6,8 @@ services:
|
||||
args:
|
||||
ANI_CLI: ${ANI_CLI:-https://github.com/pystardust/ani-cli.git}
|
||||
ANI_CLI_BRANCH: ${ANI_CLI_BRANCH:-master}
|
||||
ANI_CLI_WEB: ${ANI_CLI_WEB:-https://gitea.coreplay.eu/Dymas/ani-cli-web.git}
|
||||
image: ani-cli-web:latest
|
||||
container_name: ani-cli-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ANI_CLI_WEB_PORT:-8421}:8421"
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
ANI_CLI_WEB_HOST: 0.0.0.0
|
||||
ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421}
|
||||
ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false}
|
||||
ANI_CLI_WEB_REMOTE_TOKEN: ${ANI_CLI_WEB_REMOTE_TOKEN:-}
|
||||
ANI_CLI_WEB_AUTH_USERNAME: ${ANI_CLI_WEB_AUTH_USERNAME:-}
|
||||
ANI_CLI_WEB_AUTH_PASSWORD: ${ANI_CLI_WEB_AUTH_PASSWORD:-}
|
||||
ANI_CLI_WEB_JOB_STDOUT: ${ANI_CLI_WEB_JOB_STDOUT:-true}
|
||||
ANI_CLI_DOWNLOAD_DIR: /downloads
|
||||
volumes:
|
||||
- ./downloads:/downloads
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
+648
-58
@@ -6,48 +6,79 @@ import json
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import traceback
|
||||
from base64 import b64decode
|
||||
from binascii import Error as BinasciiError
|
||||
from dataclasses import dataclass
|
||||
from http.cookies import SimpleCookie
|
||||
from functools import lru_cache
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from html import escape as html_escape
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
|
||||
|
||||
from app_support import (
|
||||
ANIMDL,
|
||||
ANI_CLI,
|
||||
ANIPY_CLI,
|
||||
APP_NAME,
|
||||
CHANGELOG_FILE,
|
||||
CLIENT_DISCONNECT_ERRORS,
|
||||
MAX_JSON_BODY_BYTES,
|
||||
MODE_CHOICES,
|
||||
PROJECT_ROOT,
|
||||
VERSION,
|
||||
client_address_is_local,
|
||||
configured_remote_path_roots,
|
||||
debug_enabled,
|
||||
debug_log,
|
||||
load_project_text_file,
|
||||
cli_executable_exists_any,
|
||||
cli_executable_exists,
|
||||
normalize_config,
|
||||
path_is_within_roots,
|
||||
remote_access_allowed,
|
||||
remote_access_token_configured,
|
||||
remote_access_token_matches,
|
||||
remote_access_credentials_configured,
|
||||
remote_access_credentials_match,
|
||||
remote_access_session_matches,
|
||||
remote_access_session_value,
|
||||
sanitize_public_config,
|
||||
send_discord_webhook_event,
|
||||
validate_discord_webhook_url,
|
||||
)
|
||||
|
||||
FAVICON_PATH = PROJECT_ROOT / "favicon.png"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HandlerContext:
|
||||
add_to_watchlist: object
|
||||
config_html: str
|
||||
download_watchlist_item: object
|
||||
ensure_runtime: object
|
||||
episode_list: object
|
||||
get_config_snapshot: object
|
||||
get_jellyfin_sync_status: object
|
||||
get_watchlist_homepage_summary: object
|
||||
get_watchlist: object
|
||||
get_watchlist_refresh_status: object
|
||||
http_error: object
|
||||
index_html: str
|
||||
queue_html: str
|
||||
remove_from_watchlist: object
|
||||
run_jellyfin_sync: object
|
||||
runtime_state: object
|
||||
save_runtime_config: object
|
||||
search_anime: object
|
||||
resolve_search_thumbnail: object
|
||||
start_jellyfin_sync: object
|
||||
test_discord_webhook_config: object
|
||||
thumbnail_file_path: object
|
||||
update_all_watchlist_statuses: object
|
||||
update_watchlist_auto_download: object
|
||||
update_watchlist_category: object
|
||||
update_watchlist_media_type: object
|
||||
update_watchlist_status: object
|
||||
upload_watchlist_thumbnail: object
|
||||
watchlist_html: str
|
||||
@@ -63,14 +94,194 @@ def build_handler_class(context):
|
||||
def dependency_status():
|
||||
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
|
||||
result = {name: bool(shutil.which(name)) for name in checks}
|
||||
result["ani-cli"] = bool(shutil.which(ANI_CLI) or (Path(ANI_CLI).exists() and os.access(ANI_CLI, os.X_OK)))
|
||||
result["ani-cli"] = cli_executable_exists(ANI_CLI)
|
||||
result["anipy-cli"] = cli_executable_exists_any(
|
||||
ANIPY_CLI,
|
||||
"anipy-cli",
|
||||
"/usr/local/bin/anipy-cli",
|
||||
"/usr/bin/anipy-cli",
|
||||
)
|
||||
result["animdl"] = cli_executable_exists_any(
|
||||
ANIMDL,
|
||||
"animdl",
|
||||
"/usr/local/bin/animdl",
|
||||
"/usr/bin/animdl",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def queue_list_payload(runtime, page=1, per_page=10):
|
||||
page = max(1, int(page or 1))
|
||||
per_page = min(50, max(1, int(per_page or 10)))
|
||||
download_queue = runtime["download_queue"]
|
||||
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
|
||||
download_payload = download_queue.list(page=1, per_page=50)
|
||||
jobs = list(download_payload.get("jobs") or [])
|
||||
for download_page in range(2, int(download_payload.get("pages") or 1) + 1):
|
||||
jobs.extend(download_queue.list(page=download_page, per_page=50).get("jobs") or [])
|
||||
if jellyfin_jobs is not None:
|
||||
jobs.extend(jellyfin_jobs.list_queue_jobs(limit=200))
|
||||
jobs.sort(key=lambda job: str(job.get("created_at") or ""), reverse=True)
|
||||
total = len(jobs)
|
||||
pages = max(1, (total + per_page - 1) // per_page)
|
||||
offset = (page - 1) * per_page
|
||||
return {
|
||||
"jobs": jobs[offset : offset + per_page],
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
|
||||
def queue_get_job(runtime, job_id):
|
||||
try:
|
||||
return runtime["download_queue"].get(job_id)
|
||||
except KeyError:
|
||||
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
|
||||
if jellyfin_jobs is None:
|
||||
raise
|
||||
return jellyfin_jobs.get_queue_job(job_id)
|
||||
|
||||
|
||||
def queue_remove_job(runtime, job_id):
|
||||
try:
|
||||
return runtime["download_queue"].remove(job_id)
|
||||
except KeyError:
|
||||
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
|
||||
if jellyfin_jobs is None:
|
||||
raise
|
||||
return jellyfin_jobs.remove_queue_job(job_id)
|
||||
|
||||
|
||||
def queue_clear_finished(runtime):
|
||||
result = runtime["download_queue"].clear_finished()
|
||||
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
|
||||
if jellyfin_jobs is not None:
|
||||
result["count"] = int(result.get("count") or 0) + int(jellyfin_jobs.clear_finished_queue_jobs().get("count") or 0)
|
||||
return result
|
||||
|
||||
|
||||
def queue_clear_failed(runtime):
|
||||
result = runtime["download_queue"].clear_failed()
|
||||
jellyfin_jobs = runtime.get("watchlist_jellyfin_sync")
|
||||
if jellyfin_jobs is not None:
|
||||
result["count"] = int(result.get("count") or 0) + int(jellyfin_jobs.clear_failed_queue_jobs().get("count") or 0)
|
||||
return result
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
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"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def installed_animdl_version():
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[ANIMDL, "--version"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=6,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return "Unavailable"
|
||||
version = str(completed.stdout or completed.stderr or "").strip()
|
||||
return version or "Unavailable"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "AniCliWeb/1.0"
|
||||
remote_token_cookie_name = "ani_cli_web_token"
|
||||
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status"}
|
||||
remote_session_cookie_name = "ani_cli_web_session"
|
||||
remote_session_cookie_max_age_seconds = 30 * 24 * 60 * 60
|
||||
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status", "/api/config/jellyfin/sync-status"}
|
||||
|
||||
def _context(self):
|
||||
context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None)
|
||||
@@ -114,15 +325,57 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return candidate
|
||||
return peer_host
|
||||
|
||||
def _remote_token(self):
|
||||
return (
|
||||
self.headers.get("X-AniCli-Web-Token")
|
||||
or self.headers.get("x-ani-cli-web-token")
|
||||
or Handler._bearer_token(self)
|
||||
or Handler._cookie_token(self)
|
||||
or Handler._query_token(self)
|
||||
or ""
|
||||
)
|
||||
def _authorization_header(self):
|
||||
return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip()
|
||||
|
||||
def _host_header(self):
|
||||
host = str(self.headers.get("X-Forwarded-Host") or self.headers.get("x-forwarded-host") or "").strip()
|
||||
if host:
|
||||
return host.split(",", 1)[0].strip()
|
||||
return str(self.headers.get("Host") or self.headers.get("host") or "").strip()
|
||||
|
||||
def _origin(self):
|
||||
return str(self.headers.get("Origin") or self.headers.get("origin") or "").strip()
|
||||
|
||||
def _content_type(self):
|
||||
return str(self.headers.get("Content-Type") or self.headers.get("content-type") or "").strip()
|
||||
|
||||
def _request_is_secure(self):
|
||||
forwarded_proto = str(self.headers.get("X-Forwarded-Proto") or self.headers.get("x-forwarded-proto") or "").strip()
|
||||
if forwarded_proto:
|
||||
return forwarded_proto.split(",", 1)[0].strip().lower() == "https"
|
||||
forwarded = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip()
|
||||
if forwarded:
|
||||
for part in forwarded.split(";"):
|
||||
key, _, value = part.partition("=")
|
||||
if key.strip().lower() == "proto":
|
||||
return value.strip().strip('"').lower() == "https"
|
||||
return False
|
||||
|
||||
def _origin_matches_host(self):
|
||||
origin = Handler._origin(self)
|
||||
if not origin:
|
||||
return False
|
||||
parsed = urlparse(origin)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
return False
|
||||
return parsed.netloc.lower() == Handler._host_header(self).lower()
|
||||
|
||||
def _basic_auth_credentials(self):
|
||||
header = Handler._authorization_header(self)
|
||||
if not header.startswith("Basic "):
|
||||
return ("", "")
|
||||
encoded = header[len("Basic ") :].strip()
|
||||
if not encoded:
|
||||
return ("", "")
|
||||
try:
|
||||
decoded = b64decode(encoded.encode("ascii"), validate=True).decode("utf-8")
|
||||
except (BinasciiError, UnicodeDecodeError, ValueError):
|
||||
return ("", "")
|
||||
username, separator, password = decoded.partition(":")
|
||||
if not separator:
|
||||
return ("", "")
|
||||
return (username.strip(), password.strip())
|
||||
|
||||
def _bearer_token(self):
|
||||
header = str(self.headers.get("Authorization") or "").strip()
|
||||
@@ -130,7 +383,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return ""
|
||||
return header[len("Bearer ") :].strip()
|
||||
|
||||
def _cookie_token(self):
|
||||
def _cookie_value(self, name):
|
||||
raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
@@ -139,51 +392,198 @@ class Handler(BaseHTTPRequestHandler):
|
||||
cookie.load(raw)
|
||||
except Exception:
|
||||
return ""
|
||||
morsel = cookie.get(Handler.remote_token_cookie_name)
|
||||
morsel = cookie.get(name)
|
||||
if not morsel:
|
||||
return ""
|
||||
return morsel.value.strip()
|
||||
|
||||
def _query_token(self):
|
||||
parsed = urlparse(self.path)
|
||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
for key in ("token", "remote_token"):
|
||||
values = params.get(key) or []
|
||||
for value in values:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
def _session_cookie(self):
|
||||
return Handler._cookie_value(self, Handler.remote_session_cookie_name)
|
||||
|
||||
def _remote_cookie_bootstrap_response(self, parsed):
|
||||
client_host = Handler._effective_client_host(self)
|
||||
if client_address_is_local(client_host):
|
||||
return False
|
||||
if not remote_access_allowed() or not remote_access_token_configured():
|
||||
return False
|
||||
if Handler._cookie_token(self):
|
||||
return False
|
||||
token = Handler._query_token(self)
|
||||
if not token or not remote_access_token_matches(token):
|
||||
return False
|
||||
def _remote_credentials_valid(self):
|
||||
session = Handler._session_cookie(self)
|
||||
if session and remote_access_session_matches(session):
|
||||
return True
|
||||
username, password = Handler._basic_auth_credentials(self)
|
||||
return remote_access_credentials_match(username, password)
|
||||
|
||||
def _normalized_next_path(self, value):
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
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.pop("token", None)
|
||||
params.pop("remote_token", None)
|
||||
query = urlencode(params, doseq=True)
|
||||
location = urlunparse(("", "", parsed.path or "/", "", query, ""))
|
||||
cookie = (
|
||||
f"{Handler.remote_token_cookie_name}={token}; "
|
||||
"Path=/; HttpOnly; SameSite=Lax"
|
||||
return urlunparse(("", "", parsed.path or "/", "", query, ""))
|
||||
|
||||
def _remote_auth_cookie(self, session_value):
|
||||
attributes = (
|
||||
f"{Handler.remote_session_cookie_name}={session_value}; "
|
||||
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}"
|
||||
)
|
||||
if Handler._request_is_secure(self):
|
||||
attributes += "; Secure"
|
||||
return attributes
|
||||
|
||||
def _clear_remote_auth_cookie(self):
|
||||
attributes = (
|
||||
f"{Handler.remote_session_cookie_name}=; "
|
||||
"Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||
)
|
||||
if Handler._request_is_secure(self):
|
||||
attributes += "; Secure"
|
||||
return attributes
|
||||
|
||||
def _set_remote_auth_cookie_and_redirect(self, session_value, location):
|
||||
Handler.write_response_bytes(
|
||||
self,
|
||||
b"",
|
||||
HTTPStatus.SEE_OTHER,
|
||||
{
|
||||
"Location": location,
|
||||
"Set-Cookie": cookie,
|
||||
"Location": Handler._normalized_next_path(self, location),
|
||||
"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>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||
<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 <base64(username:password)></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
|
||||
|
||||
def ensure_client_access(self):
|
||||
@@ -194,31 +594,88 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise PermissionError(
|
||||
"Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients."
|
||||
)
|
||||
if not remote_access_token_configured():
|
||||
if not remote_access_credentials_configured():
|
||||
raise PermissionError(
|
||||
"Remote access requires ANI_CLI_WEB_REMOTE_TOKEN for non-local clients."
|
||||
"Remote access requires ANI_CLI_WEB_AUTH_USERNAME and ANI_CLI_WEB_AUTH_PASSWORD for non-local clients."
|
||||
)
|
||||
if not remote_access_token_matches(Handler._remote_token(self)):
|
||||
raise PermissionError("Remote access token missing or invalid.")
|
||||
if not Handler._remote_credentials_valid(self):
|
||||
raise PermissionError("Remote username or password missing or invalid.")
|
||||
|
||||
def enforce_same_origin(self):
|
||||
origin = Handler._origin(self)
|
||||
if not origin:
|
||||
if Handler._session_cookie(self):
|
||||
raise PermissionError("Browser API requests must include a same-origin Origin header.")
|
||||
return
|
||||
if not Handler._origin_matches_host(self):
|
||||
raise PermissionError("Cross-origin browser requests are not allowed.")
|
||||
|
||||
def _remote_path_roots(self):
|
||||
return configured_remote_path_roots(Handler._context(self).get_config_snapshot())
|
||||
|
||||
def validate_remote_path(self, path_value, label):
|
||||
client_host = Handler._effective_client_host(self)
|
||||
if client_address_is_local(client_host):
|
||||
return
|
||||
roots = Handler._remote_path_roots(self)
|
||||
if not roots:
|
||||
raise PermissionError(f"Remote {label} changes are disabled until a server-side path root is configured.")
|
||||
if not path_is_within_roots(path_value, roots):
|
||||
raise PermissionError(f"Remote {label} must stay inside the configured remote path roots.")
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
if Handler._remote_cookie_bootstrap_response(self, parsed):
|
||||
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
|
||||
self.ensure_client_access()
|
||||
if parsed.path == "/":
|
||||
self.html(Handler._context(self).index_html)
|
||||
elif parsed.path in {"/favicon.png", "/favicon.ico"}:
|
||||
if not FAVICON_PATH.exists():
|
||||
self.error(HTTPStatus.NOT_FOUND, "Favicon not found")
|
||||
return
|
||||
self.file(FAVICON_PATH)
|
||||
elif parsed.path == "/queue":
|
||||
self.html(Handler._context(self).queue_html)
|
||||
elif parsed.path == "/config":
|
||||
self.html(Handler._context(self).config_html)
|
||||
elif parsed.path == "/watchlist":
|
||||
self.html(Handler._context(self).watchlist_html)
|
||||
elif parsed.path == "/api/config":
|
||||
self.json(Handler._context(self).get_config_snapshot())
|
||||
self.json(sanitize_public_config(Handler._context(self).get_config_snapshot()))
|
||||
elif parsed.path == "/api/version":
|
||||
self.json({"name": APP_NAME, "version": VERSION})
|
||||
self.json(
|
||||
{
|
||||
"name": APP_NAME,
|
||||
"version": VERSION,
|
||||
"ani_cli_version": installed_ani_cli_version(),
|
||||
"anipy_cli_version": installed_anipy_cli_version(),
|
||||
"animdl_version": installed_animdl_version(),
|
||||
}
|
||||
)
|
||||
elif parsed.path == "/api/changelog":
|
||||
self.json({"content": load_project_text_file(CHANGELOG_FILE, default="Changelog unavailable.")})
|
||||
elif parsed.path == "/api/dependencies":
|
||||
self.json(dependency_status())
|
||||
elif parsed.path == "/api/fs/browse":
|
||||
params = parse_qs(parsed.query)
|
||||
client_host = Handler._effective_client_host(self)
|
||||
allowed_roots = None if client_address_is_local(client_host) else Handler._remote_path_roots(self)
|
||||
self.json(
|
||||
browse_filesystem(
|
||||
(params.get("path") or [""])[0],
|
||||
(params.get("mode") or ["dir"])[0],
|
||||
allowed_roots=allowed_roots,
|
||||
)
|
||||
)
|
||||
elif parsed.path == "/api/search":
|
||||
runtime = Handler._runtime(self)
|
||||
config = runtime["config"]
|
||||
@@ -230,6 +687,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if mode not in MODE_CHOICES:
|
||||
raise ValueError("Mode must be sub or dub")
|
||||
self.json({"results": Handler._context(self).search_anime(query, mode)})
|
||||
elif parsed.path == "/api/search/thumb":
|
||||
Handler._runtime(self)
|
||||
params = parse_qs(parsed.query)
|
||||
title = (params.get("title") or [""])[0].strip()
|
||||
if not title:
|
||||
raise ValueError("Title is required")
|
||||
path = Handler._context(self).resolve_search_thumbnail(title)
|
||||
self.file(path)
|
||||
elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"):
|
||||
runtime = Handler._runtime(self)
|
||||
config = runtime["config"]
|
||||
@@ -244,7 +709,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
params = parse_qs(parsed.query)
|
||||
page = (params.get("page") or ["1"])[0]
|
||||
per_page = (params.get("per_page") or ["10"])[0]
|
||||
self.json(runtime["download_queue"].list(page=page, per_page=per_page))
|
||||
self.json(queue_list_payload(runtime, 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(queue_get_job(runtime, job_id))
|
||||
elif parsed.path.startswith("/api/watchlist/thumb/"):
|
||||
runtime = Handler._runtime(self)
|
||||
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
|
||||
@@ -257,6 +730,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
debug_log("thumbnail.route.hit", show_id=show_id, resolved_path=path)
|
||||
self.file(path)
|
||||
elif parsed.path == "/api/watchlist/homepage":
|
||||
Handler._runtime(self)
|
||||
self.json(Handler._context(self).get_watchlist_homepage_summary())
|
||||
elif parsed.path in {"/api/watchlist", "/api/watchlist/get"}:
|
||||
Handler._runtime(self)
|
||||
params = parse_qs(parsed.query)
|
||||
@@ -267,6 +743,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif parsed.path == "/api/watchlist/refresh-status":
|
||||
Handler._runtime(self)
|
||||
self.json(Handler._context(self).get_watchlist_refresh_status())
|
||||
elif parsed.path == "/api/config/jellyfin/sync-status":
|
||||
Handler._runtime(self)
|
||||
self.json(Handler._context(self).get_jellyfin_sync_status())
|
||||
else:
|
||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||
except Exception as exc:
|
||||
@@ -275,18 +754,58 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
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()
|
||||
Handler.enforce_same_origin(self)
|
||||
if parsed.path == "/api/config":
|
||||
Handler.update_config(self, Handler.require_json_object(self, self.body_json()))
|
||||
elif parsed.path == "/api/config/jellyfin/sync":
|
||||
payload = Handler.require_json_object(self, self.body_json())
|
||||
current = Handler._context(self).get_config_snapshot()
|
||||
merged = dict(current)
|
||||
merged.update(payload)
|
||||
for key, label in (
|
||||
("download_dir", "download directory"),
|
||||
("jellyfin_tv_dir", "Jellyfin TV directory"),
|
||||
("jellyfin_movie_dir", "Jellyfin movie directory"),
|
||||
):
|
||||
if str(merged.get(key) or "").strip():
|
||||
Handler.validate_remote_path(self, merged[key], label)
|
||||
self.json(Handler._context(self).start_jellyfin_sync(merged), HTTPStatus.ACCEPTED)
|
||||
elif parsed.path == "/api/config/webhook/test":
|
||||
payload = Handler.require_json_object(self, self.body_json())
|
||||
validate_discord_webhook_url(payload.get("discord_webhook_url"))
|
||||
self.json(Handler._context(self).test_discord_webhook_config(payload))
|
||||
elif parsed.path == "/api/queue":
|
||||
runtime = Handler._runtime(self)
|
||||
self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED)
|
||||
payload = Handler.require_json_object(self, self.body_json())
|
||||
if str(payload.get("download_dir") or "").strip():
|
||||
Handler.validate_remote_path(self, payload["download_dir"], "download directory")
|
||||
self.json(runtime["download_queue"].add(payload), HTTPStatus.CREATED)
|
||||
elif parsed.path == "/api/queue/retry-failed":
|
||||
runtime = Handler._runtime(self)
|
||||
self.json(runtime["download_queue"].retry_all_failed())
|
||||
elif parsed.path == "/api/queue/clear-finished":
|
||||
runtime = Handler._runtime(self)
|
||||
self.json(runtime["download_queue"].clear_finished())
|
||||
self.json(queue_clear_finished(runtime))
|
||||
elif parsed.path == "/api/queue/clear-failed":
|
||||
runtime = Handler._runtime(self)
|
||||
self.json(queue_clear_failed(runtime))
|
||||
elif parsed.path.startswith("/api/queue/"):
|
||||
runtime = Handler._runtime(self)
|
||||
parts = parsed.path.strip("/").split("/")
|
||||
@@ -297,7 +816,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
actions = {
|
||||
"retry": runtime["download_queue"].retry,
|
||||
"cancel": runtime["download_queue"].cancel,
|
||||
"remove": runtime["download_queue"].remove,
|
||||
"remove": lambda job_id: queue_remove_job(runtime, job_id),
|
||||
}
|
||||
if action not in actions:
|
||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||
@@ -322,10 +841,32 @@ class Handler(BaseHTTPRequestHandler):
|
||||
Handler._runtime(self)
|
||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||
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":
|
||||
Handler._runtime(self)
|
||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||
self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category")))
|
||||
elif parsed.path == "/api/watchlist/update-media-type":
|
||||
Handler._runtime(self)
|
||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||
self.json(Handler._context(self).update_watchlist_media_type(payload["show_id"], payload.get("media_type")))
|
||||
elif parsed.path == "/api/watchlist/update-auto-download":
|
||||
Handler._runtime(self)
|
||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||
self.json(
|
||||
Handler._context(self).update_watchlist_auto_download(
|
||||
payload["show_id"],
|
||||
payload.get("mode"),
|
||||
payload.get("quality"),
|
||||
payload.get("name"),
|
||||
payload.get("source_name"),
|
||||
payload.get("series"),
|
||||
payload.get("episode_offset"),
|
||||
)
|
||||
)
|
||||
elif parsed.path == "/api/watchlist/update-all":
|
||||
Handler._runtime(self)
|
||||
result = Handler._context(self).update_all_watchlist_statuses()
|
||||
@@ -345,11 +886,24 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.exception(exc)
|
||||
|
||||
def update_config(self, payload):
|
||||
config = normalize_config(payload)
|
||||
payload = Handler.require_json_object(self, payload)
|
||||
current = Handler._context(self).get_config_snapshot()
|
||||
merged = dict(current)
|
||||
merged.update(payload)
|
||||
config = normalize_config(merged)
|
||||
for key, label in (
|
||||
("download_dir", "download directory"),
|
||||
("jellyfin_tv_dir", "Jellyfin TV directory"),
|
||||
("jellyfin_movie_dir", "Jellyfin movie directory"),
|
||||
):
|
||||
if str(config.get(key) or "").strip():
|
||||
Handler.validate_remote_path(self, config[key], label)
|
||||
if "discord_webhook_url" in payload and str(config.get("discord_webhook_url") or "").strip():
|
||||
validate_discord_webhook_url(config["discord_webhook_url"])
|
||||
Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
|
||||
context = Handler._context(self)
|
||||
context.save_runtime_config(config)
|
||||
self.json(context.get_config_snapshot())
|
||||
context.save_runtime_config(payload)
|
||||
self.json(sanitize_public_config(context.get_config_snapshot()))
|
||||
|
||||
def require_fields(self, payload, *names):
|
||||
payload = Handler.require_json_object(self, payload)
|
||||
@@ -390,6 +944,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise ValueError(f"Field '{name}' must be a boolean")
|
||||
|
||||
def body_json(self):
|
||||
content_type = Handler._content_type(self).split(";", 1)[0].strip().lower()
|
||||
if content_type != "application/json":
|
||||
raise ValueError("Request body must use Content-Type: application/json.")
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
except ValueError as exc:
|
||||
@@ -410,6 +967,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except json.JSONDecodeError as 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):
|
||||
data = content.encode("utf-8")
|
||||
self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=utf-8"})
|
||||
@@ -456,6 +1030,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif isinstance(exc, KeyError):
|
||||
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
|
||||
elif isinstance(exc, PermissionError):
|
||||
parsed = urlparse(getattr(self, "path", "") or "/")
|
||||
if remote_access_allowed() and remote_access_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))
|
||||
elif isinstance(exc, ValueError):
|
||||
self.error(HTTPStatus.BAD_REQUEST, str(exc))
|
||||
@@ -465,6 +1043,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
traceback.print_exc()
|
||||
else:
|
||||
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.")
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
|
||||
+809
-31
File diff suppressed because it is too large
Load Diff
+580
@@ -0,0 +1,580 @@
|
||||
#!/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>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||
<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="clearFailedBtn" type="button">Clear 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) {
|
||||
if (job.job_type === "jellyfin_handoff") {
|
||||
const progress = `${job.completed || 0}/${job.total || 0} entries`;
|
||||
const moved = `${job.moved || 0} moved`;
|
||||
const files = `${job.moved_files || 0} files`;
|
||||
const target = job.target_dir || "Jellyfin libraries";
|
||||
return `${progress} · ${moved} · ${files} · ${target}`;
|
||||
}
|
||||
const libraryName = job.anime_name || job.title;
|
||||
const 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.job_type === "jellyfin_handoff") {
|
||||
if (job.status !== "running" && job.status !== "pending") {
|
||||
actions.append(actionButton("Remove", () => queueAction(job.id, "remove")));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (job.status === "running" || job.status === "pending") {
|
||||
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 || "Untitled job";
|
||||
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 || job.exit_code === undefined ? "" : `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"));
|
||||
$("clearFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-failed", "Removed failed 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>
|
||||
"""
|
||||
+189
-220
@@ -2,7 +2,7 @@
|
||||
|
||||
"""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>
|
||||
@@ -11,6 +11,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ani-cli web</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -135,9 +136,9 @@ INDEX_HTML = r"""<!doctype html>
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
}
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 {
|
||||
@@ -183,18 +184,18 @@ INDEX_HTML = r"""<!doctype html>
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.page-links {
|
||||
display: flex;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.page-link {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
@@ -232,47 +233,127 @@ INDEX_HTML = r"""<!doctype html>
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.results, .queue {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.result, .job, .settings, .episodes-panel, .deps {
|
||||
.settings, .episodes-panel, .results-panel, .deps {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.result {
|
||||
.settings, .episodes-panel, .results-panel, .deps {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.results-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.result-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(32, 24, 54, 0.9) 0%, rgba(17, 13, 29, 0.96) 100%);
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
min-height: auto;
|
||||
min-width: 0;
|
||||
box-shadow: 0 18px 38px rgba(5, 2, 15, 0.32);
|
||||
text-align: left;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.result:hover {
|
||||
.result-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
transform: translateY(-1px);
|
||||
background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.result.active {
|
||||
border-color: rgba(157, 123, 255, 0.34);
|
||||
.result-card.active {
|
||||
border-color: rgba(157, 123, 255, 0.58);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(157, 123, 255, 0.16),
|
||||
0 18px 38px rgba(5, 2, 15, 0.32);
|
||||
background: linear-gradient(180deg, rgba(48, 36, 80, 0.96), rgba(20, 15, 35, 0.96));
|
||||
}
|
||||
.cover-tile {
|
||||
aspect-ratio: 3 / 4;
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(157, 123, 255, 0.16);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(157, 123, 255, 0.28), transparent 44%),
|
||||
linear-gradient(180deg, #221734 0%, #0c0915 100%);
|
||||
}
|
||||
.cover-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
.cover-tile.has-image img {
|
||||
opacity: 1;
|
||||
}
|
||||
.cover-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #d8e5ef;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(180deg, rgba(157, 123, 255, 0.24), rgba(9, 7, 15, 0.18));
|
||||
}
|
||||
.cover-tile.has-image .cover-fallback {
|
||||
display: none;
|
||||
}
|
||||
.result-title {
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
min-height: 2.7em;
|
||||
}
|
||||
.result-subtitle {
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
min-height: 1.3em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.result-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings, .episodes-panel, .deps {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -291,57 +372,6 @@ INDEX_HTML = r"""<!doctype html>
|
||||
}
|
||||
.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); }
|
||||
.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 {
|
||||
border: 1px dashed rgba(157, 123, 255, 0.22);
|
||||
border-radius: 22px;
|
||||
@@ -355,16 +385,28 @@ INDEX_HTML = r"""<!doctype html>
|
||||
color: #ddd0ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.results-panel .toolbar {
|
||||
align-items: end;
|
||||
}
|
||||
.statline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.results-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 1160px) {
|
||||
.results-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; }
|
||||
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.controls, .row, .toolbar { align-items: stretch; flex-direction: column; }
|
||||
.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>
|
||||
</head>
|
||||
@@ -398,7 +440,6 @@ INDEX_HTML = r"""<!doctype html>
|
||||
<button class="primary" id="searchBtn" type="button">Search</button>
|
||||
</div>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="results" id="results"></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
@@ -417,6 +458,12 @@ INDEX_HTML = r"""<!doctype html>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="min-width: 160px;">Media type
|
||||
<select id="trackMediaType">
|
||||
<option value="tv">TV</option>
|
||||
<option value="movie">Movie</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="ghost" id="trackBtn" type="button" disabled>Add to watchlist</button>
|
||||
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
|
||||
</div>
|
||||
@@ -446,17 +493,14 @@ INDEX_HTML = r"""<!doctype html>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section class="results-panel">
|
||||
<div class="toolbar">
|
||||
<h2>Download queue</h2>
|
||||
<div class="row" style="flex:0 0 auto">
|
||||
<button class="ghost" id="retryFailedBtn" type="button">Retry failed</button>
|
||||
<button class="ghost" id="removeFinishedBtn" type="button">Remove finished</button>
|
||||
<div>
|
||||
<h2>Select results</h2>
|
||||
<p class="muted" id="resultsMeta">Poster cards appear here after a search.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue" id="queue"></div>
|
||||
<div class="toolbar" id="queuePager"></div>
|
||||
<div class="results-grid" id="results"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
@@ -466,17 +510,12 @@ INDEX_HTML = r"""<!doctype html>
|
||||
config: { mode: "sub", quality: "best", download_dir: "" },
|
||||
selected: null,
|
||||
episodes: [],
|
||||
queuePage: 1,
|
||||
queuePages: 1,
|
||||
queueTotal: 0,
|
||||
queueTimer: null,
|
||||
queueRequestId: 0,
|
||||
searchRequestId: 0,
|
||||
episodeRequestId: 0
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r"""
|
||||
""" + BROWSER_API_HELPER + r"""
|
||||
|
||||
function setNotice(text) {
|
||||
const notice = $("notice");
|
||||
@@ -488,31 +527,77 @@ INDEX_HTML = r"""<!doctype html>
|
||||
$("search-mode").value = mode;
|
||||
}
|
||||
|
||||
function coverInitials(title) {
|
||||
const parts = String(title || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
if (!parts.length) return "AN";
|
||||
return parts.map((part) => part[0]).join("").slice(0, 2);
|
||||
}
|
||||
|
||||
function resultThumbnailUrl(title) {
|
||||
return `/api/search/thumb?title=${encodeURIComponent(title || "")}`;
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const el = $("results");
|
||||
el.innerHTML = "";
|
||||
if (!results.length) {
|
||||
el.innerHTML = '<div class="empty">No matching anime found.</div>';
|
||||
$("resultsMeta").textContent = "No matching anime found.";
|
||||
return;
|
||||
}
|
||||
for (const item of results) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "result";
|
||||
btn.className = "result-card";
|
||||
btn.innerHTML = `
|
||||
<span class="result-title"></span>
|
||||
<span class="muted"></span>
|
||||
<div class="cover-tile">
|
||||
<img alt="" loading="lazy" decoding="async">
|
||||
<span class="cover-fallback"></span>
|
||||
</div>
|
||||
<div class="result-title"></div>
|
||||
<div class="result-subtitle"></div>
|
||||
<div class="result-meta">
|
||||
<span class="result-pill"></span>
|
||||
<span class="result-pill"></span>
|
||||
</div>
|
||||
`;
|
||||
btn.querySelector(".result-title").textContent = item.title;
|
||||
btn.querySelector(".muted").textContent = `${item.episodes} episodes · result ${item.index}`;
|
||||
const subtitle = btn.querySelector(".result-subtitle");
|
||||
const alternativeTitle = Array.isArray(item.alternative_titles) && item.alternative_titles.length
|
||||
? String(item.alternative_titles[0].value || "").trim()
|
||||
: "";
|
||||
subtitle.textContent = alternativeTitle ? `Also known as: ${alternativeTitle}` : "";
|
||||
subtitle.hidden = !alternativeTitle;
|
||||
btn.querySelector(".cover-fallback").textContent = coverInitials(item.title);
|
||||
const pills = btn.querySelectorAll(".result-pill");
|
||||
pills[0].textContent = `${item.episodes} episodes`;
|
||||
pills[1].textContent = `Result ${item.index}`;
|
||||
const img = btn.querySelector("img");
|
||||
const tile = btn.querySelector(".cover-tile");
|
||||
img.alt = item.title;
|
||||
img.src = resultThumbnailUrl(item.title);
|
||||
if (img.complete && img.naturalWidth > 0) {
|
||||
tile.classList.add("has-image");
|
||||
}
|
||||
img.addEventListener("load", () => {
|
||||
if (img.naturalWidth > 0) tile.classList.add("has-image");
|
||||
});
|
||||
img.addEventListener("error", () => {
|
||||
tile.classList.remove("has-image");
|
||||
});
|
||||
btn.addEventListener("click", () => selectResult(item, btn));
|
||||
el.appendChild(btn);
|
||||
}
|
||||
$("resultsMeta").textContent = `${results.length} matching anime`;
|
||||
}
|
||||
|
||||
async function selectResult(item, button) {
|
||||
const requestId = ++state.episodeRequestId;
|
||||
document.querySelectorAll(".result").forEach((node) => node.classList.remove("active"));
|
||||
document.querySelectorAll(".result-card").forEach((node) => node.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
state.selected = item;
|
||||
state.episodes = [];
|
||||
@@ -558,6 +643,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
if (!query) return setNotice("Type a title first.");
|
||||
setMode($("search-mode").value);
|
||||
setNotice("Searching...");
|
||||
$("resultsMeta").textContent = "Searching...";
|
||||
state.selected = null;
|
||||
state.episodeRequestId += 1;
|
||||
$("addBtn").disabled = true;
|
||||
@@ -571,6 +657,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
} catch (error) {
|
||||
if (requestId !== state.searchRequestId) return;
|
||||
setNotice(error.message);
|
||||
$("resultsMeta").textContent = "Search failed.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,7 +672,9 @@ INDEX_HTML = r"""<!doctype html>
|
||||
body: JSON.stringify({
|
||||
show_id: state.selected.id,
|
||||
title: state.selected.title,
|
||||
category: $("trackCategory").value
|
||||
category: $("trackCategory").value,
|
||||
media_type: $("trackMediaType").value,
|
||||
auto_download_series: $("seasonInput").value || "1"
|
||||
})
|
||||
});
|
||||
setNotice(data.message || "Added to watchlist.");
|
||||
@@ -598,11 +687,11 @@ INDEX_HTML = r"""<!doctype html>
|
||||
if (!state.selected) return;
|
||||
const payload = {
|
||||
show_id: state.selected.id,
|
||||
query: state.selected.query,
|
||||
query: state.selected.title,
|
||||
title: state.selected.title,
|
||||
anime_name: $("animeNameInput").value || state.selected.title,
|
||||
media_type: $("trackMediaType").value,
|
||||
season: $("seasonInput").value || "1",
|
||||
index: state.selected.index,
|
||||
mode: state.config.mode,
|
||||
quality: $("search-quality").value,
|
||||
episodes: $("episodesInput").value,
|
||||
@@ -610,128 +699,12 @@ INDEX_HTML = r"""<!doctype html>
|
||||
};
|
||||
try {
|
||||
await api("/api/queue", { method: "POST", body: JSON.stringify(payload) });
|
||||
setNotice("Added to queue.");
|
||||
await loadQueue();
|
||||
setNotice("Added to queue. Open Queue to monitor progress.");
|
||||
} catch (error) {
|
||||
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() {
|
||||
state.config = await api("/api/config");
|
||||
$("jobFolder").value = state.config.download_dir;
|
||||
@@ -746,8 +719,6 @@ INDEX_HTML = r"""<!doctype html>
|
||||
$("search-mode").addEventListener("change", (event) => setMode(event.target.value));
|
||||
$("trackBtn").addEventListener("click", addSelectedToWatchlist);
|
||||
$("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; });
|
||||
$("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; });
|
||||
$("latestEpBtn").addEventListener("click", () => {
|
||||
@@ -765,9 +736,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
const query = (params.get("query") || "").trim();
|
||||
if (query) $("search-query").value = query;
|
||||
await loadConfig();
|
||||
await loadQueue();
|
||||
if (query) await search();
|
||||
state.queueTimer = startSerialPoll(() => loadQueue(), 2500);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@
|
||||
|
||||
PAGE_LINKS = (
|
||||
("search", "Search", "/"),
|
||||
("queue", "Queue", "/queue"),
|
||||
("config", "Config", "/config"),
|
||||
("watchlist", "Watchlist", "/watchlist"),
|
||||
)
|
||||
@@ -85,4 +86,4 @@ def render_page_links(active_page):
|
||||
for key, label, href in PAGE_LINKS:
|
||||
class_name = "page-link active" if key == active_page else "page-link"
|
||||
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"
|
||||
|
||||
+2439
-66
File diff suppressed because it is too large
Load Diff
+22
-5
@@ -45,8 +45,12 @@ def resolve_job_watchlist_identity(
|
||||
title = str(job.get("title") or job.get("anime_name") or "").strip()
|
||||
if show_id:
|
||||
return _resolution_result(show_id, title, "", return_reason)
|
||||
raw_index = job.get("result_index")
|
||||
if raw_index in (None, ""):
|
||||
index = None
|
||||
else:
|
||||
try:
|
||||
index = max(1, int(job.get("result_index") or 1))
|
||||
index = max(1, int(raw_index))
|
||||
except (TypeError, ValueError):
|
||||
return _resolution_result("", title, "invalid queued result index", return_reason)
|
||||
query = str(job.get("query") or title).strip()
|
||||
@@ -56,13 +60,26 @@ def resolve_job_watchlist_identity(
|
||||
results = search_fn(query, job.get("mode") or default_mode)
|
||||
except Exception as exc:
|
||||
return _resolution_result("", title, f"search fallback failed: {exc}", return_reason)
|
||||
if index > len(results):
|
||||
return _resolution_result("", title, "queued result index no longer exists in search results", return_reason)
|
||||
result = results[index - 1]
|
||||
matches = []
|
||||
for position, result in enumerate(results, start=1):
|
||||
candidate_title = str(result.get("title") or "").strip()
|
||||
if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn):
|
||||
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()
|
||||
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:
|
||||
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)
|
||||
|
||||
+457
-15
@@ -11,6 +11,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ani-cli web - watchlist</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -150,18 +151,18 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
max-width: 28ch;
|
||||
}
|
||||
.page-links {
|
||||
display: flex;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.page-link {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
@@ -303,10 +304,19 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
box-shadow: 0 18px 38px rgba(5, 2, 15, 0.32);
|
||||
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 {
|
||||
border-color: var(--line-strong);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.anime-card.fully-ready:hover {
|
||||
border-color: rgba(149, 228, 186, 0.88);
|
||||
}
|
||||
.anime-card.error {
|
||||
border-color: rgba(255, 144, 164, 0.3);
|
||||
}
|
||||
@@ -492,10 +502,133 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
line-height: 1.35;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.alt-titles-toggle {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
}
|
||||
.alt-titles-modal {
|
||||
width: min(640px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(20, 27, 40, 0.98), rgba(12, 17, 28, 0.98));
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
||||
padding: 18px;
|
||||
}
|
||||
.alt-titles-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.alt-titles-modal-header p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.alt-titles-modal-close {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.alt-titles-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.alt-title-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(157, 123, 255, 0.08);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
}
|
||||
.alt-title-pill strong {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.category-editor {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(7, 10, 16, 0.74);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1200;
|
||||
}
|
||||
.auto-download-modal {
|
||||
width: min(640px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(20, 27, 40, 0.98), rgba(12, 17, 28, 0.98));
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
||||
padding: 18px;
|
||||
}
|
||||
.auto-download-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.auto-download-modal-header p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.auto-download-modal-close {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.auto-download-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.auto-download-grid label {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.auto-download-grid .wide-half {
|
||||
grid-column: span 3;
|
||||
}
|
||||
.auto-download-modal label {
|
||||
font-size: 10px;
|
||||
}
|
||||
.auto-download-modal input,
|
||||
.auto-download-modal select {
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.auto-download-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.category-editor label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -507,13 +640,22 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
}
|
||||
.card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.card-actions button {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.download-picker {
|
||||
display: none;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.download-picker.visible {
|
||||
display: grid;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -549,6 +691,9 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.watchlist-grid { grid-template-columns: 1fr; }
|
||||
.auto-download-grid { grid-template-columns: 1fr; }
|
||||
.auto-download-grid label,
|
||||
.auto-download-grid .wide-half { grid-column: span 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -583,6 +728,12 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Media type
|
||||
<select id="watchlist-media-type">
|
||||
<option value="tv">TV</option>
|
||||
<option value="movie">Movie</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="field-hint">Tip: you can also add entries directly from the Search page.</div>
|
||||
<button class="primary" type="submit">Add to watchlist</button>
|
||||
</form>
|
||||
@@ -592,7 +743,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
<h2>How it works</h2>
|
||||
<div class="stack muted">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -639,6 +790,67 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="altTitlesOverlay" hidden>
|
||||
<div class="alt-titles-modal" role="dialog" aria-modal="true" aria-labelledby="altTitlesTitle">
|
||||
<div class="alt-titles-modal-header">
|
||||
<div>
|
||||
<h2 id="altTitlesTitle">Alternative titles</h2>
|
||||
<p class="muted" id="altTitlesSubtitle">AniDB English titles for the selected show.</p>
|
||||
</div>
|
||||
<button class="ghost alt-titles-modal-close" id="altTitlesCloseBtn" type="button" aria-label="Close alternative titles">×</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">×</button>
|
||||
</div>
|
||||
<div class="auto-download-grid">
|
||||
<label>Language
|
||||
<select class="auto-download-mode" id="autoDownloadMode">
|
||||
<option value="dub">Dub</option>
|
||||
<option value="sub">Sub</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Quality
|
||||
<select class="auto-download-quality" id="autoDownloadQuality">
|
||||
<option value="best">Best</option>
|
||||
<option value="1080">1080p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="480">480p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Series
|
||||
<input class="auto-download-series" id="autoDownloadSeries" type="number" min="1" step="1" value="1">
|
||||
</label>
|
||||
<label>Episode offset
|
||||
<input class="auto-download-offset" id="autoDownloadOffset" type="number" min="1" step="1" placeholder="Optional">
|
||||
</label>
|
||||
<label class="wide-half">Source name
|
||||
<input class="auto-download-source-name" id="autoDownloadSourceName" type="text">
|
||||
</label>
|
||||
<label class="wide-half">Name source
|
||||
<select class="auto-download-name-select" id="autoDownloadNameSelect"></select>
|
||||
</label>
|
||||
<label class="wide-half">Name
|
||||
<input class="auto-download-name" id="autoDownloadName" type="text">
|
||||
</label>
|
||||
</div>
|
||||
<div class="auto-download-actions">
|
||||
<div class="muted" id="autoDownloadStatus">Manual add does not trigger it; later refreshes do.</div>
|
||||
<button class="ghost auto-download-save" id="autoDownloadSaveBtn" type="button">Save auto-download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
watchlistPage: 1,
|
||||
@@ -652,7 +864,9 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
thumbnailWarmupPending: new Set(),
|
||||
refreshRunning: false,
|
||||
refreshJobId: null,
|
||||
watchlistRequestId: 0
|
||||
watchlistRequestId: 0,
|
||||
altTitlesDraft: null,
|
||||
autoDownloadDraft: null
|
||||
};
|
||||
const categoryLabels = {
|
||||
watching: "Watching",
|
||||
@@ -660,6 +874,27 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
finished: "Finished",
|
||||
dropped: "Dropped"
|
||||
};
|
||||
const mediaTypeLabels = {
|
||||
tv: "TV",
|
||||
movie: "Movie"
|
||||
};
|
||||
const autoDownloadOverlay = document.getElementById("autoDownloadOverlay");
|
||||
const altTitlesOverlay = document.getElementById("altTitlesOverlay");
|
||||
const altTitlesCloseBtn = document.getElementById("altTitlesCloseBtn");
|
||||
const altTitlesSubtitle = document.getElementById("altTitlesSubtitle");
|
||||
const altTitlesList = document.getElementById("altTitlesList");
|
||||
const altTitlesEmpty = document.getElementById("altTitlesEmpty");
|
||||
const autoDownloadCloseBtn = document.getElementById("autoDownloadCloseBtn");
|
||||
const autoDownloadSubtitle = document.getElementById("autoDownloadSubtitle");
|
||||
const autoDownloadStatus = document.getElementById("autoDownloadStatus");
|
||||
const autoDownloadMode = document.getElementById("autoDownloadMode");
|
||||
const autoDownloadQuality = document.getElementById("autoDownloadQuality");
|
||||
const autoDownloadSeries = document.getElementById("autoDownloadSeries");
|
||||
const autoDownloadOffset = document.getElementById("autoDownloadOffset");
|
||||
const autoDownloadSourceName = document.getElementById("autoDownloadSourceName");
|
||||
const autoDownloadNameSelect = document.getElementById("autoDownloadNameSelect");
|
||||
const autoDownloadName = document.getElementById("autoDownloadName");
|
||||
const autoDownloadSaveBtn = document.getElementById("autoDownloadSaveBtn");
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
""" + BROWSER_API_HELPER + BROWSER_POLL_HELPER + r"""
|
||||
@@ -699,6 +934,11 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
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) {
|
||||
const current = Number(currentCount || 0);
|
||||
const expected = Number(expectedCount || 0);
|
||||
@@ -719,6 +959,12 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
.join("");
|
||||
}
|
||||
|
||||
function mediaTypeOptions(selected) {
|
||||
return Object.entries(mediaTypeLabels)
|
||||
.map(([value, label]) => `<option value="${value}"${value === selected ? " selected" : ""}>${label}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
const totals = summary || { shows: 0, sub: 0, dub: 0 };
|
||||
const cards = $("watchlist-summary").querySelectorAll(".summary-card");
|
||||
@@ -774,6 +1020,146 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
controls.append(prev, next);
|
||||
}
|
||||
|
||||
function closeAltTitlesOverlay() {
|
||||
state.altTitlesDraft = null;
|
||||
altTitlesOverlay.hidden = true;
|
||||
}
|
||||
|
||||
function openAltTitlesOverlay(item) {
|
||||
state.altTitlesDraft = { ...item };
|
||||
altTitlesSubtitle.textContent = `${item.title} · AniDB English titles for the selected show.`;
|
||||
altTitlesList.innerHTML = "";
|
||||
const alternativeTitles = Array.isArray(item.alternative_titles) ? item.alternative_titles : [];
|
||||
if (alternativeTitles.length) {
|
||||
for (const title of alternativeTitles) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "alt-title-pill";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = title.label;
|
||||
const value = document.createElement("span");
|
||||
value.textContent = title.value;
|
||||
pill.append(label, value);
|
||||
altTitlesList.appendChild(pill);
|
||||
}
|
||||
altTitlesEmpty.textContent = "Fetched from AniDB English titles.";
|
||||
altTitlesList.hidden = false;
|
||||
} else {
|
||||
altTitlesEmpty.textContent = "AniDB English alternative titles were not available for this show.";
|
||||
altTitlesList.hidden = true;
|
||||
}
|
||||
altTitlesEmpty.hidden = false;
|
||||
altTitlesOverlay.hidden = false;
|
||||
}
|
||||
|
||||
altTitlesCloseBtn.addEventListener("click", closeAltTitlesOverlay);
|
||||
altTitlesOverlay.addEventListener("click", (event) => {
|
||||
if (event.target === altTitlesOverlay) {
|
||||
closeAltTitlesOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
function closeAutoDownloadOverlay() {
|
||||
state.autoDownloadDraft = null;
|
||||
autoDownloadOverlay.hidden = true;
|
||||
}
|
||||
|
||||
function populateAutoDownloadNameChoices(item) {
|
||||
const selectedName = item.auto_download_name || "";
|
||||
const nameSuggestions = [];
|
||||
for (const name of [item.title, ...(Array.isArray(item.alternative_titles) ? item.alternative_titles.map((entry) => entry.value) : [])]) {
|
||||
if (name && !nameSuggestions.includes(name)) nameSuggestions.push(name);
|
||||
}
|
||||
autoDownloadNameSelect.innerHTML = "";
|
||||
for (const name of nameSuggestions) {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
autoDownloadNameSelect.appendChild(option);
|
||||
}
|
||||
const customOption = document.createElement("option");
|
||||
customOption.value = "__custom__";
|
||||
customOption.textContent = "Custom";
|
||||
autoDownloadNameSelect.appendChild(customOption);
|
||||
const matchedSuggestion = nameSuggestions.find((name) => name === selectedName);
|
||||
autoDownloadNameSelect.value = matchedSuggestion || "__custom__";
|
||||
autoDownloadName.value = selectedName;
|
||||
}
|
||||
|
||||
function openAutoDownloadOverlay(item) {
|
||||
state.autoDownloadDraft = { ...item };
|
||||
autoDownloadSubtitle.textContent = `${item.title} · Used only while this anime is in the Watching tab. Manual add does not trigger it; later refreshes do.`;
|
||||
autoDownloadStatus.textContent = "Manual add does not trigger it; later refreshes do.";
|
||||
autoDownloadMode.value = item.auto_download_mode || "dub";
|
||||
autoDownloadQuality.value = item.auto_download_quality || "best";
|
||||
autoDownloadSeries.value = item.auto_download_series || "1";
|
||||
autoDownloadOffset.value = item.auto_download_offset || "";
|
||||
autoDownloadSourceName.value = item.auto_download_source_name || item.title || "";
|
||||
populateAutoDownloadNameChoices(item);
|
||||
autoDownloadOverlay.hidden = false;
|
||||
}
|
||||
|
||||
autoDownloadNameSelect.addEventListener("change", () => {
|
||||
if (autoDownloadNameSelect.value !== "__custom__") {
|
||||
autoDownloadName.value = autoDownloadNameSelect.value;
|
||||
}
|
||||
});
|
||||
autoDownloadCloseBtn.addEventListener("click", closeAutoDownloadOverlay);
|
||||
autoDownloadOverlay.addEventListener("click", (event) => {
|
||||
if (event.target === autoDownloadOverlay) {
|
||||
closeAutoDownloadOverlay();
|
||||
}
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !altTitlesOverlay.hidden) {
|
||||
closeAltTitlesOverlay();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && !autoDownloadOverlay.hidden) {
|
||||
closeAutoDownloadOverlay();
|
||||
}
|
||||
});
|
||||
autoDownloadSaveBtn.addEventListener("click", async () => {
|
||||
if (!state.autoDownloadDraft) return;
|
||||
autoDownloadSaveBtn.disabled = true;
|
||||
try {
|
||||
const data = await api("/api/watchlist/update-auto-download", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
show_id: state.autoDownloadDraft.show_id,
|
||||
mode: autoDownloadMode.value,
|
||||
quality: autoDownloadQuality.value,
|
||||
name: autoDownloadName.value,
|
||||
source_name: autoDownloadSourceName.value,
|
||||
series: autoDownloadSeries.value,
|
||||
episode_offset: autoDownloadOffset.value
|
||||
})
|
||||
});
|
||||
const savedItem = data.item || {};
|
||||
state.autoDownloadDraft = {
|
||||
...state.autoDownloadDraft,
|
||||
auto_download_mode: savedItem.auto_download_mode || autoDownloadMode.value,
|
||||
auto_download_quality: savedItem.auto_download_quality || autoDownloadQuality.value,
|
||||
auto_download_name: savedItem.auto_download_name || autoDownloadName.value,
|
||||
auto_download_source_name: savedItem.auto_download_source_name || autoDownloadSourceName.value,
|
||||
auto_download_series: savedItem.auto_download_series || autoDownloadSeries.value,
|
||||
auto_download_offset: savedItem.auto_download_offset || ""
|
||||
};
|
||||
populateAutoDownloadNameChoices(state.autoDownloadDraft);
|
||||
autoDownloadSeries.value = state.autoDownloadDraft.auto_download_series || autoDownloadSeries.value;
|
||||
autoDownloadOffset.value = state.autoDownloadDraft.auto_download_offset || "";
|
||||
autoDownloadSourceName.value = state.autoDownloadDraft.auto_download_source_name || autoDownloadSourceName.value;
|
||||
autoDownloadStatus.textContent = data.message || "Auto-download settings saved.";
|
||||
setNotice(data.message || "Auto-download settings saved.");
|
||||
closeAutoDownloadOverlay();
|
||||
await fetchWatchlist(state.watchlistPage, { silent: true });
|
||||
} catch (error) {
|
||||
autoDownloadStatus.textContent = error.message;
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
autoDownloadSaveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function applyThumbnail(card, item, force = false) {
|
||||
const tile = card.querySelector(".cover-tile");
|
||||
const img = card.querySelector("img");
|
||||
@@ -965,7 +1351,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
for (const item of items) {
|
||||
const card = document.createElement("article");
|
||||
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.title = item.thumbnail_debug || "Thumbnail source: unresolved";
|
||||
card.innerHTML = `
|
||||
@@ -985,16 +1371,25 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
<span class="count-pill${item.dub_complete ? " complete" : ""}">Dub <strong>${formatModeProgress(item.dub_count, item.expected_count)}</strong></span>
|
||||
</div>
|
||||
<div class="status-row"></div>
|
||||
<button class="ghost alt-titles-toggle" type="button">Alternative titles</button>
|
||||
<div class="category-editor">
|
||||
<label>Category
|
||||
<select class="category-select">${categoryOptions(item.category)}</select>
|
||||
</label>
|
||||
<label>Media
|
||||
<select class="media-type-select">${mediaTypeOptions(item.media_type || "tv")}</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="muted card-date"></div>
|
||||
<div class="card-actions">
|
||||
<button class="primary" type="button">Refresh</button>
|
||||
<button class="ghost" type="button">Open</button>
|
||||
<button class="danger" type="button">Remove</button>
|
||||
<button class="ghost" type="button">Download all</button>
|
||||
<button class="ghost" type="button">Auto-download</button>
|
||||
<div class="download-picker">
|
||||
<button class="ghost" type="button">Sub</button>
|
||||
<button class="ghost" type="button">Dub</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
card.querySelector(".cover-fallback").textContent = coverInitials(item.title);
|
||||
@@ -1013,6 +1408,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
}
|
||||
const statusRow = card.querySelector(".status-row");
|
||||
appendStatusTag(statusRow, item.category_label || categoryLabels[item.category] || "Watching");
|
||||
appendStatusTag(statusRow, item.media_type_label || mediaTypeLabels[item.media_type] || "TV");
|
||||
if (item.airing_status) {
|
||||
appendStatusTag(statusRow, item.airing_status);
|
||||
}
|
||||
@@ -1022,6 +1418,8 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
if (item.dub_complete) {
|
||||
appendStatusTag(statusRow, "Dub ready", "complete");
|
||||
}
|
||||
const altTitlesToggle = card.querySelector(".alt-titles-toggle");
|
||||
altTitlesToggle.addEventListener("click", () => openAltTitlesOverlay(item));
|
||||
card.querySelector(".card-date").textContent = item.status_message
|
||||
? `Last checked: ${checked}\n${item.status_message}`
|
||||
: `Last checked: ${checked}`;
|
||||
@@ -1037,9 +1435,14 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
});
|
||||
const actionButtons = card.querySelector(".card-actions").querySelectorAll("button");
|
||||
const refreshBtn = actionButtons[0];
|
||||
const searchBtn = actionButtons[1];
|
||||
const removeBtn = actionButtons[2];
|
||||
const removeBtn = actionButtons[1];
|
||||
const downloadBtn = actionButtons[2];
|
||||
const autoDownloadBtn = actionButtons[3];
|
||||
const subBtn = actionButtons[4];
|
||||
const dubBtn = actionButtons[5];
|
||||
const downloadPicker = card.querySelector(".download-picker");
|
||||
const categorySelect = card.querySelector(".category-select");
|
||||
const mediaTypeSelect = card.querySelector(".media-type-select");
|
||||
categorySelect.addEventListener("change", async () => {
|
||||
categorySelect.disabled = true;
|
||||
try {
|
||||
@@ -1056,10 +1459,32 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
categorySelect.disabled = false;
|
||||
}
|
||||
});
|
||||
refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id));
|
||||
searchBtn.addEventListener("click", () => {
|
||||
window.location.href = `/?query=${encodeURIComponent(item.title || item.show_id)}`;
|
||||
mediaTypeSelect.addEventListener("change", async () => {
|
||||
mediaTypeSelect.disabled = true;
|
||||
try {
|
||||
const data = await api("/api/watchlist/update-media-type", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ show_id: item.show_id, media_type: mediaTypeSelect.value })
|
||||
});
|
||||
setNotice(data.message || "Media type updated.");
|
||||
await fetchWatchlist(state.watchlistPage);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
mediaTypeSelect.value = item.media_type || "tv";
|
||||
} finally {
|
||||
mediaTypeSelect.disabled = false;
|
||||
}
|
||||
});
|
||||
refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id));
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
const visible = downloadPicker.classList.toggle("visible");
|
||||
downloadBtn.textContent = visible ? "Cancel download" : "Download all";
|
||||
});
|
||||
autoDownloadBtn.addEventListener("click", () => {
|
||||
openAutoDownloadOverlay(item);
|
||||
});
|
||||
subBtn.addEventListener("click", () => downloadWatchlistItem(item, "sub", downloadPicker, downloadBtn));
|
||||
dubBtn.addEventListener("click", () => downloadWatchlistItem(item, "dub", downloadPicker, downloadBtn));
|
||||
removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title));
|
||||
container.appendChild(card);
|
||||
applyThumbnail(card, item, false);
|
||||
@@ -1129,11 +1554,13 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
body: JSON.stringify({
|
||||
show_id: showId,
|
||||
title,
|
||||
category: $("watchlist-category").value
|
||||
category: $("watchlist-category").value,
|
||||
media_type: $("watchlist-media-type").value
|
||||
})
|
||||
});
|
||||
$("watchlist-form").reset();
|
||||
$("watchlist-category").value = "watching";
|
||||
$("watchlist-media-type").value = "tv";
|
||||
setNotice(data.message || "Added to watchlist.");
|
||||
await fetchWatchlist();
|
||||
} catch (error) {
|
||||
@@ -1154,6 +1581,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) {
|
||||
if (!window.confirm(`Remove ${title || "this anime"} from the watchlist?`)) {
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"""Aggregated template exports for ani-cli-web."""
|
||||
|
||||
from config_page import CONFIG_HTML
|
||||
from queue_page import QUEUE_HTML
|
||||
from search_page import INDEX_HTML
|
||||
from template_helpers import PAGE_LINKS, render_page_links, render_sidebar_brand
|
||||
from watchlist_page import WATCHLIST_HTML
|
||||
@@ -11,6 +12,7 @@ __all__ = [
|
||||
"CONFIG_HTML",
|
||||
"INDEX_HTML",
|
||||
"PAGE_LINKS",
|
||||
"QUEUE_HTML",
|
||||
"WATCHLIST_HTML",
|
||||
"render_page_links",
|
||||
"render_sidebar_brand",
|
||||
|
||||
Reference in New Issue
Block a user