diff --git a/CHANGELOG.md b/CHANGELOG.md index fe98598..820b35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.45.8 - 2026-05-26 + +- Made watchlist removal sticky by recording removed show IDs, detaching related queue jobs from watchlist sync, and preventing later completed downloads from silently recreating deleted entries. +- Cleared watchlist queue bindings when a show is removed so old job history no longer suppresses future auto-download coverage if that anime is tracked again later. +- Updated manual watchlist `Download all` queueing to honor the per-anime auto-download quality first, then fall back to the global auto-download quality before the general queue default. + ## 0.45.7 - 2026-05-25 - Removed the remaining backend `ani-cli -S` dependency so queued downloads no longer trust caller-supplied search result ordinals, including older persisted jobs that still carry a legacy `result_index`. diff --git a/README.md b/README.md index 34c81ed..230ad95 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Small local web UI for a system-wide `ani-cli` install. -Current version: `0.45.7` +Current version: `0.45.8` ## Project Layout @@ -214,6 +214,8 @@ Jellyfin handoff: Watchlist queueing: - Manual `Download all` and automatic watchlist downloads now queue directly from the stored watchlist title and `show_id`, so they no longer fail just because a fresh provider search returns a shifted or incomplete result list. +- Removing a watchlist entry now detaches its existing queue jobs from later watchlist sync, so finishing an old queued download cannot silently recreate that deleted show. +- Manual watchlist `Download all` now uses the anime's own configured auto-download quality when one is set. Discord notification events: diff --git a/VERSION b/VERSION index 68016cd..9d7e3b3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.7 +0.45.8 diff --git a/app.py b/app.py index 4da441c..69b59ec 100644 --- a/app.py +++ b/app.py @@ -1431,6 +1431,37 @@ class WatchlistStore: conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_title ON watchlist(title)") conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_category ON watchlist(category)") conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_updated_at ON watchlist(updated_at DESC)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS watchlist_removals ( + show_id TEXT PRIMARY KEY, + title TEXT, + removed_at TEXT NOT NULL + ) + """ + ) + + def _remember_removal_conn(self, conn, show_id, title): + conn.execute( + """ + INSERT INTO watchlist_removals (show_id, title, removed_at) + VALUES (?, ?, ?) + ON CONFLICT(show_id) DO UPDATE SET + title = excluded.title, + removed_at = excluded.removed_at + """, + (str(show_id or "").strip(), str(title or "").strip(), now_iso()), + ) + + def _clear_removal_conn(self, conn, show_id): + conn.execute("DELETE FROM watchlist_removals WHERE show_id = ?", (str(show_id or "").strip(),)) + + def _was_removed_conn(self, conn, show_id): + row = conn.execute( + "SELECT 1 FROM watchlist_removals WHERE show_id = ? LIMIT 1", + (str(show_id or "").strip(),), + ).fetchone() + return row is not None def _row_to_item(self, row): item = dict(row) @@ -1766,6 +1797,7 @@ class WatchlistStore: item["auto_download_series"] = normalize_season(payload.get("auto_download_series") or item.get("auto_download_series") or "1") item["auto_download_offset"] = normalize_episode_offset(payload.get("auto_download_offset")) self._upsert_conn(conn, item) + self._clear_removal_conn(conn, show_id) queued = self.schedule_refresh(show_id, source="initial") return {"message": f"Added {queued['title']} to the watchlist. Refresh queued.", "item": queued, "created": True} @@ -2010,6 +2042,8 @@ class WatchlistStore: (target_category, "Queued refresh after successful download.", now, sub_payload, dub_payload, normalized_show_id), ) else: + if self._was_removed_conn(conn, normalized_show_id): + return None matched_row = self._find_download_title_match_conn(conn, normalized_title, exclude_show_id=normalized_show_id) if matched_row is not None: matched_item = self._row_to_item(matched_row) @@ -2128,6 +2162,7 @@ class WatchlistStore: cursor = conn.execute("DELETE FROM watchlist WHERE show_id = ?", (str(show_id).strip(),)) if cursor.rowcount < 1: raise KeyError("Anime not found in watchlist.") + self._remember_removal_conn(conn, existing["show_id"], existing["title"]) event = self.thumbnail_events.pop(str(show_id).strip(), None) if event is not None: event.set() @@ -2342,7 +2377,9 @@ def download_watchlist_item(show_id, mode): "season": item.get("auto_download_series") or "1", "episode_offset": item.get("auto_download_offset"), "mode": normalized_mode, - "quality": runtime["config"]["quality"], + "quality": item.get("auto_download_quality") + or runtime["config"].get("auto_download_quality") + or runtime["config"]["quality"], "episodes": episode_spec, "download_dir": runtime["config"]["download_dir"], } @@ -2525,8 +2562,10 @@ def get_watchlist_refresh_status(): def remove_from_watchlist(show_id): - ensure_runtime() - return WATCHLIST.remove(show_id) + runtime = ensure_runtime() + result = WATCHLIST.remove(show_id) + runtime["download_queue"].detach_show(show_id) + return result def upload_watchlist_thumbnail(payload): @@ -2547,6 +2586,8 @@ def resolve_job_watchlist_identity(job, config=None): def sync_downloaded_job_to_watchlist(job): ensure_runtime() + if job.get("skip_watchlist_sync"): + return None show_id, title, reason = resolve_job_watchlist_identity_impl( job, search_fn=search_anime, diff --git a/app_support.py b/app_support.py index 8ca0798..4b6dc9b 100644 --- a/app_support.py +++ b/app_support.py @@ -20,7 +20,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.45.7" +VERSION = "0.45.8" 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 d9c706e..2e96fca 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -871,6 +871,26 @@ class DownloadQueue: covered.update(self._expand_episode_spec((row["episodes"] if isinstance(row, sqlite3.Row) else row[0]), available_episodes)) return covered + def detach_show(self, show_id): + normalized_show_id = str(show_id or "").strip() + if not normalized_show_id: + return {"ok": True, "count": 0} + count = 0 + with self.lock, self._connect() as conn: + rows = conn.execute("SELECT * FROM jobs WHERE show_id = ?", (normalized_show_id,)).fetchall() + for row in rows: + job = self._job_from_row(row) + job["show_id"] = "" + job["skip_watchlist_sync"] = True + job["updated_at"] = now_iso() + self._upsert_job_conn(conn, job) + count += 1 + if self.current_job is not None and str(self.current_job.get("show_id") or "").strip() == normalized_show_id: + self.current_job["show_id"] = "" + self.current_job["skip_watchlist_sync"] = True + self.current_job["updated_at"] = now_iso() + return {"ok": True, "count": count} + def retry(self, job_id): with self.lock: job = self._find(job_id) diff --git a/test_app.py b/test_app.py index 7dec9d2..ab78898 100644 --- a/test_app.py +++ b/test_app.py @@ -346,6 +346,29 @@ class QueueApiTests(unittest.TestCase): self.assertEqual(retried["status"], "pending") self.assertFalse(retried["cancel_requested"]) + def test_detach_show_clears_queue_coverage_and_sync_binding(self): + created = self.queue.add( + { + "show_id": "show-42", + "query": "Queued Show", + "title": "Queued Show", + "anime_name": "Queued Show", + "mode": "dub", + "quality": "best", + "episodes": "1-3", + "download_dir": "/tmp/example", + "season": "1", + } + ) + + result = self.queue.detach_show("show-42") + detached = self.queue.get(created["id"]) + + self.assertEqual(result["count"], 1) + self.assertEqual(detached["show_id"], "") + self.assertTrue(detached["skip_watchlist_sync"]) + self.assertEqual(self.queue.covered_episodes("show-42", "dub", ["1", "2", "3"]), set()) + def test_save_job_splits_log_and_extra_payload_columns(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) job = { @@ -1210,6 +1233,19 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(item["finished_badge"], "downloaded") self.assertEqual(item["status"], "queued") + def test_mark_downloaded_does_not_recreate_removed_show(self): + self.seed_watchlist_item(show_id="show-removed", title="Removed Show", category="watching") + + APP.WATCHLIST.remove("show-removed") + + with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=AssertionError("removed show should not be requeued")), mock.patch.object( + APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") + ): + item = APP.WATCHLIST.mark_downloaded("show-removed", title="Removed Show", mode="sub", episodes="1") + + self.assertIsNone(item) + self.assertFalse(APP.WATCHLIST.has_show("show-removed")) + def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self): self.seed_watchlist_item( show_id="legacy-show-9", @@ -1390,6 +1426,7 @@ class WatchlistCompletionTests(unittest.TestCase): "title": "Queue Show", "auto_download_name": "Queue Show Library", "auto_download_series": "4", + "auto_download_quality": "720", "media_type": "movie", } job = {"id": "job-1", "show_id": "show-42", "episodes": "1-12", "mode": "dub"} @@ -1410,7 +1447,7 @@ class WatchlistCompletionTests(unittest.TestCase): "season": "4", "episode_offset": None, "mode": "dub", - "quality": "best", + "quality": "720", "episodes": "1-12", "download_dir": "/tmp/downloads", } @@ -1418,6 +1455,29 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(result["job"], job) self.assertIn("Queued Queue Show for dub download.", result["message"]) + def test_remove_from_watchlist_detaches_existing_queue_jobs(self): + runtime = APP.ensure_runtime() + APP.WATCHLIST.add({"show_id": "show-queue-remove", "title": "Queue Remove Show"}) + created = runtime["download_queue"].add( + { + "show_id": "show-queue-remove", + "query": "Queue Remove Show", + "title": "Queue Remove Show", + "anime_name": "Queue Remove Show", + "mode": "sub", + "quality": "best", + "episodes": "1-2", + "download_dir": "/tmp/example", + "season": "1", + } + ) + + APP.remove_from_watchlist("show-queue-remove") + + detached = runtime["download_queue"].get(created["id"]) + self.assertEqual(detached["show_id"], "") + self.assertTrue(detached["skip_watchlist_sync"]) + def test_download_watchlist_item_does_not_require_search_match(self): item = { "show_id": "show-42",