diff --git a/CHANGELOG.md b/CHANGELOG.md index 652991c..0ece6ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 15beb81..d5404e2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/VERSION b/VERSION index 989b29c..d721c76 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.32.2 +0.32.3 diff --git a/app.py b/app.py index ac06be2..8beb144 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/app_support.py b/app_support.py index 7c39f29..07c84ac 100644 --- a/app_support.py +++ b/app_support.py @@ -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" diff --git a/test_app.py b/test_app.py index bd92a31..4ae73c6 100644 --- a/test_app.py +++ b/test_app.py @@ -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")