Add anikoto backup downloader

- add a config toggle for backup downloads and retry failed ani-cli jobs with anikoto-cli
- install anikoto-cli systemwide in the container with its required dependencies
- bump the project version to 0.46.1 and publish the release notes
This commit is contained in:
Dymas
2026-07-09 12:54:14 +02:00
parent 332ec670fc
commit 4dd56b72f6
10 changed files with 268 additions and 62 deletions
+111 -54
View File
@@ -13,6 +13,7 @@ import time
import re
from app_support import (
ANIKOTO_CLI,
MAX_LOG_LINES,
PROJECT_ROOT,
QUEUE_PATH,
@@ -1335,6 +1336,16 @@ class DownloadQueue:
debug_log("queue.cleanup.staging_failed", job_id=job.get("id"), staging_dir=staging_dir, error=exc)
return False
def _command_available(self, command_name):
return bool(shutil.which(command_name) or (os.path.exists(command_name) and os.access(command_name, os.X_OK)))
def _download_attempts(self):
config = normalize_config(self.config_getter() or {})
attempts = [("ani-cli", command_for_job)]
if config.get("download_backup_enabled"):
attempts.append(("anikoto-cli", lambda job: command_for_job(job, downloader="anikoto-cli")))
return attempts
def _fail_job_startup(self, job, exc):
message = f"Could not start download: {exc}"
with self.lock:
@@ -1383,7 +1394,6 @@ 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()
@@ -1397,12 +1407,14 @@ class DownloadQueue:
)
staging_dir.mkdir(parents=True, exist_ok=True)
target_dir.mkdir(parents=True, exist_ok=True)
attempts = self._download_attempts()
primary_command = attempts[0][1](job)
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["log"] = [
@@ -1410,73 +1422,117 @@ class DownloadQueue:
f"Staging in: {job['staging_dir']}",
f"Final folder: {job['target_dir']}",
]
if len(attempts) > 1:
job["log"].append("Backup downloads are enabled. Failed ani-cli jobs will retry with anikoto-cli.")
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()
finalize_error = None
last_exit_code = 1
last_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"):
canceled = False
download_succeeded = False
for attempt_index, (downloader_name, command_builder) in enumerate(attempts):
command = command_builder(job)
if attempt_index > 0:
self._cleanup_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True)
self._append_log(job, f"Retrying with backup downloader: {downloader_name}.")
self._append_log(job, "anikoto-cli does not support ani-cli quality flags, so the saved quality preference is ignored for the backup attempt.")
if downloader_name == "anikoto-cli" and not self._command_available(ANIKOTO_CLI):
self._append_log(job, "Backup downloader anikoto-cli is not installed or not executable.")
continue
with self.lock:
job["command"] = printable_command(command)
job["updated_at"] = now_iso()
self._save_job_locked(job)
self._append_log(job, f"Launching {downloader_name}: {job['command']}")
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:
last_exit_code = 1
last_finalize_error = None
self._append_log(job, f"Could not start {downloader_name}: {exc}")
continue
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)
canceled = bool(job.get("cancel_requested"))
if canceled:
last_exit_code = exit_code
break
if exit_code != 0:
last_exit_code = exit_code
last_finalize_error = None
self._append_log(job, f"{downloader_name} failed with exit code {exit_code}.")
continue
try:
moved_files = finalize_library_files(job)
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
last_exit_code = 1
last_finalize_error = exc
message = str(exc).strip()
if "No downloaded files were found in the staging folder" in message:
self._append_log(
job,
f"{downloader_name} exited without downloading any files. The selected result or episodes may not be downloadable.",
)
else:
self._append_log(job, f"Library rename failed after {downloader_name}: {exc}")
continue
download_succeeded = True
last_exit_code = 0
last_finalize_error = None
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
with self.lock:
canceled = job.get("cancel_requested")
job["exit_code"] = 1 if finalize_error else exit_code
job["exit_code"] = last_exit_code
job["pid"] = None
job["finished_at"] = now_iso()
job["updated_at"] = job["finished_at"]
cleanup_staging = False
if canceled:
job["status"] = "canceled"
job.setdefault("log", []).append("Canceled.")
elif finalize_error:
job["status"] = "failed"
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."
)
else:
job.setdefault("log", []).append(f"Library rename failed: {finalize_error}")
cleanup_staging = True
elif exit_code == 0:
elif download_succeeded:
job["status"] = "done"
for path in moved_files:
job.setdefault("log", []).append(f"Saved: {path}")
@@ -1490,9 +1546,10 @@ 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}.")
cleanup_staging = True
if canceled:
if last_finalize_error and "No downloaded files were found in the staging folder" not in str(last_finalize_error):
job.setdefault("log", []).append(f"Download failed after all downloaders were tried: {last_finalize_error}")
else:
job.setdefault("log", []).append("Download failed after all configured downloaders were tried.")
cleanup_staging = True
self.current_process = None
self.current_job_id = None
@@ -1500,7 +1557,7 @@ class DownloadQueue:
self._save_job_locked(job)
if cleanup_staging:
self._cleanup_staging_dir(job)
elif exit_code == 0 and not finalize_error and not canceled:
elif download_succeeded:
download_title = (
str((synced_watchlist_item or {}).get("title") or "").strip()
or str(job.get("title") or "").strip()
@@ -1524,4 +1581,4 @@ class DownloadQueue:
except Exception as exc:
debug_log("discord_webhook.download_completed_failed", title=download_title, error=exc)
except Exception as exc:
self._fail_job_runtime(job, exc, process=process)
self._fail_job_runtime(job, exc)