diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a2bbe..0b81944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 5cbef38..ccc55af 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/VERSION b/VERSION index db287d4..26bea73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.30.4 +0.31.0 diff --git a/app.py b/app.py index cd18122..52597d4 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/app_support.py b/app_support.py index 9df2d96..c3303ef 100644 --- a/app_support.py +++ b/app_support.py @@ -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 diff --git a/config_page.py b/config_page.py index cad72b2..67639a7 100644 --- a/config_page.py +++ b/config_page.py @@ -306,13 +306,40 @@ CONFIG_HTML = r"""
Tip: these are the saved defaults only. The active search form can still be adjusted per download.
+
+
+
+

Watchlist schedule

+

Periodically queue a background watchlist refresh to check for newly available episodes.

+
+
+
+ + +
+
When enabled, the server periodically starts the existing background “Refresh All” job using this interval.
+
+