Fix watchlist download sync for resolved IDs

This commit is contained in:
Dymas
2026-05-17 21:35:49 +02:00
parent 9e918a50ab
commit 2a0da335b2
6 changed files with 106 additions and 36 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog
## 0.32.3 - 2026-05-17
- Fixed watchlist download sync so a successful download now reuses the existing tracked entry when the resolved `show_id` changes, preserving its category and marking it downloaded instead of creating a duplicate finished-only row.
## 0.32.2 - 2026-05-17
- Changed Search page queue log rendering to show the newest lines first instead of oldest-first.
+1
View File
@@ -151,6 +151,7 @@ The app currently includes:
- Watchlist category tabs for `Watching`, `Planned`, `Finished`, and `Dropped`, with per-anime category editing.
- AnimeSchedule-backed watchlist completion tracking that compares live sub/dub availability with the expected total episode count when known.
- Automatic watchlist sync after successful downloads, including downloaded-vs-manual finished markers.
- Download sync now also reconciles unique title matches when ani-cli resolves a different `show_id`, so existing watchlist entries keep their category and still get marked as downloaded.
- Per-show background watchlist refresh queueing so adding a show or syncing a finished download does not block the request on upstream episode, schedule, and thumbnail lookups.
- Queued per-show watchlist refresh work now survives app restarts and resumes from rows still marked `queued`.
- Per-show watchlist refresh queueing now also deduplicates in-flight refreshes, so the same show is not fetched twice back-to-back while one refresh is already running.
+1 -1
View File
@@ -1 +1 @@
0.32.2
0.32.3
+67 -17
View File
@@ -1001,6 +1001,29 @@ class WatchlistStore:
with self.lock, self._connect() as conn:
return self._exists_conn(conn, normalized_show_id)
def _find_download_title_match_conn(self, conn, title, exclude_show_id=""):
normalized_title_key = normalize_identity_title_key(title)
excluded_show_id = str(exclude_show_id or "").strip()
if not normalized_title_key:
return None
rows = conn.execute(
"""
SELECT *
FROM watchlist
ORDER BY updated_at DESC, created_at DESC, title COLLATE NOCASE ASC
"""
).fetchall()
matches = []
for row in rows:
row_show_id = str(row["show_id"] or "").strip()
if not row_show_id or row_show_id == excluded_show_id:
continue
if normalize_identity_title_key(row["title"]) == normalized_title_key:
matches.append(row)
if len(matches) > 1:
return None
return matches[0] if matches else None
def _init_db(self):
STATE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as conn:
@@ -1460,15 +1483,53 @@ class WatchlistStore:
if not normalized_show_id:
raise ValueError("Missing show_id for watchlist download sync.")
created = False
try:
existing = self.get(normalized_show_id)
except KeyError:
created = True
normalized_title = str(title or "").strip()
now = now_iso()
with self.lock, self._connect() as conn:
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (normalized_show_id,)).fetchone()
if row:
conn.execute(
"""
UPDATE watchlist
SET downloaded = 1, status = 'queued', status_message = ?, updated_at = ?
WHERE show_id = ?
""",
("Queued refresh after successful download.", now, normalized_show_id),
)
else:
matched_row = self._find_download_title_match_conn(conn, normalized_title, exclude_show_id=normalized_show_id)
if matched_row is not None:
previous_show_id = str(matched_row["show_id"]).strip()
resolved_title = normalized_title or str(matched_row["title"] or "").strip() or "Unknown Anime"
conn.execute(
"""
UPDATE watchlist
SET
show_id = ?,
title = ?,
downloaded = 1,
status = 'queued',
status_message = ?,
updated_at = ?
WHERE show_id = ?
""",
(
normalized_show_id,
resolved_title,
"Queued refresh after successful download.",
now,
previous_show_id,
),
)
if previous_show_id != normalized_show_id:
event = self.thumbnail_events.pop(previous_show_id, None)
if event is not None:
self.thumbnail_events[normalized_show_id] = event
self._drop_refresh_locked(previous_show_id)
else:
existing = {
"show_id": normalized_show_id,
"title": str(title or "Unknown Anime").strip() or "Unknown Anime",
"title": normalized_title or "Unknown Anime",
"category": "finished",
"downloaded": True,
"status": "queued",
@@ -1489,18 +1550,7 @@ class WatchlistStore:
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
with self.lock, self._connect() as conn:
self._upsert_conn(conn, existing)
else:
with self.lock, self._connect() as conn:
conn.execute(
"""
UPDATE watchlist
SET downloaded = 1, status = 'queued', status_message = ?, updated_at = ?
WHERE show_id = ?
""",
("Queued refresh after successful download.", now_iso(), normalized_show_id),
)
self.schedule_refresh(normalized_show_id)
return self.get(normalized_show_id)
+1 -1
View File
@@ -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.32.2"
VERSION = "0.32.3"
ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to"
+15
View File
@@ -834,6 +834,21 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(item["finished_badge"], "downloaded")
self.assertEqual(item["status"], "queued")
def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self):
self.seed_watchlist_item(show_id="legacy-show-9", title="Queue Show", category="watching", downloaded=False)
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
):
item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show")
self.assertEqual(item["show_id"], "resolved-show-9")
self.assertEqual(item["category"], "watching")
self.assertTrue(item["downloaded"])
self.assertEqual(item["status"], "queued")
self.assertFalse(APP.WATCHLIST.has_show("legacy-show-9"))
self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"])
def test_add_queues_refresh_without_sync_refresh(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("add should not refresh synchronously")