Fix non-interactive anikoto backup downloads

- force anikoto-cli backup runs to auto-select the queued anime title through fzf filter options
- add a regression test for the backup retry environment
- bump the project version to 0.46.3 and document the fix in the changelog and README
This commit is contained in:
Dymas
2026-07-09 13:28:36 +02:00
parent 65a1bc1233
commit 897e12e019
5 changed files with 36 additions and 4 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog
## 0.46.3 - 2026-07-09
- Fixed backup `anikoto-cli` queue runs to force `fzf` into exact-match auto-select mode, which keeps the fallback downloader non-interactive inside the background worker instead of failing with `nothing selected` and `inappropriate ioctl for device`.
## 0.46.2 - 2026-07-09
- Changed the backup downloader setting on the Config page from a hard-to-spot select field into a dedicated checkbox toggle so the enable/disable control is clearly visible.
+2 -1
View File
@@ -2,7 +2,7 @@
Local web UI for a system-wide `ani-cli` install.
Current version: `0.46.2`
Current version: `0.46.3`
## What it does
@@ -125,6 +125,7 @@ Docker notes:
- Configure the global backup toggle from `/config`
- When enabled, any failed `ani-cli` queue job automatically retries with `anikoto-cli`
- `anikoto-cli` ignores the saved quality setting, so backup attempts use the source's default stream quality
- Backup retries now auto-select the queued anime title non-interactively so the fallback works inside the background download worker
### Jellyfin handoff
+1 -1
View File
@@ -1 +1 @@
0.46.2
0.46.3
+15 -1
View File
@@ -30,6 +30,7 @@ from app_support import (
now_iso,
printable_command,
send_discord_webhook_event,
sh_quote,
strip_control,
)
@@ -1346,6 +1347,18 @@ class DownloadQueue:
attempts.append(("anikoto-cli", lambda job: command_for_job(job, downloader="anikoto-cli")))
return attempts
def _attempt_env(self, base_env, job, downloader_name):
attempt_env = dict(base_env)
if downloader_name == "anikoto-cli":
query = str(job.get("query") or job.get("title") or "").strip()
if query:
# Force fzf into non-interactive exact-match mode so backup downloads
# can auto-select the intended anime inside the queue worker.
attempt_env["FZF_DEFAULT_OPTS"] = (
f"--exact --filter={sh_quote(query)} --select-1 --exit-0"
)
return attempt_env
def _fail_job_startup(self, job, exc):
message = f"Could not start download: {exc}"
with self.lock:
@@ -1455,10 +1468,11 @@ class DownloadQueue:
self._append_log(job, f"Launching {downloader_name}: {job['command']}")
try:
attempt_env = self._attempt_env(env, job, downloader_name)
process = subprocess.Popen(
command,
cwd=str(PROJECT_ROOT),
env=env,
env=attempt_env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
+14 -1
View File
@@ -695,7 +695,15 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
def wait(self):
return 0
with mock.patch.object(queue_jobs.subprocess, "Popen", side_effect=[FailedProc(), SuccessProc()]), mock.patch.object(
popen_calls = []
def fake_popen(command, **kwargs):
popen_calls.append({"command": command, "env": dict(kwargs.get("env") or {})})
if len(popen_calls) == 1:
return FailedProc()
return SuccessProc()
with mock.patch.object(queue_jobs.subprocess, "Popen", side_effect=fake_popen), mock.patch.object(
queue, "_command_available", return_value=True
), mock.patch.object(
queue_jobs, "finalize_library_files", return_value=["/tmp/example/tv/Queue Show/Season 01/Queue Show - S01E01.mp4"]
@@ -708,6 +716,11 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
self.assertIn("Retrying with backup downloader: anikoto-cli.", log_text)
self.assertIn("anikoto-cli does not support ani-cli quality flags", log_text)
self.assertIn("Download completed.", log_text)
self.assertEqual(len(popen_calls), 2)
self.assertNotIn("FZF_DEFAULT_OPTS", popen_calls[0]["env"])
self.assertIn("--exact", popen_calls[1]["env"]["FZF_DEFAULT_OPTS"])
self.assertIn("--select-1", popen_calls[1]["env"]["FZF_DEFAULT_OPTS"])
self.assertIn("Queue Show", popen_calls[1]["env"]["FZF_DEFAULT_OPTS"])
def test_run_job_marks_post_start_exception_failed_without_killing_worker_state(self):
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)