Add watchlist auto-download settings

This commit is contained in:
Dymas
2026-05-25 09:50:20 +02:00
parent 46af0ff47d
commit 3d42657837
10 changed files with 555 additions and 45 deletions
+126 -15
View File
@@ -975,19 +975,19 @@ class WatchlistCompletionTests(unittest.TestCase):
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")
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("queue_refresh should not refresh synchronously")
):
item = APP.WATCHLIST.queue_refresh("show-11", status_message="Queued from test.")
schedule_refresh.assert_called_once_with("show-11")
schedule_refresh.assert_called_once_with("show-11", source="manual")
self.assertEqual(item["status"], "queued")
self.assertEqual(item["status_message"], "Queued from test.")
def test_mark_downloaded_moves_existing_category_to_finished_and_sets_download_flag(self):
self.seed_watchlist_item(show_id="show-9", title="Queue Show", category="planned", downloaded=False)
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object(
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": 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("show-9", title="Queue Show")
@@ -998,7 +998,7 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(item["status"], "queued")
def test_mark_downloaded_creates_missing_show_in_finished_category(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object(
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": 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("show-10", title="Downloaded Show")
@@ -1011,7 +1011,7 @@ class WatchlistCompletionTests(unittest.TestCase):
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(
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": 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")
@@ -1025,12 +1025,12 @@ class WatchlistCompletionTests(unittest.TestCase):
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(
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("add should not refresh synchronously")
):
result = APP.WATCHLIST.add({"show_id": "show-22", "title": "Queued Show", "category": "watching"})
schedule_refresh.assert_called_once_with("show-22")
schedule_refresh.assert_called_once_with("show-22", source="initial")
self.assertTrue(result["created"])
self.assertEqual(result["item"]["status"], "queued")
self.assertIn("Refresh queued", result["message"])
@@ -1196,7 +1196,7 @@ class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
restored = APP.WatchlistStore(start_worker=False)
self.assertEqual(restored.refresh_pending, ["show-a", "show-b"])
self.assertEqual(restored.refresh_pending, [("show-a", "manual"), ("show-b", "manual")])
self.assertEqual(restored.refresh_pending_set, {"show-a", "show-b"})
@@ -1226,7 +1226,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
store = object.__new__(APP.WatchlistStore)
store.lock = threading.RLock()
store.thumbnail_events = {}
store.refresh_pending = ["show-2"]
store.refresh_pending = [("show-2", "manual")]
store.refresh_pending_set = {"show-2"}
store.refresh_inflight_set = set()
store.refresh_wakeup = threading.Event()
@@ -1235,15 +1235,15 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
store.worker_enabled = False
calls = []
def fake_refresh(show_id):
calls.append(show_id)
def fake_refresh(show_id, refresh_source="manual"):
calls.append((show_id, refresh_source))
store.refresh_stop_event.set()
store.refresh = fake_refresh
APP.WatchlistStore._refresh_loop(store)
self.assertEqual(calls, ["show-2"])
self.assertEqual(calls, [("show-2", "manual")])
self.assertEqual(store.refresh_inflight_set, set())
self.assertEqual(store.refresh_pending, [])
self.assertEqual(store.refresh_pending_set, set())
@@ -1253,7 +1253,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
store.lock = threading.RLock()
thumbnail_event = threading.Event()
store.thumbnail_events = {"show-3": thumbnail_event}
store.refresh_pending = ["show-3", "show-4"]
store.refresh_pending = [("show-3", "manual"), ("show-4", "manual")]
store.refresh_pending_set = {"show-3", "show-4"}
store.refresh_inflight_set = {"show-3"}
store._connect = APP.WATCHLIST._connect
@@ -1277,7 +1277,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
result = APP.WatchlistStore.remove(store, "show-3")
self.assertEqual(result["item"]["show_id"], "show-3")
self.assertEqual(store.refresh_pending, ["show-4"])
self.assertEqual(store.refresh_pending, [("show-4", "manual")])
self.assertEqual(store.refresh_pending_set, {"show-4"})
self.assertEqual(store.refresh_inflight_set, set())
self.assertTrue(thumbnail_event.is_set())
@@ -1294,6 +1294,9 @@ class ConfigSnapshotTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60,
"watchlist_refresh_delay_seconds": 5,
"auto_download_enabled": False,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"discord_webhook_url": "",
"discord_webhook_events": [],
}
@@ -1309,6 +1312,9 @@ class ConfigSnapshotTests(unittest.TestCase):
self.assertFalse(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
self.assertFalse(config["auto_download_enabled"])
self.assertEqual(config["auto_download_mode"], "dub")
self.assertEqual(config["auto_download_quality"], "best")
self.assertEqual(config["discord_webhook_url"], "")
self.assertEqual(config["discord_webhook_events"], [])
@@ -1318,11 +1324,17 @@ class ConfigSnapshotTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": "true",
"watchlist_auto_refresh_minutes": "15",
"watchlist_refresh_delay_seconds": "7",
"auto_download_enabled": "true",
"auto_download_mode": "sub",
"auto_download_quality": "720p",
}
)
self.assertTrue(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
self.assertTrue(config["auto_download_enabled"])
self.assertEqual(config["auto_download_mode"], "sub")
self.assertEqual(config["auto_download_quality"], "720")
def test_normalize_config_filters_discord_webhook_events(self):
config = APP.normalize_config(
@@ -1475,6 +1487,78 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
start.assert_not_called()
class AutoDownloadQueueTests(unittest.TestCase):
def test_queue_watchlist_auto_download_skips_initial_refresh(self):
item = {
"show_id": "show-1",
"title": "Example Show",
"category": "watching",
"previous_last_checked": None,
"had_new_episodes": True,
}
with mock.patch.object(
APP,
"ensure_runtime",
return_value={"config": {"auto_download_enabled": True}, "download_queue": mock.Mock()},
):
result = APP.queue_watchlist_auto_download(item, refresh_source="initial")
self.assertFalse(result["queued"])
self.assertEqual(result["reason"], "source:initial")
def test_queue_watchlist_auto_download_queues_only_missing_new_episodes(self):
item = {
"show_id": "show-42",
"title": "Queue Show",
"category": "watching",
"previous_last_checked": "2026-05-25T10:00:00+00:00",
"had_new_episodes": True,
"previous_dub_count": 10,
"previous_sub_count": 10,
"dub_episode_values": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
"sub_episode_values": ["1", "2", "3"],
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": 'Queue Show: Alt/Name',
}
download_queue = mock.Mock()
download_queue.covered_episodes.return_value = {"11"}
download_queue.add.side_effect = [
{"id": "job-12", "episodes": "12"},
]
runtime = {
"config": {
"auto_download_enabled": True,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"download_dir": "/tmp/downloads",
},
"download_queue": download_queue,
}
with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
APP,
"search_anime",
return_value=[{"id": "show-42", "title": "Queue Show", "query": "Queue Show", "index": 2}],
):
result = APP.queue_watchlist_auto_download(item, refresh_source="scheduled")
self.assertTrue(result["queued"])
self.assertEqual(result["episodes"], ["12"])
download_queue.add.assert_called_once_with(
{
"show_id": "show-42",
"query": "Queue Show",
"title": "Queue Show",
"anime_name": "Queue Show- Alt-Name",
"season": "1",
"index": 2,
"mode": "dub",
"quality": "best",
"episodes": "12",
"download_dir": "/tmp/downloads",
}
)
class StartupBehaviorTests(unittest.TestCase):
def test_main_does_not_eagerly_initialize_runtime(self):
server = mock.Mock()
@@ -1535,6 +1619,9 @@ class StartupBehaviorTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60,
"watchlist_refresh_delay_seconds": 5,
"auto_download_enabled": False,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"discord_webhook_url": "",
"discord_webhook_events": [],
}
@@ -1547,6 +1634,9 @@ class StartupBehaviorTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": True,
"watchlist_auto_refresh_minutes": 15,
"watchlist_refresh_delay_seconds": 8,
"auto_download_enabled": True,
"auto_download_mode": "sub",
"auto_download_quality": "720",
"discord_webhook_url": "https://discord.example/webhook",
"discord_webhook_events": ["runtime_error"],
}
@@ -1557,6 +1647,9 @@ class StartupBehaviorTests(unittest.TestCase):
self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"])
self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8)
self.assertTrue(APP.CONFIG["auto_download_enabled"])
self.assertEqual(APP.CONFIG["auto_download_mode"], "sub")
self.assertEqual(APP.CONFIG["auto_download_quality"], "720")
self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.example/webhook")
self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"])
APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with()
@@ -1594,13 +1687,16 @@ class HandlerRouteTests(unittest.TestCase):
APP.initialize_runtime(start_workers=False)
def test_config_post_accepts_watchlist_schedule_fields(self):
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9"}'
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720"}'
handler = DummyHandler("/api/config", body=body, content_length=len(body))
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"])
self.assertEqual(handler.json_payload["watchlist_auto_refresh_minutes"], 25)
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9)
self.assertTrue(handler.json_payload["auto_download_enabled"])
self.assertEqual(handler.json_payload["auto_download_mode"], "dub")
self.assertEqual(handler.json_payload["auto_download_quality"], "720")
def test_config_post_accepts_discord_webhook_fields(self):
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}'
@@ -1744,6 +1840,15 @@ class HandlerRouteTests(unittest.TestCase):
self.assertEqual(handler.json_payload, payload)
self.assertEqual(handler.json_status, HTTPStatus.CREATED)
def test_watchlist_auto_download_settings_post_returns_updated_item(self):
body = b'{"show_id":"show-1","mode":"dub","quality":"best","name":"Example Name"}'
handler = DummyHandler("/api/watchlist/update-auto-download", body=body, content_length=len(body))
payload = {"message": "Saved auto-download settings for Show 1.", "item": {"show_id": "show-1"}}
handler.handler_context = mock.Mock(update_watchlist_auto_download=mock.Mock(return_value=payload))
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_payload, payload)
self.assertEqual(handler.json_status, HTTPStatus.OK)
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
handler = DummyHandler("/api/config")
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
@@ -1837,6 +1942,12 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn('altTitlesPanel.classList.toggle("open")', APP.WATCHLIST_HTML)
self.assertIn("AniDB English alternative titles were not available for this show.", APP.WATCHLIST_HTML)
def test_watchlist_page_renders_auto_download_editor(self):
self.assertIn("Auto-download", APP.WATCHLIST_HTML)
self.assertIn('api("/api/watchlist/update-auto-download"', APP.WATCHLIST_HTML)
self.assertIn("Save auto-download", APP.WATCHLIST_HTML)
self.assertIn("Manual add does not trigger it", APP.WATCHLIST_HTML)
def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self):
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML)