Fix thumbnail backoff and watchlist fallback matching

This commit is contained in:
Dymas
2026-05-16 10:25:24 +02:00
parent 6b1e0ce7f0
commit d12afb1284
8 changed files with 438 additions and 106 deletions
+155
View File
@@ -297,6 +297,27 @@ class WatchlistRefreshJobTests(unittest.TestCase):
self.assertEqual(status["errors"], 0)
self.assertCountEqual(calls, [("show-a", False), ("show-b", False)])
def test_refresh_job_marks_interrupted_when_shutdown_requested(self):
service = APP.WatchlistRefreshJobs(mock.Mock(), start_worker=False)
job = {
"id": "refresh-1",
"status": "running",
"created_at": APP.now_iso(),
"updated_at": APP.now_iso(),
"completed": 0,
"errors": 0,
"current_title": None,
"message": "",
}
service.store.all_show_ids = lambda: ["show-a"]
service.store.refresh = mock.Mock(return_value={"title": "Show A", "status": "updated"})
service.stop_event.set()
service._run_job(job)
self.assertEqual(job["status"], "interrupted")
self.assertIn("interrupted", job["message"].lower())
def test_refresh_start_reuses_pending_job(self):
class FakeStore:
def all_show_ids(self):
@@ -407,6 +428,53 @@ class ThumbnailEventRecoveryTests(unittest.TestCase):
self.assertEqual(store.thumbnail_events, {})
def test_ensure_thumbnail_records_failed_attempt_time_for_retry_backoff(self):
now = APP.now_iso()
item = {
"show_id": "show-thumb-1",
"title": "Broken Cover Show",
"category": "watching",
"downloaded": False,
"status": "tracked",
"status_message": "Waiting for thumbnail.",
"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 APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
APP.WATCHLIST._upsert_conn(conn, item)
with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("lookup failed")) as fetch_cover, mock.patch.object(
APP, "fetch_anidb_cover", side_effect=RuntimeError("fallback failed")
) as fetch_fallback:
result = APP.WATCHLIST.ensure_thumbnail("show-thumb-1")
self.assertIsNone(result)
self.assertEqual(fetch_cover.call_count, 1)
self.assertEqual(fetch_fallback.call_count, 1)
failed_item = APP.WATCHLIST.get("show-thumb-1")
self.assertTrue(failed_item["thumbnail_checked_at"])
self.assertFalse(APP.thumbnail_retry_due(failed_item["thumbnail_checked_at"]))
with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=AssertionError("retry window should suppress another lookup")), mock.patch.object(
APP, "fetch_anidb_cover", side_effect=AssertionError("retry window should suppress fallback lookup")
):
second = APP.WATCHLIST.ensure_thumbnail("show-thumb-1")
self.assertIsNone(second)
class WatchlistCompletionTests(unittest.TestCase):
def setUp(self):
@@ -496,6 +564,16 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(listing["queued_count"], 1)
self.assertEqual([item["show_id"] for item in listing["items"]], ["show-1"])
def test_list_clamps_out_of_range_page_before_querying_rows(self):
self.seed_watchlist_item(show_id="show-1", title="Alpha Show", category="watching")
self.seed_watchlist_item(show_id="show-2", title="Beta Show", category="watching")
listing = APP.WATCHLIST.list(page=3, per_page=1, category="watching")
self.assertEqual(listing["page"], 2)
self.assertEqual(listing["pages"], 2)
self.assertEqual([item["show_id"] for item in listing["items"]], ["show-2"])
def test_queue_refresh_marks_item_queued_without_sync_refresh(self):
self.seed_watchlist_item(show_id="show-11", title="Refresh Show", category="watching", status="updated")
@@ -543,6 +621,32 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(result["item"]["status"], "queued")
self.assertIn("Refresh queued", result["message"])
def test_refresh_uses_bounded_request_timeout(self):
self.seed_watchlist_item(show_id="show-1", title="Example Show")
snapshot = {
"show_id": "show-1",
"title": "Example Show",
"episodes": {"sub": ["1"], "dub": []},
}
schedule = {
"route": "example-show",
"title": "Example Show",
"episodes": 12,
"status": "Finished",
}
with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot) as fetch_snapshot, mock.patch.object(
APP, "resolve_animeschedule_anime", return_value=schedule
) as resolve_schedule, mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None):
APP.WATCHLIST.refresh("show-1")
fetch_snapshot.assert_called_once_with("show-1", timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS)
resolve_schedule.assert_called_once_with(
"Example Show",
route=None,
timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS,
)
def test_sync_downloaded_job_to_watchlist_resolves_missing_show_id_from_search(self):
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
@@ -562,6 +666,33 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(prepared["show_id"], "show-42")
self.assertEqual(prepared["title"], "Queue Show")
def test_prepare_download_job_accepts_equivalent_season_notation(self):
job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""}
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 2"}]):
prepared = APP.prepare_download_job(dict(job))
self.assertEqual(prepared["show_id"], "show-42")
self.assertEqual(prepared["title"], "Queue Show Season 2")
def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(self):
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Different Anime"}]):
with self.assertRaises(ValueError) as ctx:
APP.sync_downloaded_job_to_watchlist(job)
self.assertIn("title mismatch", str(ctx.exception).lower())
def test_sync_downloaded_job_to_watchlist_rejects_cross_season_match(self):
job = {"query": "Queue Show Season 2", "mode": "sub", "result_index": 1, "title": "Queue Show Season 2"}
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 1"}]):
with self.assertRaises(ValueError) as ctx:
APP.sync_downloaded_job_to_watchlist(job)
self.assertIn("title mismatch", str(ctx.exception).lower())
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
def setUp(self):
@@ -730,6 +861,7 @@ class StartupBehaviorTests(unittest.TestCase):
with mock.patch.object(APP, "initialize_runtime") as initialize_runtime, mock.patch.object(
APP, "ThreadingHTTPServer", return_value=server
), mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime:
shutdown_runtime.return_value = True
APP.main()
initialize_runtime.assert_not_called()
@@ -741,6 +873,7 @@ class StartupBehaviorTests(unittest.TestCase):
APP.initialize_runtime(start_workers=False)
try:
with mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime:
shutdown_runtime.return_value = True
APP.reset_runtime(wait=True)
shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=None)
@@ -748,6 +881,25 @@ class StartupBehaviorTests(unittest.TestCase):
finally:
APP.initialize_runtime(start_workers=False)
def test_reset_runtime_refuses_to_clear_globals_when_shutdown_does_not_finish(self):
APP.initialize_runtime(start_workers=False)
try:
current_config = APP.CONFIG
current_watchlist = APP.WATCHLIST
current_queue = APP.DOWNLOAD_QUEUE
current_refresh = APP.WATCHLIST_REFRESH
with mock.patch.object(APP, "shutdown_runtime", return_value=False):
with self.assertRaises(RuntimeError):
APP.reset_runtime(wait=True)
self.assertIs(APP.CONFIG, current_config)
self.assertIs(APP.WATCHLIST, current_watchlist)
self.assertIs(APP.DOWNLOAD_QUEUE, current_queue)
self.assertIs(APP.WATCHLIST_REFRESH, current_refresh)
finally:
APP.reset_runtime(wait=True)
APP.initialize_runtime(start_workers=False)
def test_save_runtime_config_replaces_runtime_config(self):
original = APP.CONFIG
try:
@@ -876,6 +1028,9 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)
self.assertIn("startSerialPoll(() => loadQueue(), 2500)", APP.INDEX_HTML)
self.assertIn("startSerialPoll(async () => {", APP.WATCHLIST_HTML)
self.assertIn("window.clearTimeout(timer);", APP.INDEX_HTML)
self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.INDEX_HTML)
self.assertIn('window.addEventListener("beforeunload", stop, { once: true });', APP.WATCHLIST_HTML)
def test_watchlist_page_polls_when_queued_items_exist(self):
self.assertIn("queuedCount: 0", APP.WATCHLIST_HTML)