Keep removed watchlist shows from reappearing
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+61
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user