From c5ff2567a87c82207b18a2a6f369f4b87676617a Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 15 Jun 2026 09:34:47 -0700 Subject: [PATCH] video: OMDb daily-limit cools down + auto-resumes (vs hard pause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Request limit reached!' is the free-tier daily quota (1,000/day), not a bad key — so the worker now idles ~30 min and auto-resumes instead of pausing for good. A library bigger than the daily cap just spreads its ratings across days on its own. A genuinely invalid/unactivated key still hard-pauses until fixed. Cooldown reads as paused in the UI (+ a 'cooldown' flag and note). Item is never burned to synced, so nothing is lost. --- core/video/enrichment/worker.py | 37 ++++++++++++++++++++++++++------- tests/test_video_enrichment.py | 18 ++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/core/video/enrichment/worker.py b/core/video/enrichment/worker.py index a56f40a2..819eb741 100644 --- a/core/video/enrichment/worker.py +++ b/core/video/enrichment/worker.py @@ -10,6 +10,7 @@ video.db helpers; no music code. from __future__ import annotations import threading +import time from utils.logging_config import get_logger @@ -38,6 +39,7 @@ class VideoEnrichmentWorker: self.stats = {"matched": 0, "not_found": 0, "errors": 0} self.note = None # a human reason when auto-paused (e.g. bad key) self._rating_errors = 0 # consecutive ratings failures (transient backoff) + self._cooldown_until = 0.0 # monotonic time to idle until (daily-limit backoff) # ── lifecycle ───────────────────────────────────────────────────────────── def start(self): @@ -89,6 +91,12 @@ class VideoEnrichmentWorker: if self.paused or not self.enabled: self._stop.wait(1.0) continue + # Daily-limit cooldown: idle until the quota resets, then auto-resume + # (re-checks periodically so we pick back up shortly after midnight UTC). + if self._cooldown_until > time.monotonic(): + self.current_item = None + self._stop.wait(15.0) + continue try: did = self.process_one() except Exception: @@ -160,12 +168,24 @@ class VideoEnrichmentWorker: try: r = self.client.ratings(item["imdb_id"]) except OMDbAuthError as e: - # Bad/expired key affects EVERY item — pause instead of churning the - # whole library + flooding the log. Transient pause (not persisted) so - # fixing the key (which rebuilds the engine) resumes automatically. The - # item is NOT marked synced, so it'll be tried again once the key works. + msg = str(e) + if "limit" in msg.lower(): + # Free-tier daily quota (1,000/req) — resets at midnight UTC. Cool + # down and AUTO-RESUME (so a big library just spreads across days) + # rather than pausing for good. Item not marked synced → retried. + if self._cooldown_until <= time.monotonic(): + logger.warning("OMDb daily request limit reached — idling ratings ~30 min; " + "auto-resumes after the daily reset") + self._cooldown_until = time.monotonic() + 1800 + self.note = "OMDb daily limit reached — resumes after reset" + return False + # Bad/expired/unactivated key affects EVERY item — pause instead of + # churning the whole library + flooding the log. Transient pause (not + # persisted) so fixing the key (which rebuilds the engine) resumes + # automatically. The item is NOT marked synced, so it retries once + # the key works. if not self.paused: - logger.warning("OMDb rejected the API key — pausing ratings until it's fixed (%s)", e) + logger.warning("OMDb rejected the API key — pausing ratings until it's fixed (%s)", msg) self.note = "OMDb API key rejected" self.pause(persist=False) return False @@ -182,6 +202,7 @@ class VideoEnrichmentWorker: self._rating_errors = 0 return False self._rating_errors = 0 + self._cooldown_until = 0.0 self.note = None if r: self.db.apply_ratings(item["kind"], item["id"], r) # marks synced @@ -258,7 +279,8 @@ class VideoEnrichmentWorker: pending += self.db.episode_sync_pending_count() except Exception: pass - running = self.running and not self.paused and self.enabled + cooling = self._cooldown_until > time.monotonic() + running = self.running and not self.paused and self.enabled and not cooling idle = running and pending == 0 and self.current_item is None progress = {} for kind, b in breakdown.items(): @@ -269,10 +291,11 @@ class VideoEnrichmentWorker: return { "enabled": self.enabled, "running": running, - "paused": self.paused, + "paused": self.paused or cooling, # cooldown reads as paused in the UI "idle": idle, "current_item": self.current_item, "note": self.note, + "cooldown": cooling, "stats": {**self.stats, "pending": pending}, "progress": progress, "breakdown": breakdown, diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index ca048967..80b4c52a 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -341,6 +341,24 @@ def test_omdb_worker_pauses_on_bad_key_without_burning_items(db): assert db.ratings_next() is not None # item NOT burned to 'synced' +def test_omdb_worker_cools_down_on_daily_limit_not_hard_pause(db): + # 'Request limit reached!' is the free-tier daily quota — cool down + auto + # resume (don't hard-pause), so a big library spreads across days. + from core.video.enrichment.clients import OMDbAuthError + db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"}) + + class Omdb: + enabled = True + def ratings(self, imdb_id): raise OMDbAuthError("Request limit reached!") + + w = VideoEnrichmentWorker(db, "omdb", Omdb()) + assert w.process_one() is False + assert w.paused is False # NOT a hard pause (auto-resumes) + assert w._cooldown_until > 0 # cooling down instead + assert w.get_stats()["cooldown"] is True and w.get_stats()["paused"] is True + assert db.ratings_next() is not None # item not burned to synced + + def test_omdb_worker_keeps_item_on_transient_error(db): db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"})