Archived
Compare commits
6
Commits
ce8ca1d4fb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f417a96811 | ||
|
|
edb565d855 | ||
|
|
32fc0f2829 | ||
|
|
141dcd9c70 | ||
|
|
8c62060d4a | ||
|
|
629cf8dcb6 |
@@ -3,5 +3,5 @@
|
|||||||
- when needed you may install any tools using pip if the project reqires it
|
- when needed you may install any tools using pip if the project reqires it
|
||||||
- you can freely acces any LAN URL and github.com URL.
|
- you can freely acces any LAN URL and github.com URL.
|
||||||
- always update README.md to reflect current project.
|
- always update README.md to reflect current project.
|
||||||
- always commit changes with commentary
|
- always commit changes with commentary and push
|
||||||
- always make sure that update wont wipe ani data in a previosly running instance like configuration or watchlist content
|
- always make sure that update wont wipe ani data in a previosly running instance like configuration or watchlist content
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.50.2 - 2026-08-01
|
||||||
|
|
||||||
|
- Re-ran queue job preparation when retrying failed or canceled downloads, letting retries repair missing `ani-cli` result selections before launching.
|
||||||
|
- Added result-index inference for older or watchlist-created jobs by matching the stored show ID, or a single confident title match, against current provider search results.
|
||||||
|
|
||||||
|
## 0.50.1 - 2026-08-01
|
||||||
|
|
||||||
|
- Passed the selected Search result number through queued `ani-cli` jobs as `-S`, so downloads avoid the interactive series picker when multiple similar titles are returned.
|
||||||
|
- Preserved the selected result index in queued jobs for more reliable watchlist identity sync after ambiguous downloads.
|
||||||
|
|
||||||
|
## 0.50.0 - 2026-08-01
|
||||||
|
|
||||||
|
- Added `curl-impersonate` to Docker images using the maintained `lexiforest/curl-impersonate` image so `ani-cli` v5 can use browser-like curl wrappers before falling back to plain `curl`.
|
||||||
|
- Exposed `curl-impersonate` availability in the Config page dependency status.
|
||||||
|
- Updated Docker/runtime documentation for the new `ani-cli` v5 Cloudflare workaround.
|
||||||
|
|
||||||
|
## 0.49.9 - 2026-07-26
|
||||||
|
|
||||||
|
- Changed the Config page `ani-cli` version probe to run with `ANI_CLI_PLAYER=download`, avoiding false missing-player failures on headless installs without `mpv` or `vlc`.
|
||||||
|
- Added regression coverage for the player-safe `ani-cli --version` environment.
|
||||||
|
|
||||||
|
## 0.49.8 - 2026-07-21
|
||||||
|
|
||||||
|
- Forced queued `ani-cli` attempts into explicit download-player mode with `ANI_CLI_PLAYER=download`, preventing headless Docker jobs from failing the upstream player check when `mpv` or `vlc` are not installed.
|
||||||
|
- Expanded fallback regression coverage for the `ani-cli` download-mode environment.
|
||||||
|
|
||||||
## 0.49.7 - 2026-07-21
|
## 0.49.7 - 2026-07-21
|
||||||
|
|
||||||
- Ignored the local `_backup_/` folder in git and Docker build contexts so temporary backups do not get committed or copied into images.
|
- Ignored the local `_backup_/` folder in git and Docker build contexts so temporary backups do not get committed or copied into images.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
FROM lexiforest/curl-impersonate:latest AS curl-impersonate
|
||||||
|
|
||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
ARG ANI_CLI=https://github.com/pystardust/ani-cli.git
|
ARG ANI_CLI=https://github.com/pystardust/ani-cli.git
|
||||||
@@ -22,6 +24,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
aria2 \
|
aria2 \
|
||||||
|
bash \
|
||||||
botan \
|
botan \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
@@ -32,6 +35,7 @@ RUN apt-get update \
|
|||||||
grep \
|
grep \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
libxslt1-dev \
|
libxslt1-dev \
|
||||||
|
libstdc++6 \
|
||||||
openssl \
|
openssl \
|
||||||
patch \
|
patch \
|
||||||
python3.11 \
|
python3.11 \
|
||||||
@@ -41,6 +45,11 @@ RUN apt-get update \
|
|||||||
util-linux \
|
util-linux \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=curl-impersonate /usr/local /usr/local
|
||||||
|
|
||||||
|
RUN curl_firefox135 --version \
|
||||||
|
&& curl_chrome136 --version
|
||||||
|
|
||||||
RUN git clone --depth 1 --branch "${ANI_CLI_BRANCH}" "${ANI_CLI}" /tmp/ani-cli-src \
|
RUN git clone --depth 1 --branch "${ANI_CLI_BRANCH}" "${ANI_CLI}" /tmp/ani-cli-src \
|
||||||
&& install -m 0755 /tmp/ani-cli-src/ani-cli /usr/local/bin/ani-cli \
|
&& install -m 0755 /tmp/ani-cli-src/ani-cli /usr/local/bin/ani-cli \
|
||||||
&& ani_cli_raw_repo="$(printf '%s' "${ANI_CLI}" | sed -E 's#^https://github.com/##; s#\\.git$##')" \
|
&& ani_cli_raw_repo="$(printf '%s' "${ANI_CLI}" | sed -E 's#^https://github.com/##; s#\\.git$##')" \
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
Local web UI for a system-wide `ani-cli` install.
|
Local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.49.7`
|
Current version: `0.50.2`
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- Search anime in `sub` or `dub`, with poster-style results shown below the selection panel and the first English alternate title when available
|
- Search anime in `sub` or `dub`, with poster-style results shown below the selection panel and the first English alternate title when available
|
||||||
- Queue downloads with folder, quality, media type, season, and episode controls
|
- Queue downloads with folder, quality, media type, season, episode controls, and the selected upstream search result when a Search card is used
|
||||||
- Choose which download backends queue jobs may use: `ani-cli`, `anipy-cli`, and `animdl`
|
- Choose which download backends queue jobs may use: `ani-cli`, `anipy-cli`, and `animdl`
|
||||||
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
|
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
|
||||||
- Expose a small JSON summary endpoint for Homepage dashboard widgets
|
- Expose a small JSON summary endpoint for Homepage dashboard widgets
|
||||||
@@ -30,7 +30,7 @@ Requirements:
|
|||||||
|
|
||||||
- `ani-cli` installed and available in `PATH`
|
- `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
|
- 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`
|
- Common runtime tools used by `ani-cli`, especially `curl` or `curl-impersonate`, `ffmpeg`, `fzf`, `grep`, `openssl`, `sed`, `aria2c`, `botan`, and `yt-dlp`
|
||||||
|
|
||||||
Run locally:
|
Run locally:
|
||||||
|
|
||||||
@@ -90,6 +90,7 @@ Docker notes:
|
|||||||
- `USER_UID` and `USER_GID` help keep mounted files owned by your host user
|
- `USER_UID` and `USER_GID` help keep mounted files owned by your host user
|
||||||
- `UPDATE_ON_START=true` runs `ani-cli --update` before startup
|
- `UPDATE_ON_START=true` runs `ani-cli --update` before startup
|
||||||
- `ANI_CLI_WEB_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
|
- `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
|
||||||
|
- `curl-impersonate` is installed inside the container and exposes the browser wrapper commands used by `ani-cli` v5, such as `curl_firefox135` and `curl_chrome136`, so provider requests can avoid Cloudflare blocks that affect plain `curl`
|
||||||
- `botan` is installed inside the container for providers or scripts that need Botan cryptography tooling
|
- `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
|
- `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
|
||||||
|
|
||||||
@@ -117,6 +118,10 @@ Docker notes:
|
|||||||
- Queue jobs can use any checked combination of `ani-cli`, `anipy-cli`, and `animdl`
|
- 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`
|
- 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
|
- Selecting one method disables fallback retry; selecting multiple methods makes later methods automatic fallbacks
|
||||||
|
- Queue `ani-cli` downloads explicitly run with `ANI_CLI_PLAYER=download` so headless Docker installs do not need `mpv` or `vlc` just to save files
|
||||||
|
- Docker images include `curl-impersonate` wrappers before plain `curl` on `PATH`, matching the preference order used by `ani-cli` v5
|
||||||
|
- Queue jobs created from Search pass the selected result number to `ani-cli -S`, avoiding the interactive series picker when the provider returns multiple similar titles
|
||||||
|
- Retried failed jobs are prepared again before running, so older or watchlist-created jobs can infer and persist an `ani-cli -S` result when the stored show ID or title maps cleanly to a provider search result
|
||||||
- Fallback retries keep the queued episode numbers when fallback downloader filenames are renumbered from `1`
|
- 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
|
- Docker images install `anipy-cli` and `animdl` automatically; non-Docker installs should provide the selected tools in `PATH` themselves
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ This endpoint is intentionally small so Homepage can map the returned values dir
|
|||||||
The Config page runtime panel shows:
|
The Config page runtime panel shows:
|
||||||
|
|
||||||
- `ani-cli-web` version loaded from `VERSION`
|
- `ani-cli-web` version loaded from `VERSION`
|
||||||
- Installed `ani-cli` version
|
- Installed `ani-cli` version, checked with download-player mode so headless installs do not need `mpv` or `vlc` for runtime info
|
||||||
- Installed `anipy-cli` and `animdl` versions and dependency status
|
- Installed `anipy-cli` and `animdl` versions and dependency status
|
||||||
- A `Changelog` button that opens a scrollable viewer backed by `CHANGELOG.md`
|
- A `Changelog` button that opens a scrollable viewer backed by `CHANGELOG.md`
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from app_support import (
|
|||||||
normalize_config,
|
normalize_config,
|
||||||
normalize_episode_offset,
|
normalize_episode_offset,
|
||||||
normalize_media_type,
|
normalize_media_type,
|
||||||
|
normalize_result_index,
|
||||||
now_iso,
|
now_iso,
|
||||||
remote_access_session_fingerprint,
|
remote_access_session_fingerprint,
|
||||||
sanitize_path_component,
|
sanitize_path_component,
|
||||||
@@ -74,7 +75,10 @@ from title_matching import (
|
|||||||
title_lookup_queries,
|
title_lookup_queries,
|
||||||
title_match_variants,
|
title_match_variants,
|
||||||
)
|
)
|
||||||
from watchlist_identity import resolve_job_watchlist_identity as resolve_job_watchlist_identity_impl
|
from watchlist_identity import (
|
||||||
|
resolve_job_watchlist_identity as resolve_job_watchlist_identity_impl,
|
||||||
|
titles_confidently_match,
|
||||||
|
)
|
||||||
from web_templates import CONFIG_HTML, INDEX_HTML, PAGE_LINKS, QUEUE_HTML, WATCHLIST_HTML, render_page_links, render_sidebar_brand
|
from web_templates import CONFIG_HTML, INDEX_HTML, PAGE_LINKS, QUEUE_HTML, WATCHLIST_HTML, render_page_links, render_sidebar_brand
|
||||||
|
|
||||||
RUNTIME_LOCK = threading.RLock()
|
RUNTIME_LOCK = threading.RLock()
|
||||||
@@ -3007,6 +3011,10 @@ def sync_downloaded_job_to_watchlist(job):
|
|||||||
|
|
||||||
def prepare_download_job(job):
|
def prepare_download_job(job):
|
||||||
config = ensure_runtime()["config"]
|
config = ensure_runtime()["config"]
|
||||||
|
if normalize_result_index(job.get("result_index")) is None:
|
||||||
|
result_index = infer_download_job_result_index(job, config)
|
||||||
|
if result_index is not None:
|
||||||
|
job["result_index"] = result_index
|
||||||
show_id, title, reason = resolve_job_watchlist_identity_impl(
|
show_id, title, reason = resolve_job_watchlist_identity_impl(
|
||||||
job,
|
job,
|
||||||
search_fn=search_anime,
|
search_fn=search_anime,
|
||||||
@@ -3024,6 +3032,36 @@ def prepare_download_job(job):
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def infer_download_job_result_index(job, config):
|
||||||
|
query = str(job.get("query") or job.get("title") or job.get("anime_name") or "").strip()
|
||||||
|
mode = str(job.get("mode") or config.get("mode") or "sub").strip().lower()
|
||||||
|
if not query or mode not in MODE_CHOICES:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
results = search_anime(query, mode)
|
||||||
|
except Exception as exc:
|
||||||
|
debug_log("watchlist.prepare_job.result_index_search_failed", job=job, error=exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
show_id = str(job.get("show_id") or "").strip()
|
||||||
|
if show_id:
|
||||||
|
for position, result in enumerate(results, start=1):
|
||||||
|
if str(result.get("id") or "").strip() == show_id:
|
||||||
|
return position
|
||||||
|
|
||||||
|
title = str(job.get("title") or job.get("anime_name") or query).strip()
|
||||||
|
matches = []
|
||||||
|
for position, result in enumerate(results, start=1):
|
||||||
|
candidate_title = str(result.get("title") or "").strip()
|
||||||
|
if titles_confidently_match(title, candidate_title, normalize_identity_title_key, base_title_variants):
|
||||||
|
matches.append(position)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
if len(matches) > 1:
|
||||||
|
debug_log("watchlist.prepare_job.result_index_ambiguous", job=job, matches=matches)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_config_snapshot(fallback=None):
|
def get_config_snapshot(fallback=None):
|
||||||
with RUNTIME_LOCK:
|
with RUNTIME_LOCK:
|
||||||
source = CONFIG if CONFIG is not None else fallback
|
source = CONFIG if CONFIG is not None else fallback
|
||||||
|
|||||||
+16
-1
@@ -1216,7 +1216,7 @@ def build_job(payload, config):
|
|||||||
"season": normalize_season(payload.get("season")),
|
"season": normalize_season(payload.get("season")),
|
||||||
"episode_offset": normalize_episode_offset(payload.get("episode_offset")),
|
"episode_offset": normalize_episode_offset(payload.get("episode_offset")),
|
||||||
"query": query,
|
"query": query,
|
||||||
"result_index": None,
|
"result_index": normalize_result_index(payload.get("result_index")),
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"quality": quality,
|
"quality": quality,
|
||||||
"episodes": validate_episode_spec(payload.get("episodes")),
|
"episodes": validate_episode_spec(payload.get("episodes")),
|
||||||
@@ -1232,6 +1232,18 @@ def build_job(payload, config):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_result_index(value):
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
index = int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if index < 1:
|
||||||
|
return None
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
def anipy_search_spec(job):
|
def anipy_search_spec(job):
|
||||||
query = re.sub(r"\s+", " ", str(job.get("query") or "").replace(":", " ")).strip()
|
query = re.sub(r"\s+", " ", str(job.get("query") or "").replace(":", " ")).strip()
|
||||||
mode = "dub" if str(job.get("mode") or "").strip().lower() == "dub" else "sub"
|
mode = "dub" if str(job.get("mode") or "").strip().lower() == "dub" else "sub"
|
||||||
@@ -1276,6 +1288,9 @@ def command_for_job(job, backend="ani-cli", download_path=None):
|
|||||||
"-e",
|
"-e",
|
||||||
job["episodes"],
|
job["episodes"],
|
||||||
]
|
]
|
||||||
|
result_index = normalize_result_index(job.get("result_index"))
|
||||||
|
if result_index is not None:
|
||||||
|
command.extend(["-S", str(result_index)])
|
||||||
if job["mode"] == "dub":
|
if job["mode"] == "dub":
|
||||||
command.append("--dub")
|
command.append("--dub")
|
||||||
command.append(job["query"])
|
command.append(job["query"])
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ def build_handler_class(context):
|
|||||||
def dependency_status():
|
def dependency_status():
|
||||||
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
|
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
|
||||||
result = {name: bool(shutil.which(name)) for name in checks}
|
result = {name: bool(shutil.which(name)) for name in checks}
|
||||||
|
result["curl-impersonate"] = cli_executable_exists_any(
|
||||||
|
"curl_firefox135",
|
||||||
|
"curl_chrome136",
|
||||||
|
"curl_chrome116",
|
||||||
|
"curl_ff117",
|
||||||
|
)
|
||||||
result["ani-cli"] = cli_executable_exists(ANI_CLI)
|
result["ani-cli"] = cli_executable_exists(ANI_CLI)
|
||||||
result["anipy-cli"] = cli_executable_exists_any(
|
result["anipy-cli"] = cli_executable_exists_any(
|
||||||
ANIPY_CLI,
|
ANIPY_CLI,
|
||||||
@@ -239,6 +245,11 @@ def installed_ani_cli_version():
|
|||||||
[ANI_CLI, "--version"],
|
[ANI_CLI, "--version"],
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
env={
|
||||||
|
**os.environ.copy(),
|
||||||
|
"ANI_CLI_PLAYER": "download",
|
||||||
|
"TERM": os.environ.get("TERM", "xterm-256color"),
|
||||||
|
},
|
||||||
text=True,
|
text=True,
|
||||||
timeout=6,
|
timeout=6,
|
||||||
)
|
)
|
||||||
|
|||||||
+15
-14
@@ -1258,12 +1258,15 @@ class DownloadQueue:
|
|||||||
self._save_job_locked(job)
|
self._save_job_locked(job)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
def _prepare_job(self, job):
|
||||||
|
if self.job_prepare_fn is None:
|
||||||
|
return job
|
||||||
|
prepared = self.job_prepare_fn(dict(job))
|
||||||
|
return prepared if prepared is not None else job
|
||||||
|
|
||||||
def add(self, payload):
|
def add(self, payload):
|
||||||
job = build_job(payload, self.config_getter())
|
job = build_job(payload, self.config_getter())
|
||||||
if self.job_prepare_fn is not None:
|
job = self._prepare_job(job)
|
||||||
prepared = self.job_prepare_fn(dict(job))
|
|
||||||
if prepared is not None:
|
|
||||||
job = prepared
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
reusable = self._find_reusable_failed_job_locked(job)
|
reusable = self._find_reusable_failed_job_locked(job)
|
||||||
if reusable is not None:
|
if reusable is not None:
|
||||||
@@ -1347,24 +1350,21 @@ class DownloadQueue:
|
|||||||
raise ValueError("Running jobs cannot be retried")
|
raise ValueError("Running jobs cannot be retried")
|
||||||
if job["status"] not in {"failed", "canceled"}:
|
if job["status"] not in {"failed", "canceled"}:
|
||||||
raise ValueError("Only failed or canceled jobs can be retried")
|
raise ValueError("Only failed or canceled jobs can be retried")
|
||||||
|
job = self._prepare_job(job)
|
||||||
|
with self.lock:
|
||||||
job = self._reset_retryable_job_locked(job)
|
job = self._reset_retryable_job_locked(job)
|
||||||
self.wakeup.set()
|
self.wakeup.set()
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def retry_all_failed(self):
|
def retry_all_failed(self):
|
||||||
count = 0
|
count = 0
|
||||||
with self.lock, self._connect() as conn:
|
with self.lock:
|
||||||
|
with self._connect() as conn:
|
||||||
rows = conn.execute("SELECT * FROM jobs WHERE status = 'failed'").fetchall()
|
rows = conn.execute("SELECT * FROM jobs WHERE status = 'failed'").fetchall()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
job = self._job_from_row(row)
|
job = self._prepare_job(self._job_from_row(row))
|
||||||
job["status"] = "pending"
|
with self.lock:
|
||||||
job["exit_code"] = None
|
self._reset_retryable_job_locked(job)
|
||||||
job["pid"] = None
|
|
||||||
job["started_at"] = None
|
|
||||||
job["finished_at"] = None
|
|
||||||
job["updated_at"] = now_iso()
|
|
||||||
job["log"] = []
|
|
||||||
self._upsert_job_conn(conn, job)
|
|
||||||
count += 1
|
count += 1
|
||||||
if count:
|
if count:
|
||||||
self.wakeup.set()
|
self.wakeup.set()
|
||||||
@@ -1518,6 +1518,7 @@ class DownloadQueue:
|
|||||||
"env": {
|
"env": {
|
||||||
**os.environ.copy(),
|
**os.environ.copy(),
|
||||||
"ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
|
"ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
|
||||||
|
"ANI_CLI_PLAYER": "download",
|
||||||
"ANI_CLI_MODE": job["mode"],
|
"ANI_CLI_MODE": job["mode"],
|
||||||
"ANI_CLI_QUALITY": job["quality"],
|
"ANI_CLI_QUALITY": job["quality"],
|
||||||
"TERM": os.environ.get("TERM", "xterm-256color"),
|
"TERM": os.environ.get("TERM", "xterm-256color"),
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
const payload = {
|
const payload = {
|
||||||
show_id: state.selected.id,
|
show_id: state.selected.id,
|
||||||
query: state.selected.title,
|
query: state.selected.title,
|
||||||
|
result_index: state.selected.index,
|
||||||
title: state.selected.title,
|
title: state.selected.title,
|
||||||
anime_name: $("animeNameInput").value || state.selected.title,
|
anime_name: $("animeNameInput").value || state.selected.title,
|
||||||
media_type: $("trackMediaType").value,
|
media_type: $("trackMediaType").value,
|
||||||
|
|||||||
+134
-3
@@ -532,6 +532,66 @@ class QueueApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(retried["status"], "pending")
|
self.assertEqual(retried["status"], "pending")
|
||||||
self.assertFalse(retried["cancel_requested"])
|
self.assertFalse(retried["cancel_requested"])
|
||||||
|
|
||||||
|
def test_retry_prepares_failed_job_before_requeue(self):
|
||||||
|
queue = APP.DownloadQueue(
|
||||||
|
lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
|
||||||
|
job_prepare_fn=lambda job: {**job, "result_index": 3},
|
||||||
|
start_worker=False,
|
||||||
|
)
|
||||||
|
created = queue.add(
|
||||||
|
{
|
||||||
|
"query": "Retry Show",
|
||||||
|
"title": "Retry Show",
|
||||||
|
"anime_name": "Retry Show",
|
||||||
|
"mode": "sub",
|
||||||
|
"quality": "best",
|
||||||
|
"episodes": "1",
|
||||||
|
"download_dir": "/tmp/example",
|
||||||
|
"season": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with queue.lock:
|
||||||
|
job = queue._find(created["id"])
|
||||||
|
job["status"] = "failed"
|
||||||
|
job["result_index"] = None
|
||||||
|
queue._save_job_locked(job)
|
||||||
|
|
||||||
|
retried = queue.retry(created["id"])
|
||||||
|
|
||||||
|
self.assertEqual(retried["status"], "pending")
|
||||||
|
self.assertEqual(retried["result_index"], 3)
|
||||||
|
|
||||||
|
def test_retry_all_failed_prepares_jobs_before_requeue(self):
|
||||||
|
queue = APP.DownloadQueue(
|
||||||
|
lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
|
||||||
|
job_prepare_fn=lambda job: {**job, "result_index": 4},
|
||||||
|
start_worker=False,
|
||||||
|
)
|
||||||
|
created = queue.add(
|
||||||
|
{
|
||||||
|
"query": "Retry All Show",
|
||||||
|
"title": "Retry All Show",
|
||||||
|
"anime_name": "Retry All Show",
|
||||||
|
"mode": "sub",
|
||||||
|
"quality": "best",
|
||||||
|
"episodes": "1",
|
||||||
|
"download_dir": "/tmp/example",
|
||||||
|
"season": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with queue.lock:
|
||||||
|
job = queue._find(created["id"])
|
||||||
|
job["status"] = "failed"
|
||||||
|
job["result_index"] = None
|
||||||
|
queue._save_job_locked(job)
|
||||||
|
|
||||||
|
result = queue.retry_all_failed()
|
||||||
|
retried = queue.get(created["id"])
|
||||||
|
|
||||||
|
self.assertEqual(result["count"], 1)
|
||||||
|
self.assertEqual(retried["status"], "pending")
|
||||||
|
self.assertEqual(retried["result_index"], 4)
|
||||||
|
|
||||||
def test_add_reuses_matching_failed_job_instead_of_creating_duplicate(self):
|
def test_add_reuses_matching_failed_job_instead_of_creating_duplicate(self):
|
||||||
payload = {
|
payload = {
|
||||||
"show_id": "show-42",
|
"show_id": "show-42",
|
||||||
@@ -1094,6 +1154,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
||||||
commands = []
|
commands = []
|
||||||
|
envs = []
|
||||||
|
|
||||||
class FakeProc:
|
class FakeProc:
|
||||||
def __init__(self, pid, stdout, exit_code):
|
def __init__(self, pid, stdout, exit_code):
|
||||||
@@ -1112,6 +1173,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
|
|
||||||
def fake_popen(command, **kwargs):
|
def fake_popen(command, **kwargs):
|
||||||
commands.append(command)
|
commands.append(command)
|
||||||
|
envs.append(kwargs.get("env", {}))
|
||||||
return processes.pop(0)
|
return processes.pop(0)
|
||||||
|
|
||||||
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object(
|
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object(
|
||||||
@@ -1125,6 +1187,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
self.assertEqual(stored["status"], "done")
|
self.assertEqual(stored["status"], "done")
|
||||||
self.assertTrue(stored["fallback_used"])
|
self.assertTrue(stored["fallback_used"])
|
||||||
self.assertEqual([command[0] for command in commands], [APP.app_support.ANI_CLI, APP.app_support.ANIPY_CLI, APP.app_support.ANIMDL])
|
self.assertEqual([command[0] for command in commands], [APP.app_support.ANI_CLI, APP.app_support.ANIPY_CLI, APP.app_support.ANIMDL])
|
||||||
|
self.assertEqual(envs[0]["ANI_CLI_PLAYER"], "download")
|
||||||
self.assertIn("Fallback download completed with animdl.", stored["log"])
|
self.assertIn("Fallback download completed with animdl.", stored["log"])
|
||||||
|
|
||||||
|
|
||||||
@@ -1884,6 +1947,36 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertEqual(prepared["show_id"], "show-42")
|
self.assertEqual(prepared["show_id"], "show-42")
|
||||||
self.assertEqual(prepared["title"], "Queue Show")
|
self.assertEqual(prepared["title"], "Queue Show")
|
||||||
|
|
||||||
|
def test_prepare_download_job_infers_result_index_from_show_id(self):
|
||||||
|
job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": "show-99"}
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
APP,
|
||||||
|
"search_anime",
|
||||||
|
return_value=[
|
||||||
|
{"id": "show-42", "title": "Queue Show"},
|
||||||
|
{"id": "show-99", "title": "Queue Show Season 4"},
|
||||||
|
],
|
||||||
|
):
|
||||||
|
prepared = APP.prepare_download_job(dict(job))
|
||||||
|
|
||||||
|
self.assertEqual(prepared["result_index"], 2)
|
||||||
|
|
||||||
|
def test_prepare_download_job_infers_result_index_from_unique_title_match(self):
|
||||||
|
job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""}
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
APP,
|
||||||
|
"search_anime",
|
||||||
|
return_value=[
|
||||||
|
{"id": "show-42", "title": "Queue Show"},
|
||||||
|
{"id": "show-99", "title": "Different Show"},
|
||||||
|
],
|
||||||
|
):
|
||||||
|
prepared = APP.prepare_download_job(dict(job))
|
||||||
|
|
||||||
|
self.assertEqual(prepared["result_index"], 1)
|
||||||
|
|
||||||
def test_prepare_download_job_accepts_equivalent_season_notation(self):
|
def test_prepare_download_job_accepts_equivalent_season_notation(self):
|
||||||
job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""}
|
job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""}
|
||||||
|
|
||||||
@@ -1940,7 +2033,7 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertNotIn("-S", APP.app_support.command_for_job(job))
|
self.assertNotIn("-S", APP.app_support.command_for_job(job))
|
||||||
|
|
||||||
def test_command_for_job_ignores_legacy_result_index(self):
|
def test_command_for_job_includes_result_switch_for_selected_search_result(self):
|
||||||
command = APP.app_support.command_for_job(
|
command = APP.app_support.command_for_job(
|
||||||
{
|
{
|
||||||
"query": "Queue Show",
|
"query": "Queue Show",
|
||||||
@@ -1951,6 +2044,20 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.assertIn("-S", command)
|
||||||
|
self.assertEqual(command[command.index("-S") + 1], "2")
|
||||||
|
|
||||||
|
def test_command_for_job_ignores_invalid_result_index(self):
|
||||||
|
command = APP.app_support.command_for_job(
|
||||||
|
{
|
||||||
|
"query": "Queue Show",
|
||||||
|
"quality": "best",
|
||||||
|
"episodes": "1-10",
|
||||||
|
"mode": "dub",
|
||||||
|
"result_index": "nope",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.assertNotIn("-S", command)
|
self.assertNotIn("-S", command)
|
||||||
|
|
||||||
def test_command_for_job_builds_anipy_fallback_search_spec(self):
|
def test_command_for_job_builds_anipy_fallback_search_spec(self):
|
||||||
@@ -3316,10 +3423,28 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
self.assertIn("anipy_cli_version", handler.json_payload)
|
self.assertIn("anipy_cli_version", handler.json_payload)
|
||||||
self.assertIn("animdl_version", handler.json_payload)
|
self.assertIn("animdl_version", handler.json_payload)
|
||||||
|
|
||||||
|
def test_ani_cli_version_probe_uses_download_player(self):
|
||||||
|
http_handler.installed_ani_cli_version.cache_clear()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(command, **kwargs):
|
||||||
|
calls.append((command, kwargs))
|
||||||
|
return mock.Mock(stdout="ani-cli 4.9.0\n", stderr="")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with mock.patch.object(http_handler.subprocess, "run", side_effect=fake_run):
|
||||||
|
self.assertEqual(http_handler.installed_ani_cli_version(), "ani-cli 4.9.0")
|
||||||
|
finally:
|
||||||
|
http_handler.installed_ani_cli_version.cache_clear()
|
||||||
|
|
||||||
|
self.assertEqual(calls[0][0], [http_handler.ANI_CLI, "--version"])
|
||||||
|
self.assertEqual(calls[0][1]["env"]["ANI_CLI_PLAYER"], "download")
|
||||||
|
|
||||||
def test_dependencies_route_reports_fallback_tool_status(self):
|
def test_dependencies_route_reports_fallback_tool_status(self):
|
||||||
handler = DummyHandler("/api/dependencies")
|
handler = DummyHandler("/api/dependencies")
|
||||||
APP.Handler.do_GET(handler)
|
APP.Handler.do_GET(handler)
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||||
|
self.assertIn("curl-impersonate", handler.json_payload)
|
||||||
self.assertIn("anipy-cli", handler.json_payload)
|
self.assertIn("anipy-cli", handler.json_payload)
|
||||||
self.assertIn("animdl", handler.json_payload)
|
self.assertIn("animdl", handler.json_payload)
|
||||||
|
|
||||||
@@ -3330,6 +3455,12 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
status = http_handler.dependency_status()
|
status = http_handler.dependency_status()
|
||||||
|
|
||||||
self.assertTrue(status["animdl"])
|
self.assertTrue(status["animdl"])
|
||||||
|
exists_any.assert_any_call(
|
||||||
|
"curl_firefox135",
|
||||||
|
"curl_chrome136",
|
||||||
|
"curl_chrome116",
|
||||||
|
"curl_ff117",
|
||||||
|
)
|
||||||
exists_any.assert_any_call(
|
exists_any.assert_any_call(
|
||||||
http_handler.ANIMDL,
|
http_handler.ANIMDL,
|
||||||
"animdl",
|
"animdl",
|
||||||
@@ -3991,9 +4122,9 @@ class TemplateHelperTests(unittest.TestCase):
|
|||||||
self.assertIn('const progress = `${job.completed || 0}/${job.total || 0} entries`;', APP.QUEUE_HTML)
|
self.assertIn('const progress = `${job.completed || 0}/${job.total || 0} entries`;', APP.QUEUE_HTML)
|
||||||
self.assertIn('const moved = `${job.moved || 0} moved`;', APP.QUEUE_HTML)
|
self.assertIn('const moved = `${job.moved || 0} moved`;', APP.QUEUE_HTML)
|
||||||
|
|
||||||
def test_search_page_queues_selected_title_without_result_index(self):
|
def test_search_page_queues_selected_title_with_result_index(self):
|
||||||
self.assertIn('query: state.selected.title,', APP.INDEX_HTML)
|
self.assertIn('query: state.selected.title,', APP.INDEX_HTML)
|
||||||
self.assertNotIn('index: state.selected.index,', APP.INDEX_HTML)
|
self.assertIn('result_index: state.selected.index,', APP.INDEX_HTML)
|
||||||
|
|
||||||
def test_search_page_renders_poster_results_under_selection_panel(self):
|
def test_search_page_renders_poster_results_under_selection_panel(self):
|
||||||
self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML)
|
self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML)
|
||||||
|
|||||||
Reference in New Issue
Block a user