From a8d7d3de00093a785cf4b363feeae14227faf312 Mon Sep 17 00:00:00 2001 From: Dymas Date: Sun, 17 May 2026 21:06:48 +0200 Subject: [PATCH] Log scheduled refresh lifecycle to stdout --- CHANGELOG.md | 4 ++++ README.md | 3 ++- VERSION | 2 +- app_support.py | 2 +- queue_jobs.py | 33 +++++++++++++++++++++++++++++++-- test_app.py | 22 +++++++++++++++++++++- 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b81944..dcedbbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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. + ## 0.31.0 - 2026-05-17 - Added scheduled background watchlist refresh support so the app can periodically recheck episode counts for tracked anime. diff --git a/README.md b/README.md index ccc55af..fce902d 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.31.0` +Current version: `0.31.1` ## Project Layout @@ -158,6 +158,7 @@ The app currently includes: - 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. +- 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. - 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. diff --git a/VERSION b/VERSION index 26bea73..f176c94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.31.0 +0.31.1 diff --git a/app_support.py b/app_support.py index c3303ef..75d3556 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.31.0" +VERSION = "0.31.1" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/queue_jobs.py b/queue_jobs.py index 9d55302..8448494 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -178,6 +178,9 @@ class WatchlistRefreshJobs: def _copy_job(self, job): return json.loads(json.dumps(job)) + def _stdout_log(self, message): + print(f"[watchlist-refresh] {message}", flush=True) + def _find_locked(self, job_id): for job in self.jobs: if job["id"] == job_id: @@ -192,10 +195,15 @@ class WatchlistRefreshJobs: active = bool(job and job["status"] in {"pending", "running"}) return {"job": self._copy_job(job) if job else None, "running": active} - def start(self): + def start(self, source="manual"): + source_name = str(source or "manual").strip().lower() or "manual" with self.lock: job = next((candidate for candidate in self.jobs if candidate["status"] in {"pending", "running"}), None) if job is not None: + if source_name == "scheduled": + self._stdout_log( + f"Scheduled refresh skipped because another refresh is already {job['status']} (job={job['id']})." + ) return { "message": "Watchlist refresh already queued." if job["status"] == "pending" else "Watchlist refresh already running.", "job": self._copy_job(job) if job else None, @@ -205,6 +213,7 @@ class WatchlistRefreshJobs: job = { "id": os.urandom(16).hex(), "status": "pending", + "source": source_name, "created_at": now, "updated_at": now, "started_at": None, @@ -220,6 +229,8 @@ class WatchlistRefreshJobs: self._save_job_locked(job) self.pending.append(job["id"]) self.wakeup.set() + if source_name == "scheduled": + self._stdout_log(f"Scheduled refresh queued (job={job['id']}).") return {"message": "Watchlist refresh started.", "job": self._copy_job(job), "created": True} def _next_pending(self): @@ -243,6 +254,7 @@ class WatchlistRefreshJobs: def _run_job(self, job): interrupted = False + source_name = str(job.get("source") or "manual").strip().lower() or "manual" try: show_ids = self.store.all_show_ids() started_at = now_iso() @@ -256,6 +268,8 @@ class WatchlistRefreshJobs: job["current_title"] = None job["message"] = f"Refreshing 0/{len(show_ids)} watchlist entries." self._save_job_locked(job) + if source_name == "scheduled": + self._stdout_log(f"Scheduled refresh started (job={job['id']}, total={len(show_ids)}).") def refresh_one(show_id): title = show_id @@ -320,6 +334,19 @@ class WatchlistRefreshJobs: else: job["message"] = f"Refreshed {job['completed']} watchlist entries." self._save_job_locked(job) + if source_name == "scheduled": + if interrupted: + self._stdout_log( + f"Scheduled refresh interrupted (job={job['id']}, completed={job['completed']}/{job['total']})." + ) + elif job["errors"]: + self._stdout_log( + f"Scheduled refresh finished with errors (job={job['id']}, completed={job['completed']}/{job['total']}, errors={job['errors']})." + ) + else: + self._stdout_log( + f"Scheduled refresh finished successfully (job={job['id']}, completed={job['completed']}/{job['total']})." + ) except Exception as exc: finished_at = now_iso() with self.lock: @@ -329,6 +356,8 @@ class WatchlistRefreshJobs: job["current_title"] = None job["message"] = f"Watchlist refresh failed: {exc}" self._save_job_locked(job) + if source_name == "scheduled": + self._stdout_log(f"Scheduled refresh failed (job={job['id']}): {exc}") finally: with self.lock: self.current_executor = None @@ -397,7 +426,7 @@ class WatchlistAutoRefreshScheduler: return remaining self.last_attempt_monotonic = now try: - result = self.refresh_start_fn() + result = self.refresh_start_fn(source="scheduled") debug_log("watchlist.auto_refresh.tick", result=result) except Exception as exc: debug_log("watchlist.auto_refresh.error", error=exc) diff --git a/test_app.py b/test_app.py index 40be840..308e02f 100644 --- a/test_app.py +++ b/test_app.py @@ -461,6 +461,26 @@ class WatchlistRefreshJobTests(unittest.TestCase): self.assertEqual(second["job"]["id"], first["job"]["id"]) self.assertEqual(second["job"]["status"], "pending") + def test_scheduled_refresh_logs_queue_and_completion(self): + class FakeStore: + def all_show_ids(self): + return ["show-a"] + + def refresh(self, _show_id, include_thumbnail=True): + return {"title": "Show A", "status": "updated"} + + service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) + with mock.patch("builtins.print") as print_mock: + started = service.start(source="scheduled") + job = service._next_pending() + service._run_job(job) + + self.assertTrue(started["created"]) + logged = "\n".join(call.args[0] for call in print_mock.call_args_list if call.args) + self.assertIn("Scheduled refresh queued", logged) + self.assertIn("Scheduled refresh started", logged) + self.assertIn("Scheduled refresh finished successfully", logged) + def test_refresh_history_marks_pending_jobs_interrupted_after_restart(self): class FakeStore: def all_show_ids(self): @@ -1090,7 +1110,7 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase): 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() + start.assert_called_once_with(source="scheduled") def test_tick_resets_timer_when_schedule_changes(self): config = {