Pace bulk watchlist refresh requests
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user