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:
+111
-54
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user