Fix watchlist download sync for resolved IDs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,47 +1483,74 @@ 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
|
||||
now = now_iso()
|
||||
existing = {
|
||||
"show_id": normalized_show_id,
|
||||
"title": str(title or "Unknown Anime").strip() or "Unknown Anime",
|
||||
"category": "finished",
|
||||
"downloaded": True,
|
||||
"status": "queued",
|
||||
"status_message": "Queued refresh after successful download.",
|
||||
"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": None,
|
||||
"anidb_title": None,
|
||||
"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:
|
||||
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_iso(), normalized_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": normalized_title or "Unknown Anime",
|
||||
"category": "finished",
|
||||
"downloaded": True,
|
||||
"status": "queued",
|
||||
"status_message": "Queued refresh after successful download.",
|
||||
"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": None,
|
||||
"anidb_title": None,
|
||||
"thumbnail_path": None,
|
||||
"thumbnail_checked_at": None,
|
||||
}
|
||||
self._upsert_conn(conn, existing)
|
||||
self.schedule_refresh(normalized_show_id)
|
||||
return self.get(normalized_show_id)
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user