diff --git a/CHANGELOG.md b/CHANGELOG.md index 583e0fb..4f51243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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 - 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. diff --git a/README.md b/README.md index 443a94c..b3e77e2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A local web UI for a system-wide `ani-cli` install. -Current version: `0.29.0` +Current version: `0.29.1` ## Project Layout @@ -85,6 +85,8 @@ The app currently includes: - 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 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. - 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/`. @@ -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. - 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. +- 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. - 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. diff --git a/VERSION b/VERSION index ae6dd4e..25939d3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.29.0 +0.29.1 diff --git a/app.py b/app.py index c2e8b6b..cd18122 100644 --- a/app.py +++ b/app.py @@ -1592,8 +1592,6 @@ class WatchlistStore: cover = fetch_animeschedule_cover_by_route(stored_route, fallback_title=stored_schedule_title) except Exception: debug_log("thumbnail.ensure.route_failed", source="AnimeSchedule", show_id=show_id, route=stored_route) - if not force: - raise if cover is None: try: 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) except Exception: debug_log("thumbnail.ensure.route_failed", source="AniDB", show_id=show_id, aid=stored_aid) - if not force: - raise if cover is None: debug_log("thumbnail.ensure.try_title", source="AniDB", show_id=show_id, title=item["title"]) cover = fetch_anidb_cover(item["title"]) diff --git a/app_support.py b/app_support.py index 646f823..c22f591 100644 --- a/app_support.py +++ b/app_support.py @@ -16,7 +16,7 @@ from uuid import uuid4 PROJECT_ROOT = Path(__file__).resolve().parent ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" APP_NAME = "ani-cli-web" -VERSION = "0.29.0" +VERSION = "0.29.1" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/queue_jobs.py b/queue_jobs.py index 6267091..918894b 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -5,6 +5,7 @@ import concurrent.futures import json import os +import shutil import signal import sqlite3 import subprocess @@ -740,6 +741,33 @@ class DownloadQueue: if should_flush: 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): while not self.stop_event.is_set(): job = self._next_pending() @@ -781,16 +809,20 @@ class DownloadQueue: job["_dirty_log_lines"] = 0 self._save_job_locked(job) - process = subprocess.Popen( - command, - cwd=str(PROJECT_ROOT), - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - start_new_session=True, - ) + try: + process = subprocess.Popen( + command, + cwd=str(PROJECT_ROOT), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + ) + except Exception as exc: + self._fail_job_startup(job, exc) + return with self.lock: self.current_process = process self.current_job_id = job["id"] @@ -805,6 +837,7 @@ class DownloadQueue: finalize_error = None moved_files = [] watchlist_sync_error = None + cleanup_staging = False if exit_code == 0 and not job.get("cancel_requested"): try: moved_files = finalize_library_files(job) @@ -841,7 +874,12 @@ class DownloadQueue: else: job["status"] = "failed" 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_job_id = None self.current_job = None self._save_job_locked(job) + if cleanup_staging: + self._cleanup_staging_dir(job) diff --git a/test_app.py b/test_app.py index c02ca5f..317a5c0 100644 --- a/test_app.py +++ b/test_app.py @@ -225,6 +225,48 @@ class DownloadQueueCancelTests(unittest.TestCase): 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): def test_refresh_all_iterates_every_show_id(self): store = object.__new__(APP.WatchlistStore) @@ -475,6 +517,84 @@ class ThumbnailEventRecoveryTests(unittest.TestCase): 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): def setUp(self):