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:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+98
-72
@@ -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
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user