Add scheduled watchlist refresh support

This commit is contained in:
Dymas
2026-05-17 20:38:16 +02:00
parent 47480efa30
commit 6168acb272
8 changed files with 285 additions and 16 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.31.0 - 2026-05-17
- Added scheduled background watchlist refresh support so the app can periodically recheck episode counts for tracked anime.
- Added Config page controls for enabling or disabling the scheduled refresh and setting the refresh period in minutes.
- Reused the existing background `Refresh All` worker for scheduled runs and expanded regression coverage for the new config fields and scheduler timing behavior.
## 0.30.4 - 2026-05-17 ## 0.30.4 - 2026-05-17
- Added browser-friendly remote auth bootstrap: remote users can open the app once with `?token=...`, the server stores the token in an HTTP-only cookie, and then strips the token back out of the URL with a redirect. - Added browser-friendly remote auth bootstrap: remote users can open the app once with `?token=...`, the server stores the token in an HTTP-only cookie, and then strips the token back out of the URL with a redirect.
+6 -1
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.30.4` Current version: `0.31.0`
## Project Layout ## Project Layout
@@ -157,6 +157,7 @@ The app currently includes:
- 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, parallel per-show refresh work, and no inline thumbnail downloads during the bulk job.
- Optional scheduled background watchlist refreshes with Config page controls for enable/disable and refresh period.
- 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.
- That shared browser polling helper now also tears itself down on page unload and uses chained `setTimeout` scheduling instead of a bare repeating interval. - 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. - 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.
@@ -202,6 +203,8 @@ Use it to:
1. Set the default download folder. 1. Set the default download folder.
2. Choose the default `sub` or `dub` search mode. 2. Choose the default `sub` or `dub` search mode.
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.
5. Set the refresh period in minutes for that scheduled watchlist job.
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.
@@ -388,6 +391,8 @@ 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.
## Notes ## Notes
- Importing `app.py` no longer boots the runtime worker threads automatically; runtime setup is deferred until startup or first use. - Importing `app.py` no longer boots the runtime worker threads automatically; runtime setup is deferred until startup or first use.
+1 -1
View File
@@ -1 +1 @@
0.30.4 0.31.0
+43 -8
View File
@@ -57,7 +57,7 @@ from app_support import (
write_json, write_json,
) )
from http_handler import HandlerContext, build_handler_class, server_host, server_port from http_handler import HandlerContext, build_handler_class, server_host, server_port
from queue_jobs import DownloadQueue, WatchlistRefreshJobs from queue_jobs import DownloadQueue, WatchlistAutoRefreshScheduler, WatchlistRefreshJobs
from title_matching import ( from title_matching import (
base_title_variants, base_title_variants,
normalize_title_key, normalize_title_key,
@@ -82,6 +82,7 @@ CONFIG = None
WATCHLIST = None WATCHLIST = None
DOWNLOAD_QUEUE = None DOWNLOAD_QUEUE = None
WATCHLIST_REFRESH = None WATCHLIST_REFRESH = None
WATCHLIST_AUTO_REFRESH = None
def graph_request(query, variables, timeout=25): def graph_request(query, variables, timeout=25):
@@ -1780,10 +1781,14 @@ def get_config_snapshot(fallback=None):
def save_runtime_config(config): def save_runtime_config(config):
normalized = normalize_config(config) normalized = normalize_config(config)
scheduler = None
with RUNTIME_LOCK: with RUNTIME_LOCK:
global CONFIG global CONFIG
CONFIG = dict(normalized) CONFIG = dict(normalized)
write_json(CONFIG_PATH, CONFIG) write_json(CONFIG_PATH, CONFIG)
scheduler = WATCHLIST_AUTO_REFRESH
if scheduler is not None:
scheduler.notify_config_changed()
return dict(CONFIG) return dict(CONFIG)
@@ -1793,6 +1798,7 @@ def runtime_state():
"watchlist": WATCHLIST, "watchlist": WATCHLIST,
"download_queue": DOWNLOAD_QUEUE, "download_queue": DOWNLOAD_QUEUE,
"watchlist_refresh": WATCHLIST_REFRESH, "watchlist_refresh": WATCHLIST_REFRESH,
"watchlist_auto_refresh": WATCHLIST_AUTO_REFRESH,
} }
@@ -1809,35 +1815,55 @@ def build_runtime(start_workers=None):
start_worker=worker_state, start_worker=worker_state,
) )
watchlist_refresh = WatchlistRefreshJobs(watchlist, start_worker=worker_state) watchlist_refresh = WatchlistRefreshJobs(watchlist, start_worker=worker_state)
watchlist_auto_refresh = WatchlistAutoRefreshScheduler(
config_getter=lambda: get_config_snapshot(config),
refresh_start_fn=watchlist_refresh.start,
start_worker=worker_state,
)
return { return {
"config": config, "config": config,
"watchlist": watchlist, "watchlist": watchlist,
"download_queue": download_queue, "download_queue": download_queue,
"watchlist_refresh": watchlist_refresh, "watchlist_refresh": watchlist_refresh,
"watchlist_auto_refresh": watchlist_auto_refresh,
} }
def initialize_runtime(start_workers=None): def initialize_runtime(start_workers=None):
global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH
with RUNTIME_LOCK: with RUNTIME_LOCK:
workers_allowed = background_workers_enabled() workers_allowed = background_workers_enabled()
if CONFIG is not None and WATCHLIST is not None and DOWNLOAD_QUEUE is not None and WATCHLIST_REFRESH is not None: if (
CONFIG is not None
and WATCHLIST is not None
and DOWNLOAD_QUEUE is not None
and WATCHLIST_REFRESH is not None
and WATCHLIST_AUTO_REFRESH is not None
):
if start_workers and workers_allowed: if start_workers and workers_allowed:
WATCHLIST.worker_enabled = True WATCHLIST.worker_enabled = True
WATCHLIST.ensure_worker() WATCHLIST.ensure_worker()
DOWNLOAD_QUEUE.ensure_worker() DOWNLOAD_QUEUE.ensure_worker()
WATCHLIST_REFRESH.ensure_worker() WATCHLIST_REFRESH.ensure_worker()
WATCHLIST_AUTO_REFRESH.ensure_worker()
return runtime_state() return runtime_state()
runtime = build_runtime(start_workers=start_workers) runtime = build_runtime(start_workers=start_workers)
CONFIG = runtime["config"] CONFIG = runtime["config"]
WATCHLIST = runtime["watchlist"] WATCHLIST = runtime["watchlist"]
DOWNLOAD_QUEUE = runtime["download_queue"] DOWNLOAD_QUEUE = runtime["download_queue"]
WATCHLIST_REFRESH = runtime["watchlist_refresh"] WATCHLIST_REFRESH = runtime["watchlist_refresh"]
WATCHLIST_AUTO_REFRESH = runtime["watchlist_auto_refresh"]
return runtime return runtime
def ensure_runtime(start_workers=False): def ensure_runtime(start_workers=False):
if CONFIG is None or WATCHLIST is None or DOWNLOAD_QUEUE is None or WATCHLIST_REFRESH is None: if (
CONFIG is None
or WATCHLIST is None
or DOWNLOAD_QUEUE is None
or WATCHLIST_REFRESH is None
or WATCHLIST_AUTO_REFRESH is None
):
return initialize_runtime(start_workers=start_workers) return initialize_runtime(start_workers=start_workers)
if start_workers: if start_workers:
return initialize_runtime(start_workers=True) return initialize_runtime(start_workers=True)
@@ -1847,10 +1873,16 @@ def ensure_runtime(start_workers=False):
def shutdown_runtime(wait=False, cancel_active_downloads=None): def shutdown_runtime(wait=False, cancel_active_downloads=None):
services = None services = None
with RUNTIME_LOCK: with RUNTIME_LOCK:
if CONFIG is None and WATCHLIST is None and DOWNLOAD_QUEUE is None and WATCHLIST_REFRESH is None: if (
CONFIG is None
and WATCHLIST is None
and DOWNLOAD_QUEUE is None
and WATCHLIST_REFRESH is None
and WATCHLIST_AUTO_REFRESH is None
):
return False return False
services = (WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH) services = (WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH)
watchlist, download_queue, watchlist_refresh = services watchlist, download_queue, watchlist_refresh, watchlist_auto_refresh = services
should_cancel_downloads = bool(wait) if cancel_active_downloads is None else bool(cancel_active_downloads) should_cancel_downloads = bool(wait) if cancel_active_downloads is None else bool(cancel_active_downloads)
stopped = True stopped = True
if watchlist is not None: if watchlist is not None:
@@ -1859,11 +1891,13 @@ def shutdown_runtime(wait=False, cancel_active_downloads=None):
stopped = download_queue.shutdown(wait=wait, cancel_active=should_cancel_downloads) and stopped stopped = download_queue.shutdown(wait=wait, cancel_active=should_cancel_downloads) and stopped
if watchlist_refresh is not None: if watchlist_refresh is not None:
stopped = watchlist_refresh.shutdown(wait=wait) and stopped stopped = watchlist_refresh.shutdown(wait=wait) and stopped
if watchlist_auto_refresh is not None:
stopped = watchlist_auto_refresh.shutdown(wait=wait) and stopped
return stopped return stopped
def reset_runtime(wait=False, cancel_active_downloads=None): def reset_runtime(wait=False, cancel_active_downloads=None):
global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH global CONFIG, WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH
stopped = shutdown_runtime(wait=wait, cancel_active_downloads=cancel_active_downloads) stopped = shutdown_runtime(wait=wait, cancel_active_downloads=cancel_active_downloads)
if not stopped: if not stopped:
if wait: if wait:
@@ -1874,6 +1908,7 @@ def reset_runtime(wait=False, cancel_active_downloads=None):
WATCHLIST = None WATCHLIST = None
DOWNLOAD_QUEUE = None DOWNLOAD_QUEUE = None
WATCHLIST_REFRESH = None WATCHLIST_REFRESH = None
WATCHLIST_AUTO_REFRESH = None
return True return True
+24 -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.30.4" VERSION = "0.31.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"
@@ -42,6 +42,9 @@ MAX_LOG_LINES = 240
MAX_JSON_BODY_BYTES = 12 * 1024 * 1024 MAX_JSON_BODY_BYTES = 12 * 1024 * 1024
WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS = 8 WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS = 8
WORKER_SHUTDOWN_TIMEOUT_SECONDS = 10 WORKER_SHUTDOWN_TIMEOUT_SECONDS = 10
WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES = 60
WATCHLIST_AUTO_REFRESH_MIN_MINUTES = 1
WATCHLIST_AUTO_REFRESH_MAX_MINUTES = 7 * 24 * 60
def env_flag(name, default=False): def env_flag(name, default=False):
@@ -114,6 +117,8 @@ DEFAULT_CONFIG = {
"download_dir": default_download_dir(), "download_dir": default_download_dir(),
"mode": os.environ.get("ANI_CLI_MODE", "sub"), "mode": os.environ.get("ANI_CLI_MODE", "sub"),
"quality": os.environ.get("ANI_CLI_QUALITY", "best"), "quality": os.environ.get("ANI_CLI_QUALITY", "best"),
"watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
} }
@@ -254,10 +259,28 @@ def normalize_config(data):
if quality.endswith("p") and quality[:-1].isdigit(): if quality.endswith("p") and quality[:-1].isdigit():
quality = quality[:-1] quality = quality[:-1]
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_minutes = config.get("watchlist_auto_refresh_minutes")
if isinstance(auto_refresh_enabled, str):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
else:
auto_refresh_enabled = bool(auto_refresh_enabled)
try:
auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES)
except (TypeError, ValueError):
auto_refresh_minutes = WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES
auto_refresh_minutes = max(
WATCHLIST_AUTO_REFRESH_MIN_MINUTES,
min(WATCHLIST_AUTO_REFRESH_MAX_MINUTES, auto_refresh_minutes),
)
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_minutes"] = auto_refresh_minutes
return config return config
+35 -2
View File
@@ -306,13 +306,40 @@ CONFIG_HTML = r"""<!doctype html>
<div class="field-hint">Tip: these are the saved defaults only. The active search form can still be adjusted per download.</div> <div class="field-hint">Tip: these are the saved defaults only. The active search form can still be adjusted per download.</div>
</section> </section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Watchlist schedule</h2>
<p class="muted">Periodically queue a background watchlist refresh to check for newly available episodes.</p>
</div>
</div>
<div class="form-grid">
<label>Automatic refresh
<select id="watchlistAutoRefreshEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</label>
<label>Refresh period (minutes)
<input id="watchlistAutoRefreshMinutes" type="number" min="1" max="10080" step="1" value="60">
</label>
</div>
<div class="field-hint">When enabled, the server periodically starts the existing background “Refresh All” job using this interval.</div>
</section>
<section class="deps" id="deps"></section> <section class="deps" id="deps"></section>
</main> </main>
</div> </div>
<script> <script>
const state = { const state = {
config: { mode: "sub", quality: "best", download_dir: "" } config: {
mode: "sub",
quality: "best",
download_dir: "",
watchlist_auto_refresh_enabled: false,
watchlist_auto_refresh_minutes: 60
}
}; };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -327,6 +354,8 @@ CONFIG_HTML = r"""<!doctype html>
$("downloadDir").value = state.config.download_dir; $("downloadDir").value = state.config.download_dir;
$("configMode").value = state.config.mode; $("configMode").value = state.config.mode;
$("configQuality").value = state.config.quality; $("configQuality").value = state.config.quality;
$("watchlistAutoRefreshEnabled").value = String(Boolean(state.config.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = state.config.watchlist_auto_refresh_minutes;
} }
async function saveConfig() { async function saveConfig() {
@@ -336,13 +365,17 @@ CONFIG_HTML = r"""<!doctype html>
body: JSON.stringify({ body: JSON.stringify({
download_dir: $("downloadDir").value, download_dir: $("downloadDir").value,
mode: $("configMode").value, mode: $("configMode").value,
quality: $("configQuality").value quality: $("configQuality").value,
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value
}) })
}); });
state.config = data; state.config = data;
$("downloadDir").value = data.download_dir; $("downloadDir").value = data.download_dir;
$("configMode").value = data.mode; $("configMode").value = data.mode;
$("configQuality").value = data.quality; $("configQuality").value = data.quality;
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
setNotice("Defaults saved."); setNotice("Defaults saved.");
} catch (error) { } catch (error) {
setNotice(error.message); setNotice(error.message);
+75
View File
@@ -335,6 +335,81 @@ class WatchlistRefreshJobs:
self.current_job_id = None self.current_job_id = None
class WatchlistAutoRefreshScheduler:
def __init__(self, config_getter, refresh_start_fn, start_worker=True):
self.config_getter = config_getter
self.refresh_start_fn = refresh_start_fn
self.lock = threading.RLock()
self.wakeup = threading.Event()
self.stop_event = threading.Event()
self.worker = None
self.last_enabled = None
self.last_interval_seconds = None
self.last_attempt_monotonic = None
if start_worker:
self.ensure_worker()
def ensure_worker(self):
with self.lock:
if self.worker is not None and self.worker.is_alive():
return
self.stop_event.clear()
self.worker = threading.Thread(target=self._run, daemon=True)
self.worker.start()
def shutdown(self, wait=False):
self.stop_event.set()
self.wakeup.set()
worker = self.worker
if wait and worker is not None and worker.is_alive():
worker.join(timeout=WORKER_SHUTDOWN_TIMEOUT_SECONDS)
return worker is None or not worker.is_alive()
def notify_config_changed(self):
self.wakeup.set()
def _config_state(self):
config = self.config_getter() or {}
enabled = bool(config.get("watchlist_auto_refresh_enabled"))
try:
minutes = int(str(config.get("watchlist_auto_refresh_minutes", 60)).strip() or "60")
except (TypeError, ValueError):
minutes = 60
minutes = max(1, min(7 * 24 * 60, minutes))
return enabled, minutes * 60
def tick(self, now_monotonic=None):
now = time.monotonic() if now_monotonic is None else float(now_monotonic)
enabled, interval_seconds = self._config_state()
with self.lock:
changed = enabled != self.last_enabled or interval_seconds != self.last_interval_seconds
if changed:
self.last_enabled = enabled
self.last_interval_seconds = interval_seconds
self.last_attempt_monotonic = now
if not enabled:
return None
if self.last_attempt_monotonic is None:
self.last_attempt_monotonic = now
return interval_seconds
remaining = interval_seconds - (now - self.last_attempt_monotonic)
if remaining > 0:
return remaining
self.last_attempt_monotonic = now
try:
result = self.refresh_start_fn()
debug_log("watchlist.auto_refresh.tick", result=result)
except Exception as exc:
debug_log("watchlist.auto_refresh.error", error=exc)
return interval_seconds
def _run(self):
while not self.stop_event.is_set():
wait_seconds = self.tick()
self.wakeup.wait(timeout=wait_seconds)
self.wakeup.clear()
class DownloadQueue: class DownloadQueue:
def __init__(self, config_getter, watchlist_sync_fn=None, job_prepare_fn=None, start_worker=True): def __init__(self, config_getter, watchlist_sync_fn=None, job_prepare_fn=None, start_worker=True):
self.config_getter = config_getter self.config_getter = config_getter
+95 -3
View File
@@ -1048,7 +1048,13 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
class ConfigSnapshotTests(unittest.TestCase): class ConfigSnapshotTests(unittest.TestCase):
def test_get_config_snapshot_returns_copy(self): def test_get_config_snapshot_returns_copy(self):
original = APP.CONFIG original = APP.CONFIG
APP.CONFIG = {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"} APP.CONFIG = {
"mode": "sub",
"quality": "best",
"download_dir": "/tmp/example",
"watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60,
}
try: try:
snapshot = APP.get_config_snapshot() snapshot = APP.get_config_snapshot()
snapshot["mode"] = "dub" snapshot["mode"] = "dub"
@@ -1056,6 +1062,60 @@ class ConfigSnapshotTests(unittest.TestCase):
finally: finally:
APP.CONFIG = original APP.CONFIG = original
def test_normalize_config_includes_watchlist_schedule_defaults(self):
config = APP.normalize_config({})
self.assertFalse(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
def test_normalize_config_parses_watchlist_schedule_fields(self):
config = APP.normalize_config(
{
"watchlist_auto_refresh_enabled": "true",
"watchlist_auto_refresh_minutes": "15",
}
)
self.assertTrue(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
def test_tick_waits_then_triggers_refresh(self):
config = {
"watchlist_auto_refresh_enabled": True,
"watchlist_auto_refresh_minutes": 10,
}
start = mock.Mock(return_value={"created": True})
scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False)
self.assertEqual(scheduler.tick(now_monotonic=100.0), 600)
self.assertEqual(scheduler.tick(now_monotonic=699.0), 1)
self.assertEqual(scheduler.tick(now_monotonic=700.0), 600)
start.assert_called_once()
def test_tick_resets_timer_when_schedule_changes(self):
config = {
"watchlist_auto_refresh_enabled": True,
"watchlist_auto_refresh_minutes": 10,
}
start = mock.Mock(return_value={"created": True})
scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False)
self.assertEqual(scheduler.tick(now_monotonic=100.0), 600)
config["watchlist_auto_refresh_minutes"] = 5
self.assertEqual(scheduler.tick(now_monotonic=150.0), 300)
start.assert_not_called()
def test_tick_returns_none_when_disabled(self):
config = {
"watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 10,
}
start = mock.Mock(return_value={"created": True})
scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False)
self.assertIsNone(scheduler.tick(now_monotonic=100.0))
start.assert_not_called()
class StartupBehaviorTests(unittest.TestCase): class StartupBehaviorTests(unittest.TestCase):
def test_main_does_not_eagerly_initialize_runtime(self): def test_main_does_not_eagerly_initialize_runtime(self):
@@ -1092,6 +1152,7 @@ class StartupBehaviorTests(unittest.TestCase):
current_watchlist = APP.WATCHLIST current_watchlist = APP.WATCHLIST
current_queue = APP.DOWNLOAD_QUEUE current_queue = APP.DOWNLOAD_QUEUE
current_refresh = APP.WATCHLIST_REFRESH current_refresh = APP.WATCHLIST_REFRESH
current_auto_refresh = APP.WATCHLIST_AUTO_REFRESH
with mock.patch.object(APP, "shutdown_runtime", return_value=False): with mock.patch.object(APP, "shutdown_runtime", return_value=False):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
APP.reset_runtime(wait=True) APP.reset_runtime(wait=True)
@@ -1100,20 +1161,41 @@ class StartupBehaviorTests(unittest.TestCase):
self.assertIs(APP.WATCHLIST, current_watchlist) self.assertIs(APP.WATCHLIST, current_watchlist)
self.assertIs(APP.DOWNLOAD_QUEUE, current_queue) self.assertIs(APP.DOWNLOAD_QUEUE, current_queue)
self.assertIs(APP.WATCHLIST_REFRESH, current_refresh) self.assertIs(APP.WATCHLIST_REFRESH, current_refresh)
self.assertIs(APP.WATCHLIST_AUTO_REFRESH, current_auto_refresh)
finally: finally:
APP.reset_runtime(wait=True) APP.reset_runtime(wait=True)
APP.initialize_runtime(start_workers=False) APP.initialize_runtime(start_workers=False)
def test_save_runtime_config_replaces_runtime_config(self): def test_save_runtime_config_replaces_runtime_config(self):
original = APP.CONFIG original = APP.CONFIG
original_scheduler = APP.WATCHLIST_AUTO_REFRESH
try: try:
APP.CONFIG = {"mode": "sub", "quality": "best", "download_dir": "/tmp/old"} APP.CONFIG = {
saved = APP.save_runtime_config({"mode": "dub", "quality": "720", "download_dir": "/tmp/new"}) "mode": "sub",
"quality": "best",
"download_dir": "/tmp/old",
"watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60,
}
APP.WATCHLIST_AUTO_REFRESH = mock.Mock()
saved = APP.save_runtime_config(
{
"mode": "dub",
"quality": "720",
"download_dir": "/tmp/new",
"watchlist_auto_refresh_enabled": True,
"watchlist_auto_refresh_minutes": 15,
}
)
self.assertEqual(saved["mode"], "dub") self.assertEqual(saved["mode"], "dub")
self.assertEqual(APP.CONFIG["mode"], "dub") self.assertEqual(APP.CONFIG["mode"], "dub")
self.assertEqual(APP.CONFIG["quality"], "720") self.assertEqual(APP.CONFIG["quality"], "720")
self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"])
self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15)
APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with()
finally: finally:
APP.CONFIG = original APP.CONFIG = original
APP.WATCHLIST_AUTO_REFRESH = original_scheduler
def test_reset_runtime_clears_runtime_services(self): def test_reset_runtime_clears_runtime_services(self):
APP.initialize_runtime(start_workers=False) APP.initialize_runtime(start_workers=False)
@@ -1123,6 +1205,7 @@ class StartupBehaviorTests(unittest.TestCase):
self.assertIsNone(APP.WATCHLIST) self.assertIsNone(APP.WATCHLIST)
self.assertIsNone(APP.DOWNLOAD_QUEUE) self.assertIsNone(APP.DOWNLOAD_QUEUE)
self.assertIsNone(APP.WATCHLIST_REFRESH) self.assertIsNone(APP.WATCHLIST_REFRESH)
self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH)
finally: finally:
APP.initialize_runtime(start_workers=False) APP.initialize_runtime(start_workers=False)
@@ -1138,10 +1221,19 @@ class HandlerRouteTests(unittest.TestCase):
self.assertIsNone(APP.WATCHLIST.refresh_worker) self.assertIsNone(APP.WATCHLIST.refresh_worker)
self.assertIsNone(APP.DOWNLOAD_QUEUE.worker) self.assertIsNone(APP.DOWNLOAD_QUEUE.worker)
self.assertIsNone(APP.WATCHLIST_REFRESH.worker) self.assertIsNone(APP.WATCHLIST_REFRESH.worker)
self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH.worker)
finally: finally:
APP.reset_runtime(wait=True) APP.reset_runtime(wait=True)
APP.initialize_runtime(start_workers=False) 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"}'
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)
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)
with self.assertRaises(APP.HttpError) as ctx: with self.assertRaises(APP.HttpError) as ctx: