@@ -666,6 +685,7 @@ CONFIG_HTML = r"""
mode: "sub",
quality: "best",
download_dir: "",
+ anipy_cli_fallback_enabled: false,
watchlist_auto_refresh_enabled: false,
watchlist_auto_refresh_minutes: 60,
watchlist_refresh_delay_seconds: 5,
@@ -732,6 +752,7 @@ CONFIG_HTML = r"""
download_dir: $("downloadDir").value,
mode: $("configMode").value,
quality: $("configQuality").value,
+ anipy_cli_fallback_enabled: $("anipyCliFallbackEnabled").value === "true",
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value,
@@ -751,6 +772,7 @@ CONFIG_HTML = r"""
$("downloadDir").value = data.download_dir;
$("configMode").value = data.mode;
$("configQuality").value = data.quality;
+ $("anipyCliFallbackEnabled").value = String(Boolean(data.anipy_cli_fallback_enabled));
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
@@ -960,6 +982,7 @@ CONFIG_HTML = r"""
const data = await api("/api/version");
$("versionLine").textContent = `${data.name} ${data.version}`;
$("aniCliVersionLine").textContent = `ani-cli ${data.ani_cli_version || "Unavailable"}`;
+ $("anipyCliVersionLine").textContent = `anipy-cli ${data.anipy_cli_version || "Unavailable"}`;
}
async function openChangelog() {
diff --git a/http_handler.py b/http_handler.py
index afb6eb2..8af15e2 100644
--- a/http_handler.py
+++ b/http_handler.py
@@ -21,6 +21,7 @@ from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
from app_support import (
ANI_CLI,
+ ANIPY_CLI,
APP_NAME,
CHANGELOG_FILE,
CLIENT_DISCONNECT_ERRORS,
@@ -32,6 +33,7 @@ from app_support import (
debug_enabled,
debug_log,
load_project_text_file,
+ cli_executable_exists,
normalize_config,
path_is_within_roots,
remote_access_allowed,
@@ -87,7 +89,8 @@ def build_handler_class(context):
def dependency_status():
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
result = {name: bool(shutil.which(name)) for name in checks}
- result["ani-cli"] = bool(shutil.which(ANI_CLI) or (Path(ANI_CLI).exists() and os.access(ANI_CLI, os.X_OK)))
+ result["ani-cli"] = cli_executable_exists(ANI_CLI)
+ result["anipy-cli"] = cli_executable_exists(ANIPY_CLI)
return result
@@ -166,6 +169,22 @@ def installed_ani_cli_version():
return version or "Unavailable"
+@lru_cache(maxsize=1)
+def installed_anipy_cli_version():
+ try:
+ completed = subprocess.run(
+ [ANIPY_CLI, "--version"],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=6,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return "Unavailable"
+ version = str(completed.stdout or completed.stderr or "").strip()
+ return version or "Unavailable"
+
+
class Handler(BaseHTTPRequestHandler):
server_version = "AniCliWeb/1.0"
remote_session_cookie_name = "ani_cli_web_session"
@@ -535,7 +554,14 @@ class Handler(BaseHTTPRequestHandler):
elif parsed.path == "/api/config":
self.json(sanitize_public_config(Handler._context(self).get_config_snapshot()))
elif parsed.path == "/api/version":
- self.json({"name": APP_NAME, "version": VERSION, "ani_cli_version": installed_ani_cli_version()})
+ self.json(
+ {
+ "name": APP_NAME,
+ "version": VERSION,
+ "ani_cli_version": installed_ani_cli_version(),
+ "anipy_cli_version": installed_anipy_cli_version(),
+ }
+ )
elif parsed.path == "/api/changelog":
self.json({"content": load_project_text_file(CHANGELOG_FILE, default="Changelog unavailable.")})
elif parsed.path == "/api/dependencies":
diff --git a/queue_jobs.py b/queue_jobs.py
index 7817624..7665bf6 100644
--- a/queue_jobs.py
+++ b/queue_jobs.py
@@ -13,12 +13,14 @@ import time
import re
from app_support import (
+ ANIPY_CLI,
MAX_LOG_LINES,
PROJECT_ROOT,
QUEUE_PATH,
STATE_DB_PATH,
WORKER_SHUTDOWN_TIMEOUT_SECONDS,
build_job,
+ cli_executable_exists,
command_for_job,
debug_log,
finalize_library_files,
@@ -1373,6 +1375,36 @@ class DownloadQueue:
self._save_job_locked(job)
self._cleanup_staging_dir(job)
+ def _command_attempts(self, job, staging_dir):
+ config = normalize_config(self.config_getter() or {})
+ attempts = [
+ {
+ "backend": "ani-cli",
+ "command": command_for_job(job, backend="ani-cli"),
+ "env": {
+ **os.environ.copy(),
+ "ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
+ "ANI_CLI_MODE": job["mode"],
+ "ANI_CLI_QUALITY": job["quality"],
+ "TERM": os.environ.get("TERM", "xterm-256color"),
+ },
+ }
+ ]
+ fallback_enabled = bool(config.get("anipy_cli_fallback_enabled"))
+ fallback_available = cli_executable_exists(ANIPY_CLI)
+ if fallback_enabled and fallback_available:
+ attempts.append(
+ {
+ "backend": "anipy-cli",
+ "command": command_for_job(job, backend="anipy-cli", download_path=staging_dir),
+ "env": {
+ **os.environ.copy(),
+ "TERM": os.environ.get("TERM", "xterm-256color"),
+ },
+ }
+ )
+ return attempts, fallback_enabled, fallback_available
+
def _run(self):
while not self.stop_event.is_set():
job = self._next_pending()
@@ -1383,79 +1415,129 @@ class DownloadQueue:
self._run_job(job)
def _run_job(self, job):
- command = command_for_job(job)
staging_dir = job_staging_dir(job)
target_dir = job_output_dir(job)
- env = os.environ.copy()
- env.update(
- {
- "ANI_CLI_DOWNLOAD_DIR": str(staging_dir),
- "ANI_CLI_MODE": job["mode"],
- "ANI_CLI_QUALITY": job["quality"],
- "TERM": env.get("TERM", "xterm-256color"),
- }
- )
staging_dir.mkdir(parents=True, exist_ok=True)
target_dir.mkdir(parents=True, exist_ok=True)
+ attempts, fallback_enabled, fallback_available = self._command_attempts(job, staging_dir)
+ primary_command = attempts[0]["command"]
with self.lock:
job["status"] = "running"
job["started_at"] = now_iso()
job["updated_at"] = job["started_at"]
- job["command"] = printable_command(command)
+ job["command"] = printable_command(primary_command)
job["staging_dir"] = str(staging_dir)
job["target_dir"] = str(target_dir)
+ job["download_backend"] = "ani-cli"
+ job["fallback_used"] = False
job["log"] = [
f"Starting: {job['command']}",
f"Staging in: {job['staging_dir']}",
f"Final folder: {job['target_dir']}",
]
+ if fallback_enabled and fallback_available:
+ job["log"].append("anipy-cli fallback is enabled and will retry automatically if ani-cli fails.")
+ elif fallback_enabled:
+ job["log"].append("anipy-cli fallback is enabled, but anipy-cli is not installed or not executable.")
job["cancel_requested"] = False
job["_dirty_log_lines"] = 0
self._save_job_locked(job)
try:
- process = subprocess.Popen(
- command,
- cwd=str(PROJECT_ROOT),
- env=env,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- text=True,
- bufsize=1,
- start_new_session=True,
- )
- except Exception as exc:
- self._fail_job_startup(job, exc)
- return
- with self.lock:
- self.current_process = process
- self.current_job_id = job["id"]
- self.current_job = job
- job["pid"] = process.pid
- self._save_job_locked(job)
-
- try:
- assert process.stdout is not None
- for line in process.stdout:
- self._append_log(job, line)
- exit_code = process.wait()
+ exit_code = 1
+ successful_backend = None
finalize_error = None
moved_files = []
watchlist_sync_error = None
synced_watchlist_item = None
cleanup_staging = False
- if exit_code == 0 and not job.get("cancel_requested"):
+ for index, attempt in enumerate(attempts):
+ backend = attempt["backend"]
+ command = attempt["command"]
+ should_fallback = index + 1 < len(attempts)
+ with self.lock:
+ job["download_backend"] = backend
+ job["command"] = printable_command(command)
+ if index > 0:
+ job["fallback_used"] = True
+ job["pid"] = None
+ job["updated_at"] = now_iso()
+ if index > 0:
+ job.setdefault("log", []).append(f"Retrying with {backend}: {job['command']}")
+ self._save_job_locked(job)
try:
- moved_files = finalize_library_files(job)
+ process = subprocess.Popen(
+ command,
+ cwd=str(PROJECT_ROOT),
+ env=attempt["env"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ start_new_session=True,
+ )
except Exception as exc:
- finalize_error = exc
- else:
- if self.watchlist_sync_fn is not None:
- try:
- synced_watchlist_item = self.watchlist_sync_fn(job)
- except Exception as exc:
- watchlist_sync_error = exc
+ if should_fallback:
+ self._append_log(job, f"ani-cli could not start: {exc}")
+ self._append_log(job, "Retrying with anipy-cli fallback.")
+ self._cleanup_staging_dir(job)
+ staging_dir.mkdir(parents=True, exist_ok=True)
+ continue
+ self._fail_job_startup(job, exc)
+ return
+ with self.lock:
+ self.current_process = process
+ self.current_job_id = job["id"]
+ self.current_job = job
+ job["pid"] = process.pid
+ self._save_job_locked(job)
+
+ assert process.stdout is not None
+ for line in process.stdout:
+ self._append_log(job, line)
+ exit_code = process.wait()
+ with self.lock:
+ job["pid"] = None
+ self.current_process = None
+ self.current_job_id = None
+ self.current_job = None
+ self._save_job_locked(job)
+
+ if job.get("cancel_requested"):
+ break
+
+ if backend == "ani-cli" and exit_code != 0 and should_fallback:
+ self._append_log(job, f"ani-cli failed with exit code {exit_code}.")
+ self._append_log(job, "Retrying with anipy-cli fallback.")
+ self._cleanup_staging_dir(job)
+ staging_dir.mkdir(parents=True, exist_ok=True)
+ continue
+
+ if exit_code == 0:
+ try:
+ moved_files = finalize_library_files(job)
+ except Exception as exc:
+ finalize_error = exc
+ no_files_downloaded = "No downloaded files were found in the staging folder" in str(exc)
+ if backend == "ani-cli" and no_files_downloaded and should_fallback:
+ self._append_log(
+ job,
+ "ani-cli exited without downloading any files. Retrying with anipy-cli fallback.",
+ )
+ self._cleanup_staging_dir(job)
+ staging_dir.mkdir(parents=True, exist_ok=True)
+ finalize_error = None
+ continue
+ else:
+ successful_backend = backend
+ if self.watchlist_sync_fn is not None:
+ try:
+ synced_watchlist_item = self.watchlist_sync_fn(job)
+ except Exception as exc:
+ watchlist_sync_error = exc
+ break
+ break
with self.lock:
canceled = job.get("cancel_requested")
@@ -1471,13 +1553,15 @@ class DownloadQueue:
message = str(finalize_error).strip()
if "No downloaded files were found in the staging folder" in message:
job.setdefault("log", []).append(
- "ani-cli exited without downloading any files. The selected result or episodes may not be downloadable."
+ f"{job.get('download_backend') or 'Downloader'} exited without downloading any files. The selected result or episodes may not be downloadable."
)
else:
job.setdefault("log", []).append(f"Library rename failed: {finalize_error}")
cleanup_staging = True
elif exit_code == 0:
job["status"] = "done"
+ if successful_backend == "anipy-cli":
+ job.setdefault("log", []).append("Fallback download completed with anipy-cli.")
for path in moved_files:
job.setdefault("log", []).append(f"Saved: {path}")
job.setdefault("log", []).append("Download completed.")
@@ -1490,7 +1574,9 @@ class DownloadQueue:
job.setdefault("log", []).append("Watchlist download state synced.")
else:
job["status"] = "failed"
- job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.")
+ job.setdefault("log", []).append(
+ f"{job.get('download_backend') or 'Download'} failed with exit code {exit_code}."
+ )
cleanup_staging = True
if canceled:
cleanup_staging = True
diff --git a/test_app.py b/test_app.py
index 5046a1b..d314e91 100644
--- a/test_app.py
+++ b/test_app.py
@@ -771,6 +771,51 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
self.assertIn("Watchlist sync skipped for detached job.", stored["log"])
self.assertNotIn("Watchlist download state synced.", stored["log"])
+ def test_failed_ani_cli_retries_with_anipy_when_enabled(self):
+ queue = APP.DownloadQueue(
+ lambda: {
+ "mode": "sub",
+ "quality": "best",
+ "download_dir": "/tmp/example",
+ "anipy_cli_fallback_enabled": True,
+ },
+ start_worker=False,
+ )
+ job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
+ commands = []
+
+ class FakeProc:
+ def __init__(self, pid, stdout, exit_code):
+ self.pid = pid
+ self.stdout = stdout
+ self._exit_code = exit_code
+
+ def wait(self):
+ return self._exit_code
+
+ processes = [
+ FakeProc(4321, ["ani-cli attempt\n"], 1),
+ FakeProc(5432, ["anipy-cli attempt\n"], 0),
+ ]
+
+ def fake_popen(command, **kwargs):
+ commands.append(command)
+ return processes.pop(0)
+
+ with mock.patch.object(queue_jobs, "cli_executable_exists", return_value=True), mock.patch.object(
+ queue_jobs.subprocess, "Popen", side_effect=fake_popen
+ ), mock.patch.object(
+ queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"]
+ ):
+ queue._run_job(job)
+
+ stored = queue._find(job["id"])
+ self.assertEqual(stored["status"], "done")
+ self.assertTrue(stored["fallback_used"])
+ self.assertEqual(commands[0][0], APP.app_support.ANI_CLI)
+ self.assertEqual(commands[1][0], APP.app_support.ANIPY_CLI)
+ self.assertIn("Fallback download completed with anipy-cli.", stored["log"])
+
class WatchlistRefreshAllTests(unittest.TestCase):
def test_refresh_all_iterates_only_watching_and_planned_show_ids(self):
@@ -1597,6 +1642,23 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertNotIn("-S", command)
+ def test_command_for_job_builds_anipy_fallback_search_spec(self):
+ command = APP.app_support.command_for_job(
+ {
+ "query": "Re:Zero",
+ "quality": "720",
+ "episodes": "1-3",
+ "mode": "dub",
+ },
+ backend="anipy-cli",
+ download_path="/tmp/anipy-staging",
+ )
+
+ self.assertEqual(command[0], APP.app_support.ANIPY_CLI)
+ self.assertEqual(command[1:5], ["-D", "-s", "Re Zero:1-3:dub", "-q"])
+ self.assertIn("-l", command)
+ self.assertEqual(command[-1], "/tmp/anipy-staging")
+
def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self):
item = {
"show_id": "show-42",
@@ -2077,6 +2139,7 @@ class ConfigSnapshotTests(unittest.TestCase):
self.assertFalse(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
+ self.assertFalse(config["anipy_cli_fallback_enabled"])
self.assertFalse(config["auto_download_enabled"])
self.assertEqual(config["auto_download_mode"], "dub")
self.assertEqual(config["auto_download_quality"], "best")
@@ -2089,6 +2152,7 @@ class ConfigSnapshotTests(unittest.TestCase):
def test_normalize_config_parses_watchlist_schedule_fields(self):
config = APP.normalize_config(
{
+ "anipy_cli_fallback_enabled": "true",
"watchlist_auto_refresh_enabled": "true",
"watchlist_auto_refresh_minutes": "15",
"watchlist_refresh_delay_seconds": "7",
@@ -2100,6 +2164,7 @@ class ConfigSnapshotTests(unittest.TestCase):
"jellyfin_movie_dir": "~/jellyfin/movies",
}
)
+ self.assertTrue(config["anipy_cli_fallback_enabled"])
self.assertTrue(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
@@ -2692,10 +2757,11 @@ class HandlerRouteTests(unittest.TestCase):
APP.initialize_runtime(start_workers=False)
def test_config_post_accepts_watchlist_schedule_fields(self):
- body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}'
+ body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","anipy_cli_fallback_enabled":true,"watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}'
handler = DummyHandler("/api/config", body=body, content_length=len(body))
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
+ self.assertTrue(handler.json_payload["anipy_cli_fallback_enabled"])
self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"])
self.assertEqual(handler.json_payload["watchlist_auto_refresh_minutes"], 25)
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9)
@@ -2706,13 +2772,20 @@ class HandlerRouteTests(unittest.TestCase):
self.assertEqual(handler.json_payload["jellyfin_tv_dir"], "/tmp/jellyfin-tv")
self.assertEqual(handler.json_payload["jellyfin_movie_dir"], "/tmp/jellyfin-movies")
- def test_version_route_includes_ani_cli_version(self):
+ def test_version_route_includes_cli_versions(self):
handler = DummyHandler("/api/version")
APP.Handler.do_GET(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["name"], APP.APP_NAME)
self.assertEqual(handler.json_payload["version"], APP.VERSION)
self.assertIn("ani_cli_version", handler.json_payload)
+ self.assertIn("anipy_cli_version", handler.json_payload)
+
+ def test_dependencies_route_reports_anipy_cli_status(self):
+ handler = DummyHandler("/api/dependencies")
+ APP.Handler.do_GET(handler)
+ self.assertEqual(handler.json_status, HTTPStatus.OK)
+ self.assertIn("anipy-cli", handler.json_payload)
def test_changelog_route_returns_project_changelog_content(self):
handler = DummyHandler("/api/changelog")
@@ -3244,7 +3317,10 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn("@media (max-width: 1080px)", APP.CONFIG_HTML)
self.assertIn("justify-items: stretch;", APP.CONFIG_HTML)
self.assertIn('id="aniCliVersionLine"', APP.CONFIG_HTML)
+ self.assertIn('id="anipyCliVersionLine"', APP.CONFIG_HTML)
self.assertIn('ani-cli ${data.ani_cli_version || "Unavailable"}', APP.CONFIG_HTML)
+ self.assertIn('anipy-cli ${data.anipy_cli_version || "Unavailable"}', APP.CONFIG_HTML)
+ self.assertIn('id="anipyCliFallbackEnabled"', APP.CONFIG_HTML)
self.assertIn('id="openChangelogBtn"', APP.CONFIG_HTML)
self.assertIn('id="changelogOverlay"', APP.CONFIG_HTML)
self.assertIn('const data = await api("/api/changelog");', APP.CONFIG_HTML)