Improve anikoto backup search fallback

- prefer the canonical queued title for anikoto-cli backup searches instead of relying only on the ani-cli source query
- retry backup searches with alternate title variants when AniList misses the first lookup
- bump the project version to 0.46.4 and document the fix in the changelog and README
This commit is contained in:
Dymas
2026-07-09 13:39:44 +02:00
parent 897e12e019
commit 34da6b26c5
6 changed files with 111 additions and 78 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog # 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 ## 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`. - 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`.
+2 -1
View File
@@ -2,7 +2,7 @@
Local web UI for a system-wide `ani-cli` install. Local web UI for a system-wide `ani-cli` install.
Current version: `0.46.3` Current version: `0.46.4`
## What it does ## What it does
@@ -126,6 +126,7 @@ Docker notes:
- When enabled, any failed `ani-cli` queue job automatically retries with `anikoto-cli` - 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 - `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 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 ### Jellyfin handoff
+1 -1
View File
@@ -1 +1 @@
0.46.3 0.46.4
+4 -3
View File
@@ -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": if str(downloader or "ani-cli").strip().lower() == "anikoto-cli":
command = [ command = [
ANIKOTO_CLI, ANIKOTO_CLI,
@@ -1173,7 +1174,7 @@ def command_for_job(job, downloader="ani-cli"):
command.append("--dub") command.append("--dub")
else: else:
command.append("--sub") command.append("--sub")
command.append(job["query"]) command.append(search_query)
return command return command
command = [ command = [
@@ -1186,5 +1187,5 @@ def command_for_job(job, downloader="ani-cli"):
] ]
if job["mode"] == "dub": if job["mode"] == "dub":
command.append("--dub") command.append("--dub")
command.append(job["query"]) command.append(search_query)
return command return command
+30 -4
View File
@@ -33,6 +33,7 @@ from app_support import (
sh_quote, sh_quote,
strip_control, strip_control,
) )
from title_matching import title_lookup_queries
LOG_PERSIST_INTERVAL_SECONDS = 0.75 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"))) attempts.append(("anikoto-cli", lambda job: command_for_job(job, downloader="anikoto-cli")))
return attempts 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) attempt_env = dict(base_env)
if downloader_name == "anikoto-cli": if downloader_name == "anikoto-cli":
query = str(job.get("query") or job.get("title") or "").strip() query = str(search_query or "").strip()
if query: if query:
# Force fzf into non-interactive exact-match mode so backup downloads # Force fzf into non-interactive exact-match mode so backup downloads
# can auto-select the intended anime inside the queue worker. # can auto-select the intended anime inside the queue worker.
@@ -1451,7 +1461,6 @@ class DownloadQueue:
download_succeeded = False download_succeeded = False
for attempt_index, (downloader_name, command_builder) in enumerate(attempts): for attempt_index, (downloader_name, command_builder) in enumerate(attempts):
command = command_builder(job)
if attempt_index > 0: if attempt_index > 0:
self._cleanup_staging_dir(job) self._cleanup_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True) staging_dir.mkdir(parents=True, exist_ok=True)
@@ -1461,6 +1470,21 @@ class DownloadQueue:
self._append_log(job, "Backup downloader anikoto-cli is not installed or not executable.") self._append_log(job, "Backup downloader anikoto-cli is not installed or not executable.")
continue continue
search_queries = [str(job.get("query") or "").strip()]
if downloader_name == "anikoto-cli":
search_queries = self._backup_search_queries(job)
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,
)
with self.lock: with self.lock:
job["command"] = printable_command(command) job["command"] = printable_command(command)
job["updated_at"] = now_iso() job["updated_at"] = now_iso()
@@ -1468,7 +1492,7 @@ class DownloadQueue:
self._append_log(job, f"Launching {downloader_name}: {job['command']}") self._append_log(job, f"Launching {downloader_name}: {job['command']}")
try: try:
attempt_env = self._attempt_env(env, job, downloader_name) attempt_env = self._attempt_env(env, downloader_name, search_query)
process = subprocess.Popen( process = subprocess.Popen(
command, command,
cwd=str(PROJECT_ROOT), cwd=str(PROJECT_ROOT),
@@ -1535,6 +1559,8 @@ class DownloadQueue:
except Exception as exc: except Exception as exc:
watchlist_sync_error = exc watchlist_sync_error = exc
break break
if canceled or download_succeeded:
break
with self.lock: with self.lock:
job["exit_code"] = last_exit_code job["exit_code"] = last_exit_code
+2 -1
View File
@@ -679,7 +679,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
}, },
start_worker=False, 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: class FailedProc:
pid = 1111 pid = 1111
@@ -721,6 +721,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
self.assertIn("--exact", popen_calls[1]["env"]["FZF_DEFAULT_OPTS"]) self.assertIn("--exact", popen_calls[1]["env"]["FZF_DEFAULT_OPTS"])
self.assertIn("--select-1", 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.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): 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) queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)