Archived
Prepare retried jobs before requeue
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-14
@@ -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()
|
||||
|
||||
+90
@@ -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": ""}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user