diff --git a/CHANGELOG.md b/CHANGELOG.md index 1926f26..ce254cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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. diff --git a/README.md b/README.md index a56835f..9396770 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local web UI for a system-wide `ani-cli` install. -Current version: `0.50.1` +Current version: `0.50.2` ## What it does @@ -121,6 +121,7 @@ Docker notes: - 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 diff --git a/VERSION b/VERSION index a3968ef..967995c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.50.1 +0.50.2 diff --git a/app.py b/app.py index d24076a..dd2ed17 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/queue_jobs.py b/queue_jobs.py index ff80187..0418cfb 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -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: - 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) + with self.lock: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM jobs WHERE status = 'failed'").fetchall() + for row in rows: + 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() diff --git a/test_app.py b/test_app.py index e3a75c6..7aa8b43 100644 --- a/test_app.py +++ b/test_app.py @@ -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": ""}