Keep removed watchlist shows from reappearing
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 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`.
|
- 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.
|
Small local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.45.7`
|
Current version: `0.45.8`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -214,6 +214,8 @@ Jellyfin handoff:
|
|||||||
Watchlist queueing:
|
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.
|
- 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:
|
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_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_category ON watchlist(category)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_updated_at ON watchlist(updated_at DESC)")
|
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):
|
def _row_to_item(self, row):
|
||||||
item = dict(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_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"))
|
item["auto_download_offset"] = normalize_episode_offset(payload.get("auto_download_offset"))
|
||||||
self._upsert_conn(conn, item)
|
self._upsert_conn(conn, item)
|
||||||
|
self._clear_removal_conn(conn, show_id)
|
||||||
|
|
||||||
queued = self.schedule_refresh(show_id, source="initial")
|
queued = self.schedule_refresh(show_id, source="initial")
|
||||||
return {"message": f"Added {queued['title']} to the watchlist. Refresh queued.", "item": queued, "created": True}
|
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),
|
(target_category, "Queued refresh after successful download.", now, sub_payload, dub_payload, normalized_show_id),
|
||||||
)
|
)
|
||||||
else:
|
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)
|
matched_row = self._find_download_title_match_conn(conn, normalized_title, exclude_show_id=normalized_show_id)
|
||||||
if matched_row is not None:
|
if matched_row is not None:
|
||||||
matched_item = self._row_to_item(matched_row)
|
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(),))
|
cursor = conn.execute("DELETE FROM watchlist WHERE show_id = ?", (str(show_id).strip(),))
|
||||||
if cursor.rowcount < 1:
|
if cursor.rowcount < 1:
|
||||||
raise KeyError("Anime not found in watchlist.")
|
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)
|
event = self.thumbnail_events.pop(str(show_id).strip(), None)
|
||||||
if event is not None:
|
if event is not None:
|
||||||
event.set()
|
event.set()
|
||||||
@@ -2342,7 +2377,9 @@ def download_watchlist_item(show_id, mode):
|
|||||||
"season": item.get("auto_download_series") or "1",
|
"season": item.get("auto_download_series") or "1",
|
||||||
"episode_offset": item.get("auto_download_offset"),
|
"episode_offset": item.get("auto_download_offset"),
|
||||||
"mode": normalized_mode,
|
"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,
|
"episodes": episode_spec,
|
||||||
"download_dir": runtime["config"]["download_dir"],
|
"download_dir": runtime["config"]["download_dir"],
|
||||||
}
|
}
|
||||||
@@ -2525,8 +2562,10 @@ def get_watchlist_refresh_status():
|
|||||||
|
|
||||||
|
|
||||||
def remove_from_watchlist(show_id):
|
def remove_from_watchlist(show_id):
|
||||||
ensure_runtime()
|
runtime = ensure_runtime()
|
||||||
return WATCHLIST.remove(show_id)
|
result = WATCHLIST.remove(show_id)
|
||||||
|
runtime["download_queue"].detach_show(show_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def upload_watchlist_thumbnail(payload):
|
def upload_watchlist_thumbnail(payload):
|
||||||
@@ -2547,6 +2586,8 @@ def resolve_job_watchlist_identity(job, config=None):
|
|||||||
|
|
||||||
def sync_downloaded_job_to_watchlist(job):
|
def sync_downloaded_job_to_watchlist(job):
|
||||||
ensure_runtime()
|
ensure_runtime()
|
||||||
|
if job.get("skip_watchlist_sync"):
|
||||||
|
return None
|
||||||
show_id, title, reason = resolve_job_watchlist_identity_impl(
|
show_id, title, reason = resolve_job_watchlist_identity_impl(
|
||||||
job,
|
job,
|
||||||
search_fn=search_anime,
|
search_fn=search_anime,
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,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.45.7"
|
VERSION = "0.45.8"
|
||||||
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"
|
||||||
|
|||||||
@@ -871,6 +871,26 @@ class DownloadQueue:
|
|||||||
covered.update(self._expand_episode_spec((row["episodes"] if isinstance(row, sqlite3.Row) else row[0]), available_episodes))
|
covered.update(self._expand_episode_spec((row["episodes"] if isinstance(row, sqlite3.Row) else row[0]), available_episodes))
|
||||||
return covered
|
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):
|
def retry(self, job_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
job = self._find(job_id)
|
job = self._find(job_id)
|
||||||
|
|||||||
+61
-1
@@ -346,6 +346,29 @@ class QueueApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(retried["status"], "pending")
|
self.assertEqual(retried["status"], "pending")
|
||||||
self.assertFalse(retried["cancel_requested"])
|
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):
|
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)
|
queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False)
|
||||||
job = {
|
job = {
|
||||||
@@ -1210,6 +1233,19 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertEqual(item["finished_badge"], "downloaded")
|
self.assertEqual(item["finished_badge"], "downloaded")
|
||||||
self.assertEqual(item["status"], "queued")
|
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):
|
def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self):
|
||||||
self.seed_watchlist_item(
|
self.seed_watchlist_item(
|
||||||
show_id="legacy-show-9",
|
show_id="legacy-show-9",
|
||||||
@@ -1390,6 +1426,7 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
"title": "Queue Show",
|
"title": "Queue Show",
|
||||||
"auto_download_name": "Queue Show Library",
|
"auto_download_name": "Queue Show Library",
|
||||||
"auto_download_series": "4",
|
"auto_download_series": "4",
|
||||||
|
"auto_download_quality": "720",
|
||||||
"media_type": "movie",
|
"media_type": "movie",
|
||||||
}
|
}
|
||||||
job = {"id": "job-1", "show_id": "show-42", "episodes": "1-12", "mode": "dub"}
|
job = {"id": "job-1", "show_id": "show-42", "episodes": "1-12", "mode": "dub"}
|
||||||
@@ -1410,7 +1447,7 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
"season": "4",
|
"season": "4",
|
||||||
"episode_offset": None,
|
"episode_offset": None,
|
||||||
"mode": "dub",
|
"mode": "dub",
|
||||||
"quality": "best",
|
"quality": "720",
|
||||||
"episodes": "1-12",
|
"episodes": "1-12",
|
||||||
"download_dir": "/tmp/downloads",
|
"download_dir": "/tmp/downloads",
|
||||||
}
|
}
|
||||||
@@ -1418,6 +1455,29 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["job"], job)
|
self.assertEqual(result["job"], job)
|
||||||
self.assertIn("Queued Queue Show for dub download.", result["message"])
|
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):
|
def test_download_watchlist_item_does_not_require_search_match(self):
|
||||||
item = {
|
item = {
|
||||||
"show_id": "show-42",
|
"show_id": "show-42",
|
||||||
|
|||||||
Reference in New Issue
Block a user