diff --git a/CHANGELOG.md b/CHANGELOG.md index efef317..12426a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.46.4 - 2026-07-09 + +- Improved backup `anikoto-cli` search handling by preferring the canonical queued title over provider-specific source overrides and retrying with alternate title variants when AniList search misses the first query. + ## 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`. diff --git a/README.md b/README.md index 1d1c10b..f0c2265 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.3` +Current version: `0.46.4` ## What it does @@ -126,6 +126,7 @@ Docker notes: - 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 +- Backup retries also prefer the canonical queued anime title and can try alternate title variants if a source-specific search name does not exist on AniList ### Jellyfin handoff diff --git a/VERSION b/VERSION index c063aea..fadda05 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.3 +0.46.4 diff --git a/app_support.py b/app_support.py index 8f60032..c5127a3 100644 --- a/app_support.py +++ b/app_support.py @@ -1161,7 +1161,8 @@ def build_job(payload, config): } -def command_for_job(job, downloader="ani-cli"): +def command_for_job(job, downloader="ani-cli", query_override=None): + search_query = str(query_override or job.get("query") or job.get("title") or "").strip() if str(downloader or "ani-cli").strip().lower() == "anikoto-cli": command = [ ANIKOTO_CLI, @@ -1173,7 +1174,7 @@ def command_for_job(job, downloader="ani-cli"): command.append("--dub") else: command.append("--sub") - command.append(job["query"]) + command.append(search_query) return command command = [ @@ -1186,5 +1187,5 @@ def command_for_job(job, downloader="ani-cli"): ] if job["mode"] == "dub": command.append("--dub") - command.append(job["query"]) + command.append(search_query) return command diff --git a/queue_jobs.py b/queue_jobs.py index 6aac511..f6e699a 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -33,6 +33,7 @@ from app_support import ( sh_quote, strip_control, ) +from title_matching import title_lookup_queries LOG_PERSIST_INTERVAL_SECONDS = 0.75 @@ -1347,10 +1348,19 @@ 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): + def _backup_search_queries(self, job): + queries = [] + for value in (job.get("title"), job.get("query"), job.get("anime_name")): + for candidate in title_lookup_queries(value): + text = str(candidate or "").strip() + if text and text not in queries: + queries.append(text) + return queries or [str(job.get("query") or job.get("title") or "").strip()] + + def _attempt_env(self, base_env, downloader_name, search_query): attempt_env = dict(base_env) if downloader_name == "anikoto-cli": - query = str(job.get("query") or job.get("title") or "").strip() + query = str(search_query 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. @@ -1451,7 +1461,6 @@ class DownloadQueue: 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) @@ -1461,80 +1470,97 @@ class DownloadQueue: 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']}") + search_queries = [str(job.get("query") or "").strip()] + if downloader_name == "anikoto-cli": + search_queries = self._backup_search_queries(job) - try: - attempt_env = self._attempt_env(env, job, downloader_name) - 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, + for query_index, search_query in enumerate(search_queries): + if downloader_name == "anikoto-cli" and query_index > 0: + self._cleanup_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + self._append_log(job, f"Trying alternate anikoto-cli search query: {search_query}") + + command = command_builder(job) if downloader_name != "anikoto-cli" else command_for_job( + job, + downloader="anikoto-cli", + query_override=search_query, ) - 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) + 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']}") - 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: - 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) + attempt_env = self._attempt_env(env, downloader_name, search_query) + 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: - watchlist_sync_error = exc - break + 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: + 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 + if canceled or download_succeeded: + break with self.lock: job["exit_code"] = last_exit_code diff --git a/test_app.py b/test_app.py index ff1a2f9..d6b214c 100644 --- a/test_app.py +++ b/test_app.py @@ -679,7 +679,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase): }, start_worker=False, ) - job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) + job = queue.add({"query": "Queue Source", "title": "Queue Show", "season": "1", "episodes": "1"}) class FailedProc: pid = 1111 @@ -721,6 +721,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase): 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"]) + self.assertEqual(popen_calls[1]["command"][-1], "Queue Show") 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)