diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a20a1a..efef317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 2c2fda7..1d1c10b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index 43c125a..c063aea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.2 +0.46.3 diff --git a/queue_jobs.py b/queue_jobs.py index cd63f85..6aac511 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -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, diff --git a/test_app.py b/test_app.py index 6fcb6c3..ff1a2f9 100644 --- a/test_app.py +++ b/test_app.py @@ -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)