Compare commits

...
2 Commits
Author SHA1 Message Date
Dymas 2a0da335b2 Fix watchlist download sync for resolved IDs 2026-05-17 21:35:49 +02:00
Dymas 9e918a50ab Show newest queue log lines first 2026-05-17 21:29:21 +02:00
7 changed files with 116 additions and 38 deletions
+8
View File
@@ -1,5 +1,13 @@
# 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.
## 0.32.1 - 2026-05-17
- Suppressed stdout request logging for routine polling endpoints such as `/api/queue` and `/api/watchlist/refresh-status`, reducing noisy access-log spam during normal browser use.
+3 -1
View File
@@ -2,7 +2,7 @@
A local web UI for a system-wide `ani-cli` install.
Current version: `0.32.1`
Current version: `0.32.2`
## Project Layout
@@ -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.
@@ -160,6 +161,7 @@ The app currently includes:
- Optional scheduled background watchlist refreshes with Config page controls for enable/disable and refresh period.
- Scheduled background refresh runs now log queue, start, and completion status to stdout so container logs show whether they finished successfully, with errors, or failed.
- Routine browser polling endpoints such as the search-page queue poll and watchlist refresh-status poll now stay quiet in stdout request logs to reduce log spam.
- Queue job logs on the Search page now render newest lines first for easier at-a-glance progress checking.
- Search and Watchlist polling now use a shared serial browser polling helper so slow responses do not cause overlapping poll requests to pile up in the background.
- That shared browser polling helper now also tears itself down on page unload and uses chained `setTimeout` scheduling instead of a bare repeating interval.
- Controlled shutdown and runtime reset now cancel active download workers when waiting for teardown, reducing the chance of orphaned background downloads outliving the web process.
+1 -1
View File
@@ -1 +1 @@
0.32.1
0.32.3
+84 -34
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,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
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.1"
VERSION = "0.32.3"
ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to"
+1 -1
View File
@@ -632,7 +632,7 @@ INDEX_HTML = r"""<!doctype html>
for (const job of jobs) {
const item = document.createElement("article");
item.className = "job";
const log = (job.log || []).slice(-28).join("\n");
const log = [...(job.log || [])].slice(-28).reverse().join("\n");
item.innerHTML = `
<div class="job-head">
<div>
+18
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")
@@ -1444,6 +1459,9 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn("queueRequestId", APP.INDEX_HTML)
self.assertIn("if (requestId !== state.queueRequestId) return data;", APP.INDEX_HTML)
def test_search_page_renders_queue_logs_newest_first(self):
self.assertIn('[...(job.log || [])].slice(-28).reverse().join("\\n")', APP.INDEX_HTML)
def test_pages_share_browser_api_helper(self):
self.assertIn("async function api(path, options = {})", APP.INDEX_HTML)
self.assertIn("async function api(path, options = {})", APP.CONFIG_HTML)