Add scheduled watchlist refresh support
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A local web UI for a system-wide `ani-cli` install.
|
||||
|
||||
Current version: `0.30.4`
|
||||
Current version: `0.31.0`
|
||||
|
||||
## 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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -202,6 +203,8 @@ Use it to:
|
||||
1. Set the default download folder.
|
||||
2. Choose the default `sub` or `dub` search mode.
|
||||
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.
|
||||
|
||||
@@ -388,6 +391,8 @@ Environment variables:
|
||||
|
||||
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
|
||||
|
||||
- Importing `app.py` no longer boots the runtime worker threads automatically; runtime setup is deferred until startup or first use.
|
||||
|
||||
@@ -57,7 +57,7 @@ from app_support import (
|
||||
write_json,
|
||||
)
|
||||
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 (
|
||||
base_title_variants,
|
||||
normalize_title_key,
|
||||
@@ -82,6 +82,7 @@ CONFIG = None
|
||||
WATCHLIST = None
|
||||
DOWNLOAD_QUEUE = None
|
||||
WATCHLIST_REFRESH = None
|
||||
WATCHLIST_AUTO_REFRESH = None
|
||||
|
||||
|
||||
def graph_request(query, variables, timeout=25):
|
||||
@@ -1780,10 +1781,14 @@ def get_config_snapshot(fallback=None):
|
||||
|
||||
def save_runtime_config(config):
|
||||
normalized = normalize_config(config)
|
||||
scheduler = None
|
||||
with RUNTIME_LOCK:
|
||||
global CONFIG
|
||||
CONFIG = dict(normalized)
|
||||
write_json(CONFIG_PATH, CONFIG)
|
||||
scheduler = WATCHLIST_AUTO_REFRESH
|
||||
if scheduler is not None:
|
||||
scheduler.notify_config_changed()
|
||||
return dict(CONFIG)
|
||||
|
||||
|
||||
@@ -1793,6 +1798,7 @@ def runtime_state():
|
||||
"watchlist": WATCHLIST,
|
||||
"download_queue": DOWNLOAD_QUEUE,
|
||||
"watchlist_refresh": WATCHLIST_REFRESH,
|
||||
"watchlist_auto_refresh": WATCHLIST_AUTO_REFRESH,
|
||||
}
|
||||
|
||||
|
||||
@@ -1809,35 +1815,55 @@ def build_runtime(start_workers=None):
|
||||
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 {
|
||||
"config": config,
|
||||
"watchlist": watchlist,
|
||||
"download_queue": download_queue,
|
||||
"watchlist_refresh": watchlist_refresh,
|
||||
"watchlist_auto_refresh": watchlist_auto_refresh,
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
WATCHLIST.worker_enabled = True
|
||||
WATCHLIST.ensure_worker()
|
||||
DOWNLOAD_QUEUE.ensure_worker()
|
||||
WATCHLIST_REFRESH.ensure_worker()
|
||||
WATCHLIST_AUTO_REFRESH.ensure_worker()
|
||||
return runtime_state()
|
||||
runtime = build_runtime(start_workers=start_workers)
|
||||
CONFIG = runtime["config"]
|
||||
WATCHLIST = runtime["watchlist"]
|
||||
DOWNLOAD_QUEUE = runtime["download_queue"]
|
||||
WATCHLIST_REFRESH = runtime["watchlist_refresh"]
|
||||
WATCHLIST_AUTO_REFRESH = runtime["watchlist_auto_refresh"]
|
||||
return runtime
|
||||
|
||||
|
||||
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)
|
||||
if start_workers:
|
||||
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):
|
||||
services = None
|
||||
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
|
||||
services = (WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH)
|
||||
watchlist, download_queue, watchlist_refresh = services
|
||||
services = (WATCHLIST, DOWNLOAD_QUEUE, WATCHLIST_REFRESH, WATCHLIST_AUTO_REFRESH)
|
||||
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)
|
||||
stopped = True
|
||||
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
|
||||
if watchlist_refresh is not None:
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
if not stopped:
|
||||
if wait:
|
||||
@@ -1874,6 +1908,7 @@ def reset_runtime(wait=False, cancel_active_downloads=None):
|
||||
WATCHLIST = None
|
||||
DOWNLOAD_QUEUE = None
|
||||
WATCHLIST_REFRESH = None
|
||||
WATCHLIST_AUTO_REFRESH = None
|
||||
return True
|
||||
|
||||
|
||||
|
||||
+24
-1
@@ -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.30.4"
|
||||
VERSION = "0.31.0"
|
||||
ALLANIME_BASE = "allanime.day"
|
||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||
ALLANIME_REFERER = "https://allmanga.to"
|
||||
@@ -42,6 +42,9 @@ MAX_LOG_LINES = 240
|
||||
MAX_JSON_BODY_BYTES = 12 * 1024 * 1024
|
||||
WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS = 8
|
||||
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):
|
||||
@@ -114,6 +117,8 @@ DEFAULT_CONFIG = {
|
||||
"download_dir": default_download_dir(),
|
||||
"mode": os.environ.get("ANI_CLI_MODE", "sub"),
|
||||
"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():
|
||||
quality = quality[:-1]
|
||||
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["quality"] = quality if quality in QUALITY_CHOICES else "best"
|
||||
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
|
||||
|
||||
|
||||
|
||||
+35
-2
@@ -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>
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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);
|
||||
@@ -327,6 +354,8 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
$("downloadDir").value = state.config.download_dir;
|
||||
$("configMode").value = state.config.mode;
|
||||
$("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() {
|
||||
@@ -336,13 +365,17 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
body: JSON.stringify({
|
||||
download_dir: $("downloadDir").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;
|
||||
$("downloadDir").value = data.download_dir;
|
||||
$("configMode").value = data.mode;
|
||||
$("configQuality").value = data.quality;
|
||||
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
|
||||
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
|
||||
setNotice("Defaults saved.");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
|
||||
@@ -335,6 +335,81 @@ class WatchlistRefreshJobs:
|
||||
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:
|
||||
def __init__(self, config_getter, watchlist_sync_fn=None, job_prepare_fn=None, start_worker=True):
|
||||
self.config_getter = config_getter
|
||||
|
||||
+95
-3
@@ -1048,7 +1048,13 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
|
||||
class ConfigSnapshotTests(unittest.TestCase):
|
||||
def test_get_config_snapshot_returns_copy(self):
|
||||
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:
|
||||
snapshot = APP.get_config_snapshot()
|
||||
snapshot["mode"] = "dub"
|
||||
@@ -1056,6 +1062,60 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
finally:
|
||||
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):
|
||||
def test_main_does_not_eagerly_initialize_runtime(self):
|
||||
@@ -1092,6 +1152,7 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
current_watchlist = APP.WATCHLIST
|
||||
current_queue = APP.DOWNLOAD_QUEUE
|
||||
current_refresh = APP.WATCHLIST_REFRESH
|
||||
current_auto_refresh = APP.WATCHLIST_AUTO_REFRESH
|
||||
with mock.patch.object(APP, "shutdown_runtime", return_value=False):
|
||||
with self.assertRaises(RuntimeError):
|
||||
APP.reset_runtime(wait=True)
|
||||
@@ -1100,20 +1161,41 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
self.assertIs(APP.WATCHLIST, current_watchlist)
|
||||
self.assertIs(APP.DOWNLOAD_QUEUE, current_queue)
|
||||
self.assertIs(APP.WATCHLIST_REFRESH, current_refresh)
|
||||
self.assertIs(APP.WATCHLIST_AUTO_REFRESH, current_auto_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
|
||||
original_scheduler = APP.WATCHLIST_AUTO_REFRESH
|
||||
try:
|
||||
APP.CONFIG = {"mode": "sub", "quality": "best", "download_dir": "/tmp/old"}
|
||||
saved = APP.save_runtime_config({"mode": "dub", "quality": "720", "download_dir": "/tmp/new"})
|
||||
APP.CONFIG = {
|
||||
"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(APP.CONFIG["mode"], "dub")
|
||||
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:
|
||||
APP.CONFIG = original
|
||||
APP.WATCHLIST_AUTO_REFRESH = original_scheduler
|
||||
|
||||
def test_reset_runtime_clears_runtime_services(self):
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
@@ -1123,6 +1205,7 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
self.assertIsNone(APP.WATCHLIST)
|
||||
self.assertIsNone(APP.DOWNLOAD_QUEUE)
|
||||
self.assertIsNone(APP.WATCHLIST_REFRESH)
|
||||
self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH)
|
||||
finally:
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
|
||||
@@ -1138,10 +1221,19 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
self.assertIsNone(APP.WATCHLIST.refresh_worker)
|
||||
self.assertIsNone(APP.DOWNLOAD_QUEUE.worker)
|
||||
self.assertIsNone(APP.WATCHLIST_REFRESH.worker)
|
||||
self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH.worker)
|
||||
finally:
|
||||
APP.reset_runtime(wait=True)
|
||||
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):
|
||||
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1)
|
||||
with self.assertRaises(APP.HttpError) as ctx:
|
||||
|
||||
Reference in New Issue
Block a user