Pace bulk watchlist refresh requests

This commit is contained in:
Dymas
2026-05-17 21:15:53 +02:00
parent a8d7d3de00
commit ae090c954b
8 changed files with 100 additions and 42 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.32.0 - 2026-05-17
- Changed bulk `Refresh All` processing to pace requests with a configurable delay between shows, reducing the chance of flooding upstream episode sources on large watchlists.
- Added a Config page setting for the per-show bulk refresh delay and expanded regression coverage for the new config field and pacing behavior.
## 0.31.1 - 2026-05-17 ## 0.31.1 - 2026-05-17
- Added stdout lifecycle logging for scheduled watchlist refresh jobs so container logs now show when a scheduled run is queued, starts, finishes successfully, finishes with errors, gets interrupted, or fails. - Added stdout lifecycle logging for scheduled watchlist refresh jobs so container logs now show when a scheduled run is queued, starts, finishes successfully, finishes with errors, gets interrupted, or fails.
+5 -4
View File
@@ -2,7 +2,7 @@
A local web UI for a system-wide `ani-cli` install. A local web UI for a system-wide `ani-cli` install.
Current version: `0.31.1` Current version: `0.32.0`
## Project Layout ## Project Layout
@@ -156,7 +156,7 @@ The app currently includes:
- 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. - 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.
- The watchlist page now silently refreshes itself while queued per-show refresh work is still being processed, so queued entries settle into their updated state without a manual reload. - The watchlist page now silently refreshes itself while queued per-show refresh work is still being processed, so queued entries settle into their updated state without a manual reload.
- The watchlist page now also avoids re-triggering the same unresolved thumbnail warm-up request on every silent poll cycle while a show's thumbnail state is unchanged. - The watchlist page now also avoids re-triggering the same unresolved thumbnail warm-up request on every silent poll cycle while a show's thumbnail state is unchanged.
- Background `Refresh All` execution with status polling, parallel per-show refresh work, and no inline thumbnail downloads during the bulk job. - Background `Refresh All` execution with status polling, no inline thumbnail downloads during the bulk job, and a configurable delay between shows to reduce upstream request bursts.
- Optional scheduled background watchlist refreshes with Config page controls for enable/disable and refresh period. - 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. - 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.
- 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. - 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.
@@ -206,6 +206,7 @@ Use it to:
3. Choose the default search quality used to prefill the Search page. 3. Choose the default search quality used to prefill the Search page.
4. Enable or disable periodic watchlist refresh checks. 4. Enable or disable periodic watchlist refresh checks.
5. Set the refresh period in minutes for that scheduled watchlist job. 5. Set the refresh period in minutes for that scheduled watchlist job.
6. Set the delay in seconds between shows during bulk watchlist refresh runs.
Those saved defaults are written into the project-local `config.json` and loaded automatically when the Search page opens. Those saved defaults are written into the project-local `config.json` and loaded automatically when the Search page opens.
@@ -278,7 +279,7 @@ You can add entries in two ways:
Watchlist features: Watchlist features:
1. `Refresh` updates one tracked show. 1. `Refresh` updates one tracked show.
2. `Refresh All` runs in the background, rejects duplicate queueing while a refresh is already pending or running, preserves interrupted job status across restarts, and refreshes shows in parallel without doing inline thumbnail downloads for every entry. 2. `Refresh All` runs in the background, rejects duplicate queueing while a refresh is already pending or running, preserves interrupted job status across restarts, and refreshes shows one by one with a configurable delay without doing inline thumbnail downloads for every entry.
3. `Open` jumps back to the Search page with the title pre-filled. 3. `Open` jumps back to the Search page with the title pre-filled.
4. Clicking the anime title opens the AllManga source page at `bangumi/<show_id>` in a new tab. 4. Clicking the anime title opens the AllManga source page at `bangumi/<show_id>` in a new tab.
5. `Remove` deletes the watchlist entry and clears its cached poster file. 5. `Remove` deletes the watchlist entry and clears its cached poster file.
@@ -392,7 +393,7 @@ Environment variables:
Saved defaults from the UI are written into the project-local `config.json`. Saved defaults from the UI are written into the project-local `config.json`.
The saved config now also includes `watchlist_auto_refresh_enabled` and `watchlist_auto_refresh_minutes`, which control the periodic background watchlist refresh schedule. The saved config now also includes `watchlist_auto_refresh_enabled`, `watchlist_auto_refresh_minutes`, and `watchlist_refresh_delay_seconds`, which control the periodic background watchlist refresh schedule and the per-show pacing used by bulk refresh jobs.
## Notes ## Notes
+1 -1
View File
@@ -1 +1 @@
0.31.1 0.32.0
+5 -1
View File
@@ -1814,7 +1814,11 @@ def build_runtime(start_workers=None):
job_prepare_fn=prepare_download_job, job_prepare_fn=prepare_download_job,
start_worker=worker_state, start_worker=worker_state,
) )
watchlist_refresh = WatchlistRefreshJobs(watchlist, start_worker=worker_state) watchlist_refresh = WatchlistRefreshJobs(
watchlist,
config_getter=lambda: get_config_snapshot(config),
start_worker=worker_state,
)
watchlist_auto_refresh = WatchlistAutoRefreshScheduler( watchlist_auto_refresh = WatchlistAutoRefreshScheduler(
config_getter=lambda: get_config_snapshot(config), config_getter=lambda: get_config_snapshot(config),
refresh_start_fn=watchlist_refresh.start, refresh_start_fn=watchlist_refresh.start,
+15 -1
View File
@@ -16,7 +16,7 @@ from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web" APP_NAME = "ani-cli-web"
VERSION = "0.31.1" VERSION = "0.32.0"
ALLANIME_BASE = "allanime.day" ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to" ALLANIME_REFERER = "https://allmanga.to"
@@ -45,6 +45,9 @@ WORKER_SHUTDOWN_TIMEOUT_SECONDS = 10
WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES = 60 WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES = 60
WATCHLIST_AUTO_REFRESH_MIN_MINUTES = 1 WATCHLIST_AUTO_REFRESH_MIN_MINUTES = 1
WATCHLIST_AUTO_REFRESH_MAX_MINUTES = 7 * 24 * 60 WATCHLIST_AUTO_REFRESH_MAX_MINUTES = 7 * 24 * 60
WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS = 5
WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0
WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300
def env_flag(name, default=False): def env_flag(name, default=False):
@@ -119,6 +122,7 @@ DEFAULT_CONFIG = {
"quality": os.environ.get("ANI_CLI_QUALITY", "best"), "quality": os.environ.get("ANI_CLI_QUALITY", "best"),
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES, "watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
"watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS,
} }
@@ -261,6 +265,7 @@ def normalize_config(data):
download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"]) download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"])
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled") auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes") auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds")
if isinstance(auto_refresh_enabled, str): if isinstance(auto_refresh_enabled, str):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"} auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
@@ -275,12 +280,21 @@ def normalize_config(data):
WATCHLIST_AUTO_REFRESH_MIN_MINUTES, WATCHLIST_AUTO_REFRESH_MIN_MINUTES,
min(WATCHLIST_AUTO_REFRESH_MAX_MINUTES, auto_refresh_minutes), min(WATCHLIST_AUTO_REFRESH_MAX_MINUTES, auto_refresh_minutes),
) )
try:
refresh_delay_seconds = int(str(refresh_delay_seconds).strip() or WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS)
except (TypeError, ValueError):
refresh_delay_seconds = WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS
refresh_delay_seconds = max(
WATCHLIST_REFRESH_DELAY_MIN_SECONDS,
min(WATCHLIST_REFRESH_DELAY_MAX_SECONDS, refresh_delay_seconds),
)
config["mode"] = mode if mode in MODE_CHOICES else "sub" config["mode"] = mode if mode in MODE_CHOICES else "sub"
config["quality"] = quality if quality in QUALITY_CHOICES else "best" config["quality"] = quality if quality in QUALITY_CHOICES else "best"
config["download_dir"] = str(Path(download_dir).expanduser()) config["download_dir"] = str(Path(download_dir).expanduser())
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes
config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds
return config return config
+10 -3
View File
@@ -323,8 +323,11 @@ CONFIG_HTML = r"""<!doctype html>
<label>Refresh period (minutes) <label>Refresh period (minutes)
<input id="watchlistAutoRefreshMinutes" type="number" min="1" max="10080" step="1" value="60"> <input id="watchlistAutoRefreshMinutes" type="number" min="1" max="10080" step="1" value="60">
</label> </label>
<label>Delay between series (seconds)
<input id="watchlistRefreshDelaySeconds" type="number" min="0" max="300" step="1" value="5">
</label>
</div> </div>
<div class="field-hint">When enabled, the server periodically starts the existing background “Refresh All” job using this interval.</div> <div class="field-hint">When enabled, the server periodically starts the existing background “Refresh All” job using this interval. Bulk refreshes now wait this many seconds between shows to avoid flooding upstream sources.</div>
</section> </section>
<section class="deps" id="deps"></section> <section class="deps" id="deps"></section>
@@ -338,7 +341,8 @@ CONFIG_HTML = r"""<!doctype html>
quality: "best", quality: "best",
download_dir: "", download_dir: "",
watchlist_auto_refresh_enabled: false, watchlist_auto_refresh_enabled: false,
watchlist_auto_refresh_minutes: 60 watchlist_auto_refresh_minutes: 60,
watchlist_refresh_delay_seconds: 5
} }
}; };
@@ -356,6 +360,7 @@ CONFIG_HTML = r"""<!doctype html>
$("configQuality").value = state.config.quality; $("configQuality").value = state.config.quality;
$("watchlistAutoRefreshEnabled").value = String(Boolean(state.config.watchlist_auto_refresh_enabled)); $("watchlistAutoRefreshEnabled").value = String(Boolean(state.config.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = state.config.watchlist_auto_refresh_minutes; $("watchlistAutoRefreshMinutes").value = state.config.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = state.config.watchlist_refresh_delay_seconds;
} }
async function saveConfig() { async function saveConfig() {
@@ -367,7 +372,8 @@ CONFIG_HTML = r"""<!doctype html>
mode: $("configMode").value, mode: $("configMode").value,
quality: $("configQuality").value, quality: $("configQuality").value,
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true", watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value
}) })
}); });
state.config = data; state.config = data;
@@ -376,6 +382,7 @@ CONFIG_HTML = r"""<!doctype html>
$("configQuality").value = data.quality; $("configQuality").value = data.quality;
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled)); $("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes; $("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
setNotice("Defaults saved."); setNotice("Defaults saved.");
} catch (error) { } catch (error) {
setNotice(error.message); setNotice(error.message);
+23 -26
View File
@@ -2,7 +2,6 @@
"""Queue and background job services for ani-cli-web.""" """Queue and background job services for ani-cli-web."""
import concurrent.futures
import json import json
import os import os
import shutil import shutil
@@ -33,7 +32,6 @@ from app_support import (
LOG_PERSIST_INTERVAL_SECONDS = 0.75 LOG_PERSIST_INTERVAL_SECONDS = 0.75
LOG_PERSIST_LINE_BATCH = 8 LOG_PERSIST_LINE_BATCH = 8
WATCHLIST_REFRESH_MAX_WORKERS = 4
JOB_STRUCTURED_COLUMNS = ( JOB_STRUCTURED_COLUMNS = (
"show_id", "show_id",
"title", "title",
@@ -59,8 +57,9 @@ JOB_EXTRA_PAYLOAD_COLUMN = "payload"
class WatchlistRefreshJobs: class WatchlistRefreshJobs:
def __init__(self, store, start_worker=True): def __init__(self, store, config_getter=None, start_worker=True):
self.store = store self.store = store
self.config_getter = config_getter or (lambda: {})
self.lock = threading.RLock() self.lock = threading.RLock()
self.wakeup = threading.Event() self.wakeup = threading.Event()
self.stop_event = threading.Event() self.stop_event = threading.Event()
@@ -153,6 +152,14 @@ class WatchlistRefreshJobs:
self._upsert_job_conn(conn, job) self._upsert_job_conn(conn, job)
self._trim_history_conn(conn) self._trim_history_conn(conn)
def _refresh_delay_seconds(self):
config = self.config_getter() or {}
try:
delay = int(str(config.get("watchlist_refresh_delay_seconds", 5)).strip() or "5")
except (TypeError, ValueError):
delay = 5
return max(0, min(300, delay))
def _load_jobs(self): def _load_jobs(self):
with self.lock, self._connect() as conn: with self.lock, self._connect() as conn:
rows = conn.execute( rows = conn.execute(
@@ -257,6 +264,7 @@ class WatchlistRefreshJobs:
source_name = str(job.get("source") or "manual").strip().lower() or "manual" source_name = str(job.get("source") or "manual").strip().lower() or "manual"
try: try:
show_ids = self.store.all_show_ids() show_ids = self.store.all_show_ids()
delay_seconds = self._refresh_delay_seconds()
started_at = now_iso() started_at = now_iso()
with self.lock: with self.lock:
job["status"] = "running" job["status"] = "running"
@@ -271,13 +279,14 @@ class WatchlistRefreshJobs:
if source_name == "scheduled": if source_name == "scheduled":
self._stdout_log(f"Scheduled refresh started (job={job['id']}, total={len(show_ids)}).") self._stdout_log(f"Scheduled refresh started (job={job['id']}, total={len(show_ids)}).")
def refresh_one(show_id): for index, show_id in enumerate(show_ids, start=1):
if self.stop_event.is_set():
interrupted = True
break
title = show_id title = show_id
errored = False errored = False
skipped = False skipped = False
error_message = "" error_message = ""
if self.stop_event.is_set():
return {"title": title, "errored": False, "skipped": True, "error": ""}
try: try:
item = self.store.refresh(show_id, include_thumbnail=False) item = self.store.refresh(show_id, include_thumbnail=False)
if item is None: if item is None:
@@ -290,34 +299,22 @@ class WatchlistRefreshJobs:
errored = True errored = True
error_message = str(exc) error_message = str(exc)
debug_log("watchlist.refresh_all.item_error", show_id=show_id, error=exc) debug_log("watchlist.refresh_all.item_error", show_id=show_id, error=exc)
return {"title": title, "errored": errored, "skipped": skipped, "error": error_message}
max_workers = max(1, min(WATCHLIST_REFRESH_MAX_WORKERS, len(show_ids) or 1))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
with self.lock:
self.current_executor = executor
futures = [executor.submit(refresh_one, show_id) for show_id in show_ids]
for index, future in enumerate(concurrent.futures.as_completed(futures), start=1):
if self.stop_event.is_set():
interrupted = True
executor.shutdown(wait=False, cancel_futures=True)
break
result = future.result()
title = result["title"]
with self.lock: with self.lock:
job["completed"] = index job["completed"] = index
job["errors"] += 1 if result["errored"] else 0 job["errors"] += 1 if errored else 0
job["current_title"] = title job["current_title"] = title
job["updated_at"] = now_iso() job["updated_at"] = now_iso()
if result["errored"] and result["error"]: if errored and error_message:
job["last_error"] = result["error"] job["last_error"] = error_message
if result["skipped"]: if skipped:
job["message"] = f"Refreshing {index}/{len(show_ids)}: removed entry" job["message"] = f"Refreshing {index}/{len(show_ids)}: removed entry"
else: else:
job["message"] = f"Refreshing {index}/{len(show_ids)}: {title}" job["message"] = f"Refreshing {index}/{len(show_ids)}: {title}"
self._save_job_locked(job) self._save_job_locked(job)
with self.lock: if index < len(show_ids) and delay_seconds > 0:
self.current_executor = None if self.stop_event.wait(delay_seconds):
interrupted = True
break
finished_at = now_iso() finished_at = now_iso()
with self.lock: with self.lock:
+31 -1
View File
@@ -1074,6 +1074,7 @@ class ConfigSnapshotTests(unittest.TestCase):
"download_dir": "/tmp/example", "download_dir": "/tmp/example",
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60, "watchlist_auto_refresh_minutes": 60,
"watchlist_refresh_delay_seconds": 5,
} }
try: try:
snapshot = APP.get_config_snapshot() snapshot = APP.get_config_snapshot()
@@ -1086,16 +1087,19 @@ class ConfigSnapshotTests(unittest.TestCase):
config = APP.normalize_config({}) config = APP.normalize_config({})
self.assertFalse(config["watchlist_auto_refresh_enabled"]) self.assertFalse(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60) self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
def test_normalize_config_parses_watchlist_schedule_fields(self): def test_normalize_config_parses_watchlist_schedule_fields(self):
config = APP.normalize_config( config = APP.normalize_config(
{ {
"watchlist_auto_refresh_enabled": "true", "watchlist_auto_refresh_enabled": "true",
"watchlist_auto_refresh_minutes": "15", "watchlist_auto_refresh_minutes": "15",
"watchlist_refresh_delay_seconds": "7",
} }
) )
self.assertTrue(config["watchlist_auto_refresh_enabled"]) self.assertTrue(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15) self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
class WatchlistAutoRefreshSchedulerTests(unittest.TestCase): class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
@@ -1112,6 +1116,28 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
self.assertEqual(scheduler.tick(now_monotonic=700.0), 600) self.assertEqual(scheduler.tick(now_monotonic=700.0), 600)
start.assert_called_once_with(source="scheduled") start.assert_called_once_with(source="scheduled")
def test_refresh_job_waits_between_series_using_configured_delay(self):
class FakeStore:
def all_show_ids(self):
return ["show-a", "show-b", "show-c"]
def refresh(self, show_id, include_thumbnail=True):
return {"title": show_id, "status": "updated"}
service = APP.WatchlistRefreshJobs(
FakeStore(),
config_getter=lambda: {"watchlist_refresh_delay_seconds": 5},
start_worker=False,
)
job = service.start()["job"]
job = service._next_pending()
with mock.patch.object(service.stop_event, "wait", return_value=False) as wait_mock:
service._run_job(job)
self.assertEqual(wait_mock.call_count, 2)
wait_mock.assert_called_with(5)
def test_tick_resets_timer_when_schedule_changes(self): def test_tick_resets_timer_when_schedule_changes(self):
config = { config = {
"watchlist_auto_refresh_enabled": True, "watchlist_auto_refresh_enabled": True,
@@ -1196,6 +1222,7 @@ class StartupBehaviorTests(unittest.TestCase):
"download_dir": "/tmp/old", "download_dir": "/tmp/old",
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60, "watchlist_auto_refresh_minutes": 60,
"watchlist_refresh_delay_seconds": 5,
} }
APP.WATCHLIST_AUTO_REFRESH = mock.Mock() APP.WATCHLIST_AUTO_REFRESH = mock.Mock()
saved = APP.save_runtime_config( saved = APP.save_runtime_config(
@@ -1205,6 +1232,7 @@ class StartupBehaviorTests(unittest.TestCase):
"download_dir": "/tmp/new", "download_dir": "/tmp/new",
"watchlist_auto_refresh_enabled": True, "watchlist_auto_refresh_enabled": True,
"watchlist_auto_refresh_minutes": 15, "watchlist_auto_refresh_minutes": 15,
"watchlist_refresh_delay_seconds": 8,
} }
) )
self.assertEqual(saved["mode"], "dub") self.assertEqual(saved["mode"], "dub")
@@ -1212,6 +1240,7 @@ class StartupBehaviorTests(unittest.TestCase):
self.assertEqual(APP.CONFIG["quality"], "720") self.assertEqual(APP.CONFIG["quality"], "720")
self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"]) self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"])
self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15) self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8)
APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with() APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with()
finally: finally:
APP.CONFIG = original APP.CONFIG = original
@@ -1247,12 +1276,13 @@ class HandlerRouteTests(unittest.TestCase):
APP.initialize_runtime(start_workers=False) APP.initialize_runtime(start_workers=False)
def test_config_post_accepts_watchlist_schedule_fields(self): 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"}' 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"}'
handler = DummyHandler("/api/config", body=body, content_length=len(body)) handler = DummyHandler("/api/config", body=body, content_length=len(body))
APP.Handler.do_POST(handler) APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"]) 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_auto_refresh_minutes"], 25)
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9)
def test_body_json_rejects_oversized_request(self): def test_body_json_rejects_oversized_request(self):
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1) handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1)