Compare commits

...
9 Commits
14 changed files with 941 additions and 29 deletions
+1
View File
@@ -10,3 +10,4 @@ __pycache__
venv
env
*.log
_backup_/
+1
View File
@@ -9,3 +9,4 @@ __pycache__/
venv/
env/
*.log
_backup_/
+2 -1
View File
@@ -3,4 +3,5 @@
- 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
- 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
+43
View File
@@ -1,5 +1,48 @@
# 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
- Ignored the local `_backup_/` folder in git and Docker build contexts so temporary backups do not get committed or copied into images.
- Added an agent note to preserve existing ani-cli-web configuration and watchlist data during future updates.
## 0.49.6 - 2026-07-21
- Added Config page watchlist export and import actions for full watchlist database backups.
- Backup files include raw `watchlist` rows plus `watchlist_removals`, preserving tracked shows, downloaded state, metadata, auto-download settings, thumbnails, and removed-show history.
- Expanded regression coverage for backup export/import behavior and the new HTTP routes.
## 0.49.5 - 2026-07-21
- Added a manual Config page action that reconciles watchlist downloaded episode flags against the current filesystem library.
- The new sync removes downloaded flags for missing files, adds flags for episodes already present on disk for the watchlist's active download mode, and updates movie entries when their library folder exists again.
- Expanded regression coverage for the new filesystem reconciliation flow and Config route wiring.
## 0.49.4 - 2026-07-19
- Added optional download job stdout mirroring with `ANI_CLI_WEB_JOB_STDOUT`, enabled by default in Docker and Docker Compose so container log collectors can capture downloader output.
+9
View File
@@ -1,3 +1,5 @@
FROM lexiforest/curl-impersonate:latest AS curl-impersonate
FROM python:3.12-slim-bookworm
ARG ANI_CLI=https://github.com/pystardust/ani-cli.git
@@ -22,6 +24,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
aria2 \
bash \
botan \
ca-certificates \
curl \
@@ -32,6 +35,7 @@ RUN apt-get update \
grep \
libxml2-dev \
libxslt1-dev \
libstdc++6 \
openssl \
patch \
python3.11 \
@@ -41,6 +45,11 @@ RUN apt-get update \
util-linux \
&& 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 \
&& 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$##')" \
+14 -5
View File
@@ -2,12 +2,12 @@
Local web UI for a system-wide `ani-cli` install.
Current version: `0.49.4`
Current version: `0.50.2`
## 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
- 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`
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
- Expose a small JSON summary endpoint for Homepage dashboard widgets
@@ -22,7 +22,7 @@ Current version: `0.49.4`
- `/`: 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
- `/config`: Save defaults, choose queue download methods and fallback retries, schedule refreshes, configure Jellyfin and Discord, export/import watchlist backups, manually reconcile watchlist download flags against the filesystem, monitor Jellyfin handoff progress, and view runtime info and the changelog
## Quick start
@@ -30,7 +30,7 @@ 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`
- Common runtime tools used by `ani-cli`, especially `curl` or `curl-impersonate`, `ffmpeg`, `fzf`, `grep`, `openssl`, `sed`, `aria2c`, `botan`, and `yt-dlp`
Run locally:
@@ -90,6 +90,7 @@ Docker notes:
- `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
- `curl-impersonate` is installed inside the container and exposes the browser wrapper commands used by `ani-cli` v5, such as `curl_firefox135` and `curl_chrome136`, so provider requests can avoid Cloudflare blocks that affect plain `curl`
- `botan` is installed inside the container for providers or scripts that need Botan cryptography tooling
- `anipy-cli` and `animdl` are installed systemwide inside the container at `/usr/local/bin`, linked from `/usr/bin`, and included in the runtime `PATH`; `animdl` is isolated in a Python 3.11 virtualenv so its pinned dependencies stay executable
@@ -103,6 +104,7 @@ Docker notes:
- 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
- Watchlist backups exported from Config include the full watchlist database rows and removed-show history for restore/import
### Auto-download
@@ -116,6 +118,10 @@ Docker notes:
- 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
- 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`
- Docker images install `anipy-cli` and `animdl` automatically; non-Docker installs should provide the selected tools in `PATH` themselves
@@ -130,6 +136,7 @@ Docker notes:
- 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
- Manual watchlist filesystem sync rechecks downloaded episode flags against the files currently present under the download library
- Recent Jellyfin handoff jobs survive page reloads and show their latest status after app restarts
## Homepage widget API
@@ -159,7 +166,7 @@ This endpoint is intentionally small so Homepage can map the returned values dir
The Config page runtime panel shows:
- `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
- A `Changelog` button that opens a scrollable viewer backed by `CHANGELOG.md`
@@ -196,6 +203,8 @@ Main contents:
- `state.sqlite3`
- `thumbnails/`
Local `_backup_/` folders are ignored by git and Docker builds so temporary backup copies stay outside release artifacts.
Downloaded files are organized like this:
```text
+1 -1
View File
@@ -1 +1 @@
0.49.4
0.50.2
+312 -1
View File
@@ -58,6 +58,7 @@ from app_support import (
normalize_config,
normalize_episode_offset,
normalize_media_type,
normalize_result_index,
now_iso,
remote_access_session_fingerprint,
sanitize_path_component,
@@ -74,7 +75,10 @@ from title_matching import (
title_lookup_queries,
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
RUNTIME_LOCK = threading.RLock()
@@ -93,6 +97,7 @@ DOWNLOAD_QUEUE = None
WATCHLIST_REFRESH = None
WATCHLIST_AUTO_REFRESH = None
WATCHLIST_JELLYFIN_SYNC = None
WATCHLIST_BACKUP_FORMAT_VERSION = 1
def graph_request(query, variables, timeout=25):
@@ -987,6 +992,14 @@ def watchlist_source_library_dir(item, config):
return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item)
def watchlist_source_season_dir(item, config):
source_root = watchlist_source_library_dir(item, config)
if normalize_media_type(item.get("media_type")) == "movie":
return source_root
season = normalize_season((item or {}).get("auto_download_series") or "1")
return source_root / f"Season {int(season):02d}"
def jellyfin_target_library_dir(item, config):
media_type = normalize_media_type(item.get("media_type"))
key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
@@ -1043,6 +1056,62 @@ def watchlist_item_download_complete(item, mode=""):
return True, "ready"
def normalize_downloaded_episode_value(value):
text = str(value or "").strip()
if not text:
return ""
match = re.match(r"^0*([0-9]+)(.*)$", text)
if not match:
return text
number = int(match.group(1) or "0", 10)
suffix = str(match.group(2) or "")
return f"{number}{suffix}" if number > 0 else text
def reverse_episode_offset(value, offset):
normalized_offset = normalize_episode_offset(offset)
text = str(value or "").strip()
if not normalized_offset or not text:
return normalize_downloaded_episode_value(text)
match = re.match(r"^0*([0-9]+)(.*)$", text)
if not match:
return normalize_downloaded_episode_value(text)
number = int(match.group(1) or "0", 10)
if number < 1:
return normalize_downloaded_episode_value(text)
logical_number = number - int(normalized_offset, 10) + 1
if logical_number < 1:
return ""
suffix = str(match.group(2) or "")
return f"{logical_number}{suffix}"
def watchlist_downloaded_episode_values_from_filesystem(item, config, mode):
normalized_mode = str(mode or "").strip().lower()
if normalized_mode not in MODE_CHOICES:
return []
season_dir = watchlist_source_season_dir(item, config)
if not season_dir.exists() or not season_dir.is_dir():
return []
season = normalize_season((item or {}).get("auto_download_series") or "1")
pattern = re.compile(rf"\bS{int(season):02d}E([0-9][0-9A-Za-z.\-]*)\b", re.IGNORECASE)
values = []
seen = set()
offset = (item or {}).get("auto_download_offset")
for path in sorted(season_dir.rglob("*")):
if not path.is_file():
continue
match = pattern.search(path.stem)
if not match:
continue
logical_value = reverse_episode_offset(match.group(1), offset)
normalized_value = normalize_downloaded_episode_value(logical_value)
if normalized_value and normalized_value not in seen:
seen.add(normalized_value)
values.append(normalized_value)
return values
def _move_tree_contents(source_dir, target_dir, progress_fn=None):
moved = []
file_paths = [
@@ -1365,6 +1434,9 @@ class WatchlistStore:
conn.row_factory = sqlite3.Row
return conn
def _table_columns_conn(self, conn, table_name):
return [str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall()]
def _exists_conn(self, conn, show_id):
row = conn.execute(
"SELECT 1 FROM watchlist WHERE show_id = ? LIMIT 1",
@@ -2095,6 +2167,192 @@ class WatchlistStore:
)
return self.schedule_refresh(existing["show_id"], source=source)
def reconcile_downloaded_filesystem(self, config=None):
active_config = normalize_config(config or self.config_getter() or {})
items = []
changed = 0
with self.lock, self._connect() as conn:
rows = conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
for row in rows:
existing_item = self._row_to_item(row)
media_type = normalize_media_type(existing_item.get("media_type"))
if media_type == "movie":
source_dir = watchlist_source_library_dir(existing_item, active_config)
has_files = source_dir.exists() and any(path.is_file() for path in source_dir.rglob("*"))
new_sub_values = []
new_dub_values = []
downloaded = has_files
else:
filesystem_values = watchlist_downloaded_episode_values_from_filesystem(existing_item, active_config, "dub")
if filesystem_values:
preferred_mode = watchlist_preferred_completion_mode(existing_item)
new_sub_values = list(existing_item.get("downloaded_sub_episodes") or [])
new_dub_values = list(existing_item.get("downloaded_dub_episodes") or [])
if preferred_mode == "sub":
new_sub_values = filesystem_values
else:
new_dub_values = filesystem_values
downloaded = bool(new_sub_values or new_dub_values)
else:
new_sub_values = []
new_dub_values = []
downloaded = False
old_sub_values = existing_item.get("downloaded_sub_episodes") or []
old_dub_values = existing_item.get("downloaded_dub_episodes") or []
sub_added = [value for value in new_sub_values if value not in old_sub_values]
sub_removed = [value for value in old_sub_values if value not in new_sub_values]
dub_added = [value for value in new_dub_values if value not in old_dub_values]
dub_removed = [value for value in old_dub_values if value not in new_dub_values]
downloaded_changed = bool(existing_item.get("downloaded")) != downloaded
row_changed = bool(sub_added or sub_removed or dub_added or dub_removed or downloaded_changed)
if row_changed:
conn.execute(
"""
UPDATE watchlist
SET downloaded = ?, downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ?, updated_at = ?
WHERE show_id = ?
""",
(
1 if downloaded else 0,
encode_episode_values(new_sub_values) if new_sub_values else None,
encode_episode_values(new_dub_values) if new_dub_values else None,
now_iso(),
existing_item["show_id"],
),
)
changed += 1
items.append(
{
"show_id": existing_item.get("show_id"),
"title": existing_item.get("title"),
"media_type": media_type,
"changed": row_changed,
"downloaded": downloaded,
"sub_added": sub_added,
"sub_removed": sub_removed,
"dub_added": dub_added,
"dub_removed": dub_removed,
}
)
return {
"message": f"Reconciled downloaded watchlist files for {len(items)} entr{'y' if len(items) == 1 else 'ies'}."
+ (f" Updated {changed}." if changed else " No changes were needed."),
"total": len(items),
"changed": changed,
"items": items,
}
def export_backup(self):
with self.lock, self._connect() as conn:
watchlist_columns = self._table_columns_conn(conn, "watchlist")
removal_columns = self._table_columns_conn(conn, "watchlist_removals")
watchlist_rows = [
{column: row[column] for column in watchlist_columns}
for row in conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
]
removal_rows = [
{column: row[column] for column in removal_columns}
for row in conn.execute("SELECT * FROM watchlist_removals ORDER BY removed_at DESC").fetchall()
]
return {
"format": "ani-cli-web-watchlist-backup",
"format_version": WATCHLIST_BACKUP_FORMAT_VERSION,
"app_version": VERSION,
"exported_at": now_iso(),
"tables": {
"watchlist": {
"columns": watchlist_columns,
"rows": watchlist_rows,
},
"watchlist_removals": {
"columns": removal_columns,
"rows": removal_rows,
},
},
}
def import_backup(self, backup):
if not isinstance(backup, dict):
raise ValueError("Watchlist backup must be a JSON object.")
if backup.get("format") != "ani-cli-web-watchlist-backup":
raise ValueError("Backup format is not recognized.")
try:
format_version = int(backup.get("format_version") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("Backup format version is invalid.") from exc
if format_version < 1 or format_version > WATCHLIST_BACKUP_FORMAT_VERSION:
raise ValueError("Backup format version is not supported by this app version.")
tables = backup.get("tables")
if not isinstance(tables, dict):
raise ValueError("Backup is missing table data.")
watchlist_rows = ((tables.get("watchlist") or {}).get("rows") if isinstance(tables.get("watchlist"), dict) else None)
removal_rows = (
(tables.get("watchlist_removals") or {}).get("rows")
if isinstance(tables.get("watchlist_removals"), dict)
else []
)
if not isinstance(watchlist_rows, list):
raise ValueError("Backup is missing watchlist rows.")
if not isinstance(removal_rows, list):
raise ValueError("Backup removal rows must be an array.")
now = now_iso()
with self.lock, self._connect() as conn:
watchlist_columns = self._table_columns_conn(conn, "watchlist")
removal_columns = self._table_columns_conn(conn, "watchlist_removals")
conn.execute("DELETE FROM watchlist")
conn.execute("DELETE FROM watchlist_removals")
imported_watchlist = 0
for row in watchlist_rows:
if not isinstance(row, dict):
raise ValueError("Backup watchlist rows must be JSON objects.")
show_id = str(row.get("show_id") or "").strip()
title = str(row.get("title") or "").strip()
if not show_id or not title:
raise ValueError("Backup watchlist rows must include show_id and title.")
values = {column: row.get(column) for column in watchlist_columns}
values["show_id"] = show_id
values["title"] = title
values["category"] = normalize_watchlist_category(values.get("category"))
values["downloaded"] = 1 if values.get("downloaded") else 0
values["status"] = str(values.get("status") or "tracked")
values["status_message"] = str(values.get("status_message") or "Imported from backup.")
values["created_at"] = str(values.get("created_at") or now)
values["updated_at"] = str(values.get("updated_at") or now)
values["sub_count"] = int(values.get("sub_count") or 0)
values["dub_count"] = int(values.get("dub_count") or 0)
values["expected_count"] = int(values.get("expected_count") or 0)
values["media_type"] = normalize_media_type(values.get("media_type"))
placeholders = ", ".join("?" for _column in watchlist_columns)
column_sql = ", ".join(watchlist_columns)
conn.execute(
f"INSERT INTO watchlist ({column_sql}) VALUES ({placeholders})",
[values.get(column) for column in watchlist_columns],
)
imported_watchlist += 1
imported_removals = 0
for row in removal_rows:
if not isinstance(row, dict):
raise ValueError("Backup removal rows must be JSON objects.")
show_id = str(row.get("show_id") or "").strip()
if not show_id:
continue
values = {column: row.get(column) for column in removal_columns}
values["show_id"] = show_id
values["title"] = str(values.get("title") or "").strip()
values["removed_at"] = str(values.get("removed_at") or now)
placeholders = ", ".join("?" for _column in removal_columns)
column_sql = ", ".join(removal_columns)
conn.execute(
f"INSERT INTO watchlist_removals ({column_sql}) VALUES ({placeholders})",
[values.get(column) for column in removal_columns],
)
imported_removals += 1
return {
"message": f"Imported {imported_watchlist} watchlist entr{'y' if imported_watchlist == 1 else 'ies'} from backup.",
"imported": imported_watchlist,
"removals": imported_removals,
}
def mark_downloaded(self, show_id, title="", mode="", episodes="", media_type=""):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
@@ -2653,6 +2911,22 @@ def get_jellyfin_sync_status():
return WATCHLIST_JELLYFIN_SYNC.status()
def export_watchlist_backup():
runtime = ensure_runtime()
return runtime["watchlist"].export_backup()
def import_watchlist_backup(payload):
runtime = ensure_runtime()
return runtime["watchlist"].import_backup(payload)
def reconcile_watchlist_downloaded_files(config=None):
runtime = ensure_runtime()
active_config = normalize_config(config or runtime["config"] or {})
return runtime["watchlist"].reconcile_downloaded_filesystem(active_config)
def update_watchlist_category(show_id, category):
ensure_runtime()
item = WATCHLIST.update_category(show_id, category)
@@ -2737,6 +3011,10 @@ def sync_downloaded_job_to_watchlist(job):
def prepare_download_job(job):
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(
job,
search_fn=search_anime,
@@ -2754,6 +3032,36 @@ def prepare_download_job(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):
with RUNTIME_LOCK:
source = CONFIG if CONFIG is not None else fallback
@@ -2943,6 +3251,7 @@ Handler = build_handler_class(
download_watchlist_item=download_watchlist_item,
ensure_runtime=ensure_runtime,
episode_list=episode_list,
export_watchlist_backup=export_watchlist_backup,
get_config_snapshot=get_config_snapshot,
get_watchlist_homepage_summary=get_watchlist_homepage_summary,
get_jellyfin_sync_status=get_jellyfin_sync_status,
@@ -2950,7 +3259,9 @@ Handler = build_handler_class(
get_watchlist_refresh_status=get_watchlist_refresh_status,
http_error=HttpError,
index_html=INDEX_HTML,
import_watchlist_backup=import_watchlist_backup,
queue_html=QUEUE_HTML,
reconcile_watchlist_downloaded_files=reconcile_watchlist_downloaded_files,
remove_from_watchlist=remove_from_watchlist,
run_jellyfin_sync=run_jellyfin_sync,
runtime_state=runtime_state,
+16 -1
View File
@@ -1216,7 +1216,7 @@ def build_job(payload, config):
"season": normalize_season(payload.get("season")),
"episode_offset": normalize_episode_offset(payload.get("episode_offset")),
"query": query,
"result_index": None,
"result_index": normalize_result_index(payload.get("result_index")),
"mode": mode,
"quality": quality,
"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):
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"
@@ -1276,6 +1288,9 @@ def command_for_job(job, backend="ani-cli", download_path=None):
"-e",
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":
command.append("--dub")
command.append(job["query"])
+104 -1
View File
@@ -612,6 +612,34 @@ CONFIG_HTML = r"""<!doctype html>
<div class="muted" id="jellyfinSyncStatus">No recent Jellyfin handoff job.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Watchlist filesystem sync</h2>
<p class="muted">Manually reconcile downloaded episode flags with the files currently present under the download library.</p>
</div>
<button id="reconcileWatchlistDownloadsBtn" type="button">Sync now</button>
</div>
<div class="field-hint">This scans the configured download library, removes downloaded episode flags for files that no longer exist, and adds flags for episodes already present on disk.</div>
<div class="muted" id="watchlistReconcileStatus">No watchlist filesystem sync run yet.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Watchlist backup</h2>
<p class="muted">Export or restore a complete watchlist database snapshot, including removed-show records.</p>
</div>
<div class="row">
<button id="exportWatchlistBtn" type="button">Export</button>
<button id="importWatchlistBtn" type="button">Import</button>
</div>
</div>
<input id="importWatchlistFile" type="file" accept="application/json,.json" hidden>
<div class="field-hint">Import replaces the current watchlist with the selected backup file.</div>
<div class="muted" id="watchlistBackupStatus">No watchlist backup action run yet.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
@@ -706,7 +734,9 @@ CONFIG_HTML = r"""<!doctype html>
home_path: ""
},
jellyfinSyncRunning: false,
jellyfinSyncJobId: null
jellyfinSyncJobId: null,
watchlistReconcileRunning: false,
watchlistImportRunning: false
};
const $ = (id) => document.getElementById(id);
@@ -743,6 +773,24 @@ CONFIG_HTML = r"""<!doctype html>
el.textContent = job.message || "Jellyfin handoff idle.";
}
function setWatchlistReconcileButton(running) {
$("reconcileWatchlistDownloadsBtn").disabled = !!running;
$("reconcileWatchlistDownloadsBtn").textContent = running ? "Syncing..." : "Sync now";
}
function setWatchlistReconcileStatus(text) {
$("watchlistReconcileStatus").textContent = text || "No watchlist filesystem sync run yet.";
}
function setWatchlistBackupStatus(text) {
$("watchlistBackupStatus").textContent = text || "No watchlist backup action run yet.";
}
function setWatchlistImportButton(running) {
$("importWatchlistBtn").disabled = !!running;
$("importWatchlistBtn").textContent = running ? "Importing..." : "Import";
}
function selectedWebhookEvents() {
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
}
@@ -862,6 +910,57 @@ CONFIG_HTML = r"""<!doctype html>
}
}
async function reconcileWatchlistDownloads() {
state.watchlistReconcileRunning = true;
setWatchlistReconcileButton(true);
setWatchlistReconcileStatus("Scanning the download library...");
try {
const data = await api("/api/config/watchlist/reconcile-downloads", {
method: "POST",
body: JSON.stringify(formConfigPayload())
});
const changed = Number(data.changed || 0);
const total = Number(data.total || 0);
setWatchlistReconcileStatus(`${changed} updated out of ${total} watchlist entries.`);
setNotice(data.message || "Watchlist filesystem sync completed.");
} catch (error) {
setWatchlistReconcileStatus("Watchlist filesystem sync failed.");
setNotice(error.message);
} finally {
state.watchlistReconcileRunning = false;
setWatchlistReconcileButton(false);
}
}
function exportWatchlistBackup() {
setWatchlistBackupStatus("Preparing watchlist backup download...");
window.location.href = "/api/config/watchlist/export";
}
async function importWatchlistBackup(file) {
if (!file) return;
state.watchlistImportRunning = true;
setWatchlistImportButton(true);
setWatchlistBackupStatus("Reading backup file...");
try {
const payload = JSON.parse(await file.text());
setWatchlistBackupStatus("Importing backup...");
const data = await api("/api/config/watchlist/import", {
method: "POST",
body: JSON.stringify(payload)
});
setWatchlistBackupStatus(`${data.imported || 0} watchlist entries imported.`);
setNotice(data.message || "Watchlist backup imported.");
} catch (error) {
setWatchlistBackupStatus("Watchlist backup import failed.");
setNotice(error.message);
} finally {
$("importWatchlistFile").value = "";
state.watchlistImportRunning = false;
setWatchlistImportButton(false);
}
}
function closePathBrowser() {
state.pathBrowser = {
targetId: "",
@@ -1008,6 +1107,10 @@ CONFIG_HTML = r"""<!doctype html>
$("saveConfigBtn").addEventListener("click", saveConfig);
$("testWebhookBtn").addEventListener("click", testWebhook);
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
$("reconcileWatchlistDownloadsBtn").addEventListener("click", reconcileWatchlistDownloads);
$("exportWatchlistBtn").addEventListener("click", exportWatchlistBackup);
$("importWatchlistBtn").addEventListener("click", () => $("importWatchlistFile").click());
$("importWatchlistFile").addEventListener("change", (event) => importWatchlistBackup((event.target.files || [])[0]));
$("openChangelogBtn").addEventListener("click", openChangelog);
$("closeChangelogBtn").addEventListener("click", closeChangelog);
$("pathBrowserCloseBtn").addEventListener("click", closePathBrowser);
+44
View File
@@ -58,6 +58,7 @@ class HandlerContext:
download_watchlist_item: object
ensure_runtime: object
episode_list: object
export_watchlist_backup: object
get_config_snapshot: object
get_jellyfin_sync_status: object
get_watchlist_homepage_summary: object
@@ -65,7 +66,9 @@ class HandlerContext:
get_watchlist_refresh_status: object
http_error: object
index_html: str
import_watchlist_backup: object
queue_html: str
reconcile_watchlist_downloaded_files: object
remove_from_watchlist: object
run_jellyfin_sync: object
runtime_state: object
@@ -94,6 +97,12 @@ 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["curl-impersonate"] = cli_executable_exists_any(
"curl_firefox135",
"curl_chrome136",
"curl_chrome116",
"curl_ff117",
)
result["ani-cli"] = cli_executable_exists(ANI_CLI)
result["anipy-cli"] = cli_executable_exists_any(
ANIPY_CLI,
@@ -236,6 +245,11 @@ def installed_ani_cli_version():
[ANI_CLI, "--version"],
check=True,
capture_output=True,
env={
**os.environ.copy(),
"ANI_CLI_PLAYER": "download",
"TERM": os.environ.get("TERM", "xterm-256color"),
},
text=True,
timeout=6,
)
@@ -746,6 +760,12 @@ class Handler(BaseHTTPRequestHandler):
elif parsed.path == "/api/config/jellyfin/sync-status":
Handler._runtime(self)
self.json(Handler._context(self).get_jellyfin_sync_status())
elif parsed.path == "/api/config/watchlist/export":
Handler._runtime(self)
self.json_attachment(
Handler._context(self).export_watchlist_backup(),
"ani-cli-web-watchlist-backup.json",
)
else:
self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
@@ -787,6 +807,17 @@ class Handler(BaseHTTPRequestHandler):
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/watchlist/reconcile-downloads":
payload = Handler.require_json_object(self, self.body_json())
current = Handler._context(self).get_config_snapshot()
merged = dict(current)
merged.update(payload)
if str(merged.get("download_dir") or "").strip():
Handler.validate_remote_path(self, merged["download_dir"], "download directory")
self.json(Handler._context(self).reconcile_watchlist_downloaded_files(merged))
elif parsed.path == "/api/config/watchlist/import":
payload = Handler.require_json_object(self, self.body_json())
self.json(Handler._context(self).import_watchlist_backup(payload))
elif parsed.path == "/api/config/webhook/test":
payload = Handler.require_json_object(self, self.body_json())
validate_discord_webhook_url(payload.get("discord_webhook_url"))
@@ -1005,6 +1036,19 @@ class Handler(BaseHTTPRequestHandler):
data = json.dumps(payload).encode("utf-8")
self.write_response_bytes(data, status, {"Content-Type": "application/json"})
def json_attachment(self, payload, filename, status=HTTPStatus.OK):
data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
safe_filename = str(filename or "watchlist-backup.json").replace('"', "")
self.write_response_bytes(
data,
status,
{
"Content-Type": "application/json",
"Content-Disposition": f'attachment; filename="{safe_filename}"',
"Cache-Control": "no-store",
},
)
def write_response_bytes(self, payload, status, headers):
try:
self.send_response(status)
+17 -16
View File
@@ -1258,12 +1258,15 @@ class DownloadQueue:
self._save_job_locked(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):
job = build_job(payload, self.config_getter())
if self.job_prepare_fn is not None:
prepared = self.job_prepare_fn(dict(job))
if prepared is not None:
job = prepared
job = self._prepare_job(job)
with self.lock:
reusable = self._find_reusable_failed_job_locked(job)
if reusable is not None:
@@ -1347,24 +1350,21 @@ class DownloadQueue:
raise ValueError("Running jobs cannot be retried")
if job["status"] not in {"failed", "canceled"}:
raise ValueError("Only failed or canceled jobs can be retried")
job = self._prepare_job(job)
with self.lock:
job = self._reset_retryable_job_locked(job)
self.wakeup.set()
return job
def retry_all_failed(self):
count = 0
with self.lock, self._connect() as conn:
rows = conn.execute("SELECT * FROM jobs WHERE status = 'failed'").fetchall()
for row in rows:
job = self._job_from_row(row)
job["status"] = "pending"
job["exit_code"] = None
job["pid"] = None
job["started_at"] = None
job["finished_at"] = None
job["updated_at"] = now_iso()
job["log"] = []
self._upsert_job_conn(conn, job)
with self.lock:
with self._connect() as conn:
rows = conn.execute("SELECT * FROM jobs WHERE status = 'failed'").fetchall()
for row in rows:
job = self._prepare_job(self._job_from_row(row))
with self.lock:
self._reset_retryable_job_locked(job)
count += 1
if count:
self.wakeup.set()
@@ -1518,6 +1518,7 @@ class DownloadQueue:
"env": {
**os.environ.copy(),
"ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
"ANI_CLI_PLAYER": "download",
"ANI_CLI_MODE": job["mode"],
"ANI_CLI_QUALITY": job["quality"],
"TERM": os.environ.get("TERM", "xterm-256color"),
+1
View File
@@ -688,6 +688,7 @@ INDEX_HTML = r"""<!doctype html>
const payload = {
show_id: state.selected.id,
query: state.selected.title,
result_index: state.selected.index,
title: state.selected.title,
anime_name: $("animeNameInput").value || state.selected.title,
media_type: $("trackMediaType").value,
+376 -3
View File
@@ -72,6 +72,12 @@ class DummyHandler:
self.json_payload = payload
self.json_status = status
def json_attachment(self, payload, filename, status=HTTPStatus.OK):
return APP.Handler.json_attachment(self, payload, filename, status=status)
def write_response_bytes(self, payload, status, headers):
return APP.Handler.write_response_bytes(self, payload, status, headers)
def error(self, status, message):
self.error_status = status
self.error_message = message
@@ -526,6 +532,66 @@ class QueueApiTests(unittest.TestCase):
self.assertEqual(retried["status"], "pending")
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):
payload = {
"show_id": "show-42",
@@ -1088,6 +1154,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
)
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
commands = []
envs = []
class FakeProc:
def __init__(self, pid, stdout, exit_code):
@@ -1106,6 +1173,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
def fake_popen(command, **kwargs):
commands.append(command)
envs.append(kwargs.get("env", {}))
return processes.pop(0)
with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object(
@@ -1119,6 +1187,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
self.assertEqual(stored["status"], "done")
self.assertTrue(stored["fallback_used"])
self.assertEqual([command[0] for command in commands], [APP.app_support.ANI_CLI, APP.app_support.ANIPY_CLI, APP.app_support.ANIMDL])
self.assertEqual(envs[0]["ANI_CLI_PLAYER"], "download")
self.assertIn("Fallback download completed with animdl.", stored["log"])
@@ -1878,6 +1947,36 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(prepared["show_id"], "show-42")
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):
job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""}
@@ -1934,7 +2033,7 @@ class WatchlistCompletionTests(unittest.TestCase):
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(
{
"query": "Queue Show",
@@ -1945,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)
def test_command_for_job_builds_anipy_fallback_search_spec(self):
@@ -2288,6 +2401,189 @@ class JellyfinSyncTests(unittest.TestCase):
self.assertIn("title mismatch", str(ctx.exception).lower())
class WatchlistFilesystemReconcileTests(unittest.TestCase):
def setUp(self):
with APP.WATCHLIST._connect() as conn:
conn.execute("DELETE FROM watchlist")
def seed_watchlist_item(self, **overrides):
now = APP.now_iso()
item = {
"show_id": "show-reconcile-1",
"title": "Reconcile Show",
"category": "watching",
"downloaded": True,
"status": "updated",
"status_message": "Tracked.",
"created_at": now,
"updated_at": now,
"last_checked": now,
"sub_count": 12,
"dub_count": 12,
"expected_count": 12,
"airing_status": "Finished",
"sub_latest_episode": "12",
"dub_latest_episode": "12",
"animeschedule_route": None,
"animeschedule_title": None,
"anidb_aid": None,
"anidb_title": None,
"media_type": "tv",
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": "Reconcile Show",
"auto_download_source_name": "Reconcile Show",
"auto_download_series": "2",
"auto_download_offset": "13",
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "3"]),
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
item.update(overrides)
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
APP.WATCHLIST._upsert_conn(conn, item)
def test_reconcile_watchlist_downloaded_files_updates_episode_sets_from_filesystem(self):
self.seed_watchlist_item()
with tempfile.TemporaryDirectory() as temp_root:
season_dir = Path(temp_root) / "tv" / "Reconcile Show" / "Season 02"
season_dir.mkdir(parents=True, exist_ok=True)
(season_dir / "Reconcile Show - S02E13.mp4").write_bytes(b"one")
(season_dir / "Reconcile Show - S02E14.mkv").write_bytes(b"two")
result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"})
self.assertEqual(result["changed"], 1)
item = APP.WATCHLIST.get("show-reconcile-1")
self.assertTrue(item["downloaded"])
self.assertEqual(item["downloaded_dub_episodes"], ["1", "2"])
self.assertEqual(item["downloaded_sub_episodes"], [])
def test_reconcile_watchlist_downloaded_files_clears_missing_files(self):
self.seed_watchlist_item(
downloaded_sub_episodes_json=APP.encode_episode_values(["1"]),
downloaded_dub_episodes_json=APP.encode_episode_values(["1", "2"]),
)
result = APP.reconcile_watchlist_downloaded_files({"download_dir": "/tmp/missing-download-root", "mode": "sub", "quality": "best"})
self.assertEqual(result["changed"], 1)
item = APP.WATCHLIST.get("show-reconcile-1")
self.assertFalse(item["downloaded"])
self.assertEqual(item["downloaded_sub_episodes"], [])
self.assertEqual(item["downloaded_dub_episodes"], [])
def test_reconcile_watchlist_downloaded_files_marks_movie_present_when_file_exists(self):
self.seed_watchlist_item(
show_id="show-movie-1",
title="Movie Reconcile",
media_type="movie",
downloaded=False,
auto_download_name="Movie Reconcile",
auto_download_series="1",
auto_download_offset=None,
downloaded_sub_episodes_json=None,
downloaded_dub_episodes_json=None,
)
with tempfile.TemporaryDirectory() as temp_root:
movie_dir = Path(temp_root) / "movie" / "Movie Reconcile"
movie_dir.mkdir(parents=True, exist_ok=True)
(movie_dir / "Movie Reconcile.mp4").write_bytes(b"movie")
result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"})
self.assertEqual(result["changed"], 1)
item = APP.WATCHLIST.get("show-movie-1")
self.assertTrue(item["downloaded"])
class WatchlistBackupTests(unittest.TestCase):
def setUp(self):
with APP.WATCHLIST._connect() as conn:
conn.execute("DELETE FROM watchlist")
conn.execute("DELETE FROM watchlist_removals")
def seed_watchlist_item(self, **overrides):
now = APP.now_iso()
item = {
"show_id": "show-backup-1",
"title": "Backup Show",
"category": "finished",
"downloaded": True,
"status": "updated",
"status_message": "Ready.",
"created_at": now,
"updated_at": now,
"last_checked": now,
"sub_count": 2,
"dub_count": 2,
"expected_count": 2,
"airing_status": "Finished",
"sub_latest_episode": "2",
"dub_latest_episode": "2",
"animeschedule_route": "backup-route",
"animeschedule_title": "Backup Schedule",
"anidb_aid": "123",
"anidb_title": "Backup AniDB",
"media_type": "tv",
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": "Backup Show",
"auto_download_source_name": "Backup Search",
"auto_download_series": "1",
"auto_download_offset": None,
"downloaded_sub_episodes_json": APP.encode_episode_values(["1"]),
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "2"]),
"thumbnail_path": "show-backup-1.jpg",
"thumbnail_checked_at": now,
}
item.update(overrides)
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
APP.WATCHLIST._upsert_conn(conn, item)
def test_export_watchlist_backup_includes_raw_watchlist_and_removal_rows(self):
self.seed_watchlist_item()
with APP.WATCHLIST._connect() as conn:
conn.execute(
"INSERT INTO watchlist_removals (show_id, title, removed_at) VALUES (?, ?, ?)",
("removed-show", "Removed Show", APP.now_iso()),
)
backup = APP.export_watchlist_backup()
self.assertEqual(backup["format"], "ani-cli-web-watchlist-backup")
self.assertEqual(backup["format_version"], 1)
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["show_id"], "show-backup-1")
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["downloaded_dub_episodes_json"], APP.encode_episode_values(["1", "2"]))
self.assertEqual(backup["tables"]["watchlist_removals"]["rows"][0]["show_id"], "removed-show")
def test_import_watchlist_backup_replaces_existing_watchlist_tables(self):
self.seed_watchlist_item(show_id="old-show", title="Old Show")
backup = APP.export_watchlist_backup()
backup["tables"]["watchlist"]["rows"][0]["show_id"] = "new-show"
backup["tables"]["watchlist"]["rows"][0]["title"] = "New Show"
backup["tables"]["watchlist_removals"]["rows"] = [
{"show_id": "removed-new", "title": "Removed New", "removed_at": APP.now_iso()}
]
result = APP.import_watchlist_backup(backup)
self.assertEqual(result["imported"], 1)
with self.assertRaises(KeyError):
APP.WATCHLIST.get("old-show")
restored = APP.WATCHLIST.get("new-show")
self.assertEqual(restored["title"], "New Show")
self.assertEqual(restored["downloaded_dub_episodes"], ["1", "2"])
with APP.WATCHLIST._connect() as conn:
removal = conn.execute("SELECT title FROM watchlist_removals WHERE show_id = ?", ("removed-new",)).fetchone()
self.assertEqual(removal["title"], "Removed New")
def test_import_watchlist_backup_rejects_unrecognized_format(self):
with self.assertRaises(ValueError):
APP.import_watchlist_backup({"format": "not-this-app", "format_version": 1, "tables": {}})
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
def setUp(self):
with APP.WATCHLIST._connect() as conn:
@@ -3127,10 +3423,28 @@ class HandlerRouteTests(unittest.TestCase):
self.assertIn("anipy_cli_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):
handler = DummyHandler("/api/dependencies")
APP.Handler.do_GET(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertIn("curl-impersonate", handler.json_payload)
self.assertIn("anipy-cli", handler.json_payload)
self.assertIn("animdl", handler.json_payload)
@@ -3141,6 +3455,12 @@ class HandlerRouteTests(unittest.TestCase):
status = http_handler.dependency_status()
self.assertTrue(status["animdl"])
exists_any.assert_any_call(
"curl_firefox135",
"curl_chrome136",
"curl_chrome116",
"curl_ff117",
)
exists_any.assert_any_call(
http_handler.ANIMDL,
"animdl",
@@ -3641,6 +3961,59 @@ class HandlerRouteTests(unittest.TestCase):
}
)
def test_watchlist_reconcile_post_merges_partial_payload_with_saved_config(self):
body = b'{"download_dir":"/tmp/override"}'
handler = DummyHandler("/api/config/watchlist/reconcile-downloads", body=body, content_length=len(body))
payload = {"message": "ok", "total": 2, "changed": 1, "items": []}
handler.handler_context = mock.Mock(
get_config_snapshot=mock.Mock(
return_value={
"download_dir": "/tmp/example",
"mode": "sub",
"quality": "best",
}
),
reconcile_watchlist_downloaded_files=mock.Mock(return_value=payload),
)
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
handler.handler_context.reconcile_watchlist_downloaded_files.assert_called_once_with(
{
"download_dir": "/tmp/override",
"mode": "sub",
"quality": "best",
}
)
def test_watchlist_export_route_returns_json_attachment(self):
handler = DummyHandler("/api/config/watchlist/export")
payload = {"format": "ani-cli-web-watchlist-backup", "tables": {"watchlist": {"rows": []}}}
handler.handler_context = mock.Mock(
ensure_runtime=mock.Mock(),
runtime_state=mock.Mock(return_value={}),
export_watchlist_backup=mock.Mock(return_value=payload),
)
APP.Handler.do_GET(handler)
self.assertEqual(handler.response_status, HTTPStatus.OK)
headers = dict(handler.response_headers)
self.assertEqual(headers.get("Content-Type"), "application/json")
self.assertIn("attachment", headers.get("Content-Disposition", ""))
self.assertEqual(json.loads(handler.wfile.getvalue().decode("utf-8")), payload)
def test_watchlist_import_route_returns_import_summary(self):
body = b'{"format":"ani-cli-web-watchlist-backup","format_version":1,"tables":{"watchlist":{"rows":[]}}}'
handler = DummyHandler("/api/config/watchlist/import", body=body, content_length=len(body))
payload = {"message": "Imported 0 watchlist entries from backup.", "imported": 0, "removals": 0}
handler.handler_context = mock.Mock(import_watchlist_backup=mock.Mock(return_value=payload))
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload, payload)
handler.handler_context.import_watchlist_backup.assert_called_once()
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
handler = DummyHandler("/api/config")
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
@@ -3749,9 +4122,9 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn('const progress = `${job.completed || 0}/${job.total || 0} entries`;', APP.QUEUE_HTML)
self.assertIn('const moved = `${job.moved || 0} moved`;', APP.QUEUE_HTML)
def test_search_page_queues_selected_title_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.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):
self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML)