Compare commits

...
3 Commits
Author SHA1 Message Date
Codex f417a96811 Prepare retried jobs before requeue 2026-08-01 17:51:59 +02:00
Codex edb565d855 AGENTS.md updated 2026-08-01 17:40:42 +02:00
Codex 32fc0f2829 Pass selected ani-cli result to queue jobs 2026-08-01 17:39:36 +02:00
9 changed files with 195 additions and 25 deletions
+1 -1
View File
@@ -3,5 +3,5 @@
- when needed you may install any tools using pip if the project reqires it
- you can freely acces any LAN URL and github.com URL.
- always update README.md to reflect current project.
- always commit changes with commentary
- always commit changes with commentary and push
- always make sure that update wont wipe ani data in a previosly running instance like configuration or watchlist content
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 0.50.2 - 2026-08-01
- Re-ran queue job preparation when retrying failed or canceled downloads, letting retries repair missing `ani-cli` result selections before launching.
- Added result-index inference for older or watchlist-created jobs by matching the stored show ID, or a single confident title match, against current provider search results.
## 0.50.1 - 2026-08-01
- Passed the selected Search result number through queued `ani-cli` jobs as `-S`, so downloads avoid the interactive series picker when multiple similar titles are returned.
- Preserved the selected result index in queued jobs for more reliable watchlist identity sync after ambiguous downloads.
## 0.50.0 - 2026-08-01
- Added `curl-impersonate` to Docker images using the maintained `lexiforest/curl-impersonate` image so `ani-cli` v5 can use browser-like curl wrappers before falling back to plain `curl`.
+4 -2
View File
@@ -2,12 +2,12 @@
Local web UI for a system-wide `ani-cli` install.
Current version: `0.50.0`
Current version: `0.50.2`
## What it does
- Search anime in `sub` or `dub`, with poster-style results shown below the selection panel and the first English alternate title when available
- Queue downloads with folder, quality, media type, season, and episode controls
- Queue downloads with folder, quality, media type, season, episode controls, and the selected upstream search result when a Search card is used
- Choose which download backends queue jobs may use: `ani-cli`, `anipy-cli`, and `animdl`
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
- Expose a small JSON summary endpoint for Homepage dashboard widgets
@@ -120,6 +120,8 @@ Docker notes:
- Selecting one method disables fallback retry; selecting multiple methods makes later methods automatic fallbacks
- Queue `ani-cli` downloads explicitly run with `ANI_CLI_PLAYER=download` so headless Docker installs do not need `mpv` or `vlc` just to save files
- Docker images include `curl-impersonate` wrappers before plain `curl` on `PATH`, matching the preference order used by `ani-cli` v5
- Queue jobs created from Search pass the selected result number to `ani-cli -S`, avoiding the interactive series picker when the provider returns multiple similar titles
- Retried failed jobs are prepared again before running, so older or watchlist-created jobs can infer and persist an `ani-cli -S` result when the stored show ID or title maps cleanly to a provider search result
- Fallback retries keep the queued episode numbers when fallback downloader filenames are renumbered from `1`
- Docker images install `anipy-cli` and `animdl` automatically; non-Docker installs should provide the selected tools in `PATH` themselves
+1 -1
View File
@@ -1 +1 @@
0.50.0
0.50.2
+39 -1
View File
@@ -58,6 +58,7 @@ from app_support import (
normalize_config,
normalize_episode_offset,
normalize_media_type,
normalize_result_index,
now_iso,
remote_access_session_fingerprint,
sanitize_path_component,
@@ -74,7 +75,10 @@ from title_matching import (
title_lookup_queries,
title_match_variants,
)
from watchlist_identity import resolve_job_watchlist_identity as resolve_job_watchlist_identity_impl
from watchlist_identity import (
resolve_job_watchlist_identity as resolve_job_watchlist_identity_impl,
titles_confidently_match,
)
from web_templates import CONFIG_HTML, INDEX_HTML, PAGE_LINKS, QUEUE_HTML, WATCHLIST_HTML, render_page_links, render_sidebar_brand
RUNTIME_LOCK = threading.RLock()
@@ -3007,6 +3011,10 @@ def sync_downloaded_job_to_watchlist(job):
def prepare_download_job(job):
config = ensure_runtime()["config"]
if normalize_result_index(job.get("result_index")) is None:
result_index = infer_download_job_result_index(job, config)
if result_index is not None:
job["result_index"] = result_index
show_id, title, reason = resolve_job_watchlist_identity_impl(
job,
search_fn=search_anime,
@@ -3024,6 +3032,36 @@ def prepare_download_job(job):
return job
def infer_download_job_result_index(job, config):
query = str(job.get("query") or job.get("title") or job.get("anime_name") or "").strip()
mode = str(job.get("mode") or config.get("mode") or "sub").strip().lower()
if not query or mode not in MODE_CHOICES:
return None
try:
results = search_anime(query, mode)
except Exception as exc:
debug_log("watchlist.prepare_job.result_index_search_failed", job=job, error=exc)
return None
show_id = str(job.get("show_id") or "").strip()
if show_id:
for position, result in enumerate(results, start=1):
if str(result.get("id") or "").strip() == show_id:
return position
title = str(job.get("title") or job.get("anime_name") or query).strip()
matches = []
for position, result in enumerate(results, start=1):
candidate_title = str(result.get("title") or "").strip()
if titles_confidently_match(title, candidate_title, normalize_identity_title_key, base_title_variants):
matches.append(position)
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
debug_log("watchlist.prepare_job.result_index_ambiguous", job=job, matches=matches)
return None
def get_config_snapshot(fallback=None):
with RUNTIME_LOCK:
source = CONFIG if CONFIG is not None else fallback
+16 -1
View File
@@ -1216,7 +1216,7 @@ def build_job(payload, config):
"season": normalize_season(payload.get("season")),
"episode_offset": normalize_episode_offset(payload.get("episode_offset")),
"query": query,
"result_index": None,
"result_index": normalize_result_index(payload.get("result_index")),
"mode": mode,
"quality": quality,
"episodes": validate_episode_spec(payload.get("episodes")),
@@ -1232,6 +1232,18 @@ def build_job(payload, config):
}
def normalize_result_index(value):
if value in (None, ""):
return None
try:
index = int(str(value).strip())
except (TypeError, ValueError):
return None
if index < 1:
return None
return index
def anipy_search_spec(job):
query = re.sub(r"\s+", " ", str(job.get("query") or "").replace(":", " ")).strip()
mode = "dub" if str(job.get("mode") or "").strip().lower() == "dub" else "sub"
@@ -1276,6 +1288,9 @@ def command_for_job(job, backend="ani-cli", download_path=None):
"-e",
job["episodes"],
]
result_index = normalize_result_index(job.get("result_index"))
if result_index is not None:
command.extend(["-S", str(result_index)])
if job["mode"] == "dub":
command.append("--dub")
command.append(job["query"])
+14 -14
View File
@@ -1258,12 +1258,15 @@ class DownloadQueue:
self._save_job_locked(job)
return job
def _prepare_job(self, job):
if self.job_prepare_fn is None:
return job
prepared = self.job_prepare_fn(dict(job))
return prepared if prepared is not None else job
def add(self, payload):
job = build_job(payload, self.config_getter())
if self.job_prepare_fn is not None:
prepared = self.job_prepare_fn(dict(job))
if prepared is not None:
job = prepared
job = self._prepare_job(job)
with self.lock:
reusable = self._find_reusable_failed_job_locked(job)
if reusable is not None:
@@ -1347,24 +1350,21 @@ class DownloadQueue:
raise ValueError("Running jobs cannot be retried")
if job["status"] not in {"failed", "canceled"}:
raise ValueError("Only failed or canceled jobs can be retried")
job = self._prepare_job(job)
with self.lock:
job = self._reset_retryable_job_locked(job)
self.wakeup.set()
return job
def retry_all_failed(self):
count = 0
with self.lock, self._connect() as conn:
with self.lock:
with self._connect() as conn:
rows = conn.execute("SELECT * FROM jobs WHERE status = 'failed'").fetchall()
for row in rows:
job = self._job_from_row(row)
job["status"] = "pending"
job["exit_code"] = None
job["pid"] = None
job["started_at"] = None
job["finished_at"] = None
job["updated_at"] = now_iso()
job["log"] = []
self._upsert_job_conn(conn, job)
job = self._prepare_job(self._job_from_row(row))
with self.lock:
self._reset_retryable_job_locked(job)
count += 1
if count:
self.wakeup.set()
+1
View File
@@ -688,6 +688,7 @@ INDEX_HTML = r"""<!doctype html>
const payload = {
show_id: state.selected.id,
query: state.selected.title,
result_index: state.selected.index,
title: state.selected.title,
anime_name: $("animeNameInput").value || state.selected.title,
media_type: $("trackMediaType").value,
+107 -3
View File
@@ -532,6 +532,66 @@ class QueueApiTests(unittest.TestCase):
self.assertEqual(retried["status"], "pending")
self.assertFalse(retried["cancel_requested"])
def test_retry_prepares_failed_job_before_requeue(self):
queue = APP.DownloadQueue(
lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
job_prepare_fn=lambda job: {**job, "result_index": 3},
start_worker=False,
)
created = queue.add(
{
"query": "Retry Show",
"title": "Retry Show",
"anime_name": "Retry Show",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"season": "1",
}
)
with queue.lock:
job = queue._find(created["id"])
job["status"] = "failed"
job["result_index"] = None
queue._save_job_locked(job)
retried = queue.retry(created["id"])
self.assertEqual(retried["status"], "pending")
self.assertEqual(retried["result_index"], 3)
def test_retry_all_failed_prepares_jobs_before_requeue(self):
queue = APP.DownloadQueue(
lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
job_prepare_fn=lambda job: {**job, "result_index": 4},
start_worker=False,
)
created = queue.add(
{
"query": "Retry All Show",
"title": "Retry All Show",
"anime_name": "Retry All Show",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"season": "1",
}
)
with queue.lock:
job = queue._find(created["id"])
job["status"] = "failed"
job["result_index"] = None
queue._save_job_locked(job)
result = queue.retry_all_failed()
retried = queue.get(created["id"])
self.assertEqual(result["count"], 1)
self.assertEqual(retried["status"], "pending")
self.assertEqual(retried["result_index"], 4)
def test_add_reuses_matching_failed_job_instead_of_creating_duplicate(self):
payload = {
"show_id": "show-42",
@@ -1887,6 +1947,36 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(prepared["show_id"], "show-42")
self.assertEqual(prepared["title"], "Queue Show")
def test_prepare_download_job_infers_result_index_from_show_id(self):
job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": "show-99"}
with mock.patch.object(
APP,
"search_anime",
return_value=[
{"id": "show-42", "title": "Queue Show"},
{"id": "show-99", "title": "Queue Show Season 4"},
],
):
prepared = APP.prepare_download_job(dict(job))
self.assertEqual(prepared["result_index"], 2)
def test_prepare_download_job_infers_result_index_from_unique_title_match(self):
job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""}
with mock.patch.object(
APP,
"search_anime",
return_value=[
{"id": "show-42", "title": "Queue Show"},
{"id": "show-99", "title": "Different Show"},
],
):
prepared = APP.prepare_download_job(dict(job))
self.assertEqual(prepared["result_index"], 1)
def test_prepare_download_job_accepts_equivalent_season_notation(self):
job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""}
@@ -1943,7 +2033,7 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertNotIn("-S", APP.app_support.command_for_job(job))
def test_command_for_job_ignores_legacy_result_index(self):
def test_command_for_job_includes_result_switch_for_selected_search_result(self):
command = APP.app_support.command_for_job(
{
"query": "Queue Show",
@@ -1954,6 +2044,20 @@ class WatchlistCompletionTests(unittest.TestCase):
}
)
self.assertIn("-S", command)
self.assertEqual(command[command.index("-S") + 1], "2")
def test_command_for_job_ignores_invalid_result_index(self):
command = APP.app_support.command_for_job(
{
"query": "Queue Show",
"quality": "best",
"episodes": "1-10",
"mode": "dub",
"result_index": "nope",
}
)
self.assertNotIn("-S", command)
def test_command_for_job_builds_anipy_fallback_search_spec(self):
@@ -4018,9 +4122,9 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn('const progress = `${job.completed || 0}/${job.total || 0} entries`;', APP.QUEUE_HTML)
self.assertIn('const moved = `${job.moved || 0} moved`;', APP.QUEUE_HTML)
def test_search_page_queues_selected_title_without_result_index(self):
def test_search_page_queues_selected_title_with_result_index(self):
self.assertIn('query: state.selected.title,', APP.INDEX_HTML)
self.assertNotIn('index: state.selected.index,', APP.INDEX_HTML)
self.assertIn('result_index: state.selected.index,', APP.INDEX_HTML)
def test_search_page_renders_poster_results_under_selection_panel(self):
self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML)