Fix queue worker failure handling and thumbnail fallbacks
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.29.1 - 2026-05-16
|
||||||
|
|
||||||
|
- Hardened download queue startup so a failed `ani-cli` process launch now marks the job as failed instead of crashing the background queue worker thread.
|
||||||
|
- Cleaned up per-job staging directories after canceled and failed download runs, preventing partial artifacts from piling up under `.ani-cli-web/staging/`.
|
||||||
|
- Fixed thumbnail refresh fallback flow so stale stored AnimeSchedule routes and AniDB IDs now continue on to title-based lookups instead of aborting the whole cover refresh attempt early.
|
||||||
|
- Expanded regression coverage for queue startup failures, failed-download staging cleanup, and stale thumbnail source fallback behavior.
|
||||||
|
|
||||||
## 0.29.0 - 2026-05-16
|
## 0.29.0 - 2026-05-16
|
||||||
|
|
||||||
- Fixed watchlist thumbnail retry backoff so failed automatic cover lookups now record a retry timestamp and stop hammering AnimeSchedule or AniDB on every later warm-up pass.
|
- Fixed watchlist thumbnail retry backoff so failed automatic cover lookups now record a retry timestamp and stop hammering AnimeSchedule or AniDB on every later warm-up pass.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A local web UI for a system-wide `ani-cli` install.
|
A local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.29.0`
|
Current version: `0.29.1`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -85,6 +85,8 @@ The app currently includes:
|
|||||||
- Search-page queue polling also guards against stale out-of-order refresh responses now.
|
- Search-page queue polling also guards against stale out-of-order refresh responses now.
|
||||||
- Queue log writes are batched during active downloads to reduce SQLite churn on noisy jobs.
|
- Queue log writes are batched during active downloads to reduce SQLite churn on noisy jobs.
|
||||||
- Queue persistence now stores core job metadata and logs in structured SQLite columns instead of treating the whole job as one large JSON blob.
|
- Queue persistence now stores core job metadata and logs in structured SQLite columns instead of treating the whole job as one large JSON blob.
|
||||||
|
- Queue worker startup failures now degrade into a normal failed job state instead of crashing the background queue thread.
|
||||||
|
- Failed and canceled downloads now clean their project-local staging directories automatically so partial files do not accumulate under `.ani-cli-web/staging/`.
|
||||||
- Failed thumbnail refresh attempts now record a retry timestamp, so unresolved covers back off instead of immediately requerying upstream sources on every later warm-up.
|
- Failed thumbnail refresh attempts now record a retry timestamp, so unresolved covers back off instead of immediately requerying upstream sources on every later warm-up.
|
||||||
- Download-to-watchlist fallback matching now keeps season and part identity intact while still tolerating equivalent season notation such as `2nd Season` vs `Season 2`.
|
- Download-to-watchlist fallback matching now keeps season and part identity intact while still tolerating equivalent season notation such as `2nd Season` vs `Season 2`.
|
||||||
- Project-local runtime state under `.ani-cli-web/`.
|
- Project-local runtime state under `.ani-cli-web/`.
|
||||||
@@ -214,6 +216,7 @@ Current thumbnail behavior:
|
|||||||
- If a show disappears while a thumbnail warm-up batch is running, that entry is skipped cleanly instead of failing the whole batch request.
|
- If a show disappears while a thumbnail warm-up batch is running, that entry is skipped cleanly instead of failing the whole batch request.
|
||||||
- Thumbnail image tags only hit the local route for already-cached files, so opening the watchlist no longer triggers a per-card remote lookup burst.
|
- Thumbnail image tags only hit the local route for already-cached files, so opening the watchlist no longer triggers a per-card remote lookup burst.
|
||||||
- AnimeSchedule is tried first for cover lookup.
|
- AnimeSchedule is tried first for cover lookup.
|
||||||
|
- Stale stored AnimeSchedule routes and AniDB IDs now fall back to fresh title-based lookups instead of aborting the whole thumbnail refresh attempt.
|
||||||
- If a season-labeled title fails as-is, lookup retries with simplified base-title queries.
|
- If a season-labeled title fails as-is, lookup retries with simplified base-title queries.
|
||||||
- AniDB is used as a fallback when AnimeSchedule does not resolve a cover.
|
- AniDB is used as a fallback when AnimeSchedule does not resolve a cover.
|
||||||
- AniDB fallback reuses the cached parsed title list directly to keep repeated lookups lighter.
|
- AniDB fallback reuses the cached parsed title list directly to keep repeated lookups lighter.
|
||||||
|
|||||||
@@ -1592,8 +1592,6 @@ class WatchlistStore:
|
|||||||
cover = fetch_animeschedule_cover_by_route(stored_route, fallback_title=stored_schedule_title)
|
cover = fetch_animeschedule_cover_by_route(stored_route, fallback_title=stored_schedule_title)
|
||||||
except Exception:
|
except Exception:
|
||||||
debug_log("thumbnail.ensure.route_failed", source="AnimeSchedule", show_id=show_id, route=stored_route)
|
debug_log("thumbnail.ensure.route_failed", source="AnimeSchedule", show_id=show_id, route=stored_route)
|
||||||
if not force:
|
|
||||||
raise
|
|
||||||
if cover is None:
|
if cover is None:
|
||||||
try:
|
try:
|
||||||
debug_log("thumbnail.ensure.try_title", source="AnimeSchedule", show_id=show_id, title=item["title"])
|
debug_log("thumbnail.ensure.try_title", source="AnimeSchedule", show_id=show_id, title=item["title"])
|
||||||
@@ -1609,8 +1607,6 @@ class WatchlistStore:
|
|||||||
cover = fetch_anidb_cover_by_aid(stored_aid, fallback_title=stored_title)
|
cover = fetch_anidb_cover_by_aid(stored_aid, fallback_title=stored_title)
|
||||||
except Exception:
|
except Exception:
|
||||||
debug_log("thumbnail.ensure.route_failed", source="AniDB", show_id=show_id, aid=stored_aid)
|
debug_log("thumbnail.ensure.route_failed", source="AniDB", show_id=show_id, aid=stored_aid)
|
||||||
if not force:
|
|
||||||
raise
|
|
||||||
if cover is None:
|
if cover is None:
|
||||||
debug_log("thumbnail.ensure.try_title", source="AniDB", show_id=show_id, title=item["title"])
|
debug_log("thumbnail.ensure.try_title", source="AniDB", show_id=show_id, title=item["title"])
|
||||||
cover = fetch_anidb_cover(item["title"])
|
cover = fetch_anidb_cover(item["title"])
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ from uuid import uuid4
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||||
APP_NAME = "ani-cli-web"
|
APP_NAME = "ani-cli-web"
|
||||||
VERSION = "0.29.0"
|
VERSION = "0.29.1"
|
||||||
ALLANIME_BASE = "allanime.day"
|
ALLANIME_BASE = "allanime.day"
|
||||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||||
ALLANIME_REFERER = "https://allmanga.to"
|
ALLANIME_REFERER = "https://allmanga.to"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -740,6 +741,33 @@ class DownloadQueue:
|
|||||||
if should_flush:
|
if should_flush:
|
||||||
self._save_job_locked(job)
|
self._save_job_locked(job)
|
||||||
|
|
||||||
|
def _cleanup_staging_dir(self, job):
|
||||||
|
staging_dir = job_staging_dir(job)
|
||||||
|
if not staging_dir.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
shutil.rmtree(staging_dir)
|
||||||
|
debug_log("queue.cleanup.staging_removed", job_id=job.get("id"), staging_dir=staging_dir)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
debug_log("queue.cleanup.staging_failed", job_id=job.get("id"), staging_dir=staging_dir, error=exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _fail_job_startup(self, job, exc):
|
||||||
|
message = f"Could not start download: {exc}"
|
||||||
|
with self.lock:
|
||||||
|
job["status"] = "failed"
|
||||||
|
job["exit_code"] = 1
|
||||||
|
job["pid"] = None
|
||||||
|
job["finished_at"] = now_iso()
|
||||||
|
job["updated_at"] = job["finished_at"]
|
||||||
|
job.setdefault("log", []).append(message)
|
||||||
|
self.current_process = None
|
||||||
|
self.current_job_id = None
|
||||||
|
self.current_job = None
|
||||||
|
self._save_job_locked(job)
|
||||||
|
self._cleanup_staging_dir(job)
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
job = self._next_pending()
|
job = self._next_pending()
|
||||||
@@ -781,6 +809,7 @@ class DownloadQueue:
|
|||||||
job["_dirty_log_lines"] = 0
|
job["_dirty_log_lines"] = 0
|
||||||
self._save_job_locked(job)
|
self._save_job_locked(job)
|
||||||
|
|
||||||
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
cwd=str(PROJECT_ROOT),
|
cwd=str(PROJECT_ROOT),
|
||||||
@@ -791,6 +820,9 @@ class DownloadQueue:
|
|||||||
bufsize=1,
|
bufsize=1,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._fail_job_startup(job, exc)
|
||||||
|
return
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.current_process = process
|
self.current_process = process
|
||||||
self.current_job_id = job["id"]
|
self.current_job_id = job["id"]
|
||||||
@@ -805,6 +837,7 @@ class DownloadQueue:
|
|||||||
finalize_error = None
|
finalize_error = None
|
||||||
moved_files = []
|
moved_files = []
|
||||||
watchlist_sync_error = None
|
watchlist_sync_error = None
|
||||||
|
cleanup_staging = False
|
||||||
if exit_code == 0 and not job.get("cancel_requested"):
|
if exit_code == 0 and not job.get("cancel_requested"):
|
||||||
try:
|
try:
|
||||||
moved_files = finalize_library_files(job)
|
moved_files = finalize_library_files(job)
|
||||||
@@ -841,7 +874,12 @@ class DownloadQueue:
|
|||||||
else:
|
else:
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.")
|
job.setdefault("log", []).append(f"Download failed with exit code {exit_code}.")
|
||||||
|
cleanup_staging = True
|
||||||
|
if canceled:
|
||||||
|
cleanup_staging = True
|
||||||
self.current_process = None
|
self.current_process = None
|
||||||
self.current_job_id = None
|
self.current_job_id = None
|
||||||
self.current_job = None
|
self.current_job = None
|
||||||
self._save_job_locked(job)
|
self._save_job_locked(job)
|
||||||
|
if cleanup_staging:
|
||||||
|
self._cleanup_staging_dir(job)
|
||||||
|
|||||||
+120
@@ -225,6 +225,48 @@ class DownloadQueueCancelTests(unittest.TestCase):
|
|||||||
killpg.assert_called_once_with(4321, queue_jobs.signal.SIGTERM)
|
killpg.assert_called_once_with(4321, queue_jobs.signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
||||||
|
with queue._connect() as conn:
|
||||||
|
conn.execute("DELETE FROM jobs")
|
||||||
|
|
||||||
|
def test_run_job_marks_startup_failure_and_cleans_staging(self):
|
||||||
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
||||||
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
||||||
|
staging_dir = queue_jobs.job_staging_dir(job)
|
||||||
|
|
||||||
|
with mock.patch.object(queue_jobs.subprocess, "Popen", side_effect=FileNotFoundError("ani-cli missing")):
|
||||||
|
queue._run_job(job)
|
||||||
|
|
||||||
|
stored = queue._find(job["id"])
|
||||||
|
self.assertEqual(stored["status"], "failed")
|
||||||
|
self.assertEqual(stored["exit_code"], 1)
|
||||||
|
self.assertIn("Could not start download", stored["log"][-1])
|
||||||
|
self.assertFalse(staging_dir.exists())
|
||||||
|
|
||||||
|
def test_failed_download_cleans_staging_dir(self):
|
||||||
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
||||||
|
job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"})
|
||||||
|
staging_dir = queue_jobs.job_staging_dir(job)
|
||||||
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(staging_dir / "partial.mp4").write_text("partial", encoding="utf-8")
|
||||||
|
|
||||||
|
class FakeProc:
|
||||||
|
pid = 4321
|
||||||
|
stdout = []
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()):
|
||||||
|
queue._run_job(job)
|
||||||
|
|
||||||
|
stored = queue._find(job["id"])
|
||||||
|
self.assertEqual(stored["status"], "failed")
|
||||||
|
self.assertFalse(staging_dir.exists())
|
||||||
|
|
||||||
|
|
||||||
class WatchlistRefreshAllTests(unittest.TestCase):
|
class WatchlistRefreshAllTests(unittest.TestCase):
|
||||||
def test_refresh_all_iterates_every_show_id(self):
|
def test_refresh_all_iterates_every_show_id(self):
|
||||||
store = object.__new__(APP.WatchlistStore)
|
store = object.__new__(APP.WatchlistStore)
|
||||||
@@ -475,6 +517,84 @@ class ThumbnailEventRecoveryTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIsNone(second)
|
self.assertIsNone(second)
|
||||||
|
|
||||||
|
def test_ensure_thumbnail_falls_back_from_stale_animeschedule_route(self):
|
||||||
|
now = APP.now_iso()
|
||||||
|
item = {
|
||||||
|
"show_id": "show-thumb-2",
|
||||||
|
"title": "Route Fallback Show",
|
||||||
|
"category": "watching",
|
||||||
|
"downloaded": False,
|
||||||
|
"status": "tracked",
|
||||||
|
"status_message": "Waiting for thumbnail.",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"last_checked": None,
|
||||||
|
"sub_count": 0,
|
||||||
|
"dub_count": 0,
|
||||||
|
"expected_count": 0,
|
||||||
|
"airing_status": None,
|
||||||
|
"sub_latest_episode": None,
|
||||||
|
"dub_latest_episode": None,
|
||||||
|
"animeschedule_route": "stale-route",
|
||||||
|
"animeschedule_title": "Route Fallback Show",
|
||||||
|
"anidb_aid": None,
|
||||||
|
"anidb_title": None,
|
||||||
|
"thumbnail_path": None,
|
||||||
|
"thumbnail_checked_at": None,
|
||||||
|
}
|
||||||
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
||||||
|
APP.WATCHLIST._upsert_conn(conn, item)
|
||||||
|
|
||||||
|
with mock.patch.object(APP, "fetch_animeschedule_cover_by_route", side_effect=RuntimeError("stale route")), mock.patch.object(
|
||||||
|
APP, "fetch_animeschedule_cover", return_value={"route": "fresh-route", "title": "Route Fallback Show", "url": "https://example.com/cover.jpg"}
|
||||||
|
) as title_lookup, mock.patch.object(APP, "cache_thumbnail_image", return_value="show-thumb-2.jpg"):
|
||||||
|
result = APP.WATCHLIST.ensure_thumbnail("show-thumb-2")
|
||||||
|
|
||||||
|
title_lookup.assert_called_once_with("Route Fallback Show")
|
||||||
|
self.assertEqual(result, APP.THUMBNAIL_ROOT / "show-thumb-2.jpg")
|
||||||
|
refreshed = APP.WATCHLIST.get("show-thumb-2")
|
||||||
|
self.assertEqual(refreshed["animeschedule_route"], "fresh-route")
|
||||||
|
|
||||||
|
def test_ensure_thumbnail_falls_back_from_stale_anidb_aid(self):
|
||||||
|
now = APP.now_iso()
|
||||||
|
item = {
|
||||||
|
"show_id": "show-thumb-3",
|
||||||
|
"title": "AniDB Fallback Show",
|
||||||
|
"category": "watching",
|
||||||
|
"downloaded": False,
|
||||||
|
"status": "tracked",
|
||||||
|
"status_message": "Waiting for thumbnail.",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"last_checked": None,
|
||||||
|
"sub_count": 0,
|
||||||
|
"dub_count": 0,
|
||||||
|
"expected_count": 0,
|
||||||
|
"airing_status": None,
|
||||||
|
"sub_latest_episode": None,
|
||||||
|
"dub_latest_episode": None,
|
||||||
|
"animeschedule_route": None,
|
||||||
|
"animeschedule_title": None,
|
||||||
|
"anidb_aid": "12345",
|
||||||
|
"anidb_title": "AniDB Fallback Show",
|
||||||
|
"thumbnail_path": None,
|
||||||
|
"thumbnail_checked_at": None,
|
||||||
|
}
|
||||||
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
||||||
|
APP.WATCHLIST._upsert_conn(conn, item)
|
||||||
|
|
||||||
|
with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("animeschedule failed")), mock.patch.object(
|
||||||
|
APP, "fetch_anidb_cover_by_aid", side_effect=RuntimeError("stale aid")
|
||||||
|
), mock.patch.object(
|
||||||
|
APP, "fetch_anidb_cover", return_value={"aid": "67890", "title": "AniDB Fallback Show", "url": "https://example.com/cover.jpg"}
|
||||||
|
) as title_lookup, mock.patch.object(APP, "cache_thumbnail_image", return_value="show-thumb-3.jpg"):
|
||||||
|
result = APP.WATCHLIST.ensure_thumbnail("show-thumb-3")
|
||||||
|
|
||||||
|
title_lookup.assert_called_once_with("AniDB Fallback Show")
|
||||||
|
self.assertEqual(result, APP.THUMBNAIL_ROOT / "show-thumb-3.jpg")
|
||||||
|
refreshed = APP.WATCHLIST.get("show-thumb-3")
|
||||||
|
self.assertEqual(refreshed["anidb_aid"], "67890")
|
||||||
|
|
||||||
|
|
||||||
class WatchlistCompletionTests(unittest.TestCase):
|
class WatchlistCompletionTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user