video: OMDb daily-limit cools down + auto-resumes (vs hard pause)
'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.
This commit is contained in:
parent
38d48bae37
commit
c5ff2567a8
2 changed files with 48 additions and 7 deletions
|
|
@ -10,6 +10,7 @@ video.db helpers; no music code.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
|
@ -38,6 +39,7 @@ class VideoEnrichmentWorker:
|
||||||
self.stats = {"matched": 0, "not_found": 0, "errors": 0}
|
self.stats = {"matched": 0, "not_found": 0, "errors": 0}
|
||||||
self.note = None # a human reason when auto-paused (e.g. bad key)
|
self.note = None # a human reason when auto-paused (e.g. bad key)
|
||||||
self._rating_errors = 0 # consecutive ratings failures (transient backoff)
|
self._rating_errors = 0 # consecutive ratings failures (transient backoff)
|
||||||
|
self._cooldown_until = 0.0 # monotonic time to idle until (daily-limit backoff)
|
||||||
|
|
||||||
# ── lifecycle ─────────────────────────────────────────────────────────────
|
# ── lifecycle ─────────────────────────────────────────────────────────────
|
||||||
def start(self):
|
def start(self):
|
||||||
|
|
@ -89,6 +91,12 @@ class VideoEnrichmentWorker:
|
||||||
if self.paused or not self.enabled:
|
if self.paused or not self.enabled:
|
||||||
self._stop.wait(1.0)
|
self._stop.wait(1.0)
|
||||||
continue
|
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:
|
try:
|
||||||
did = self.process_one()
|
did = self.process_one()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -160,12 +168,24 @@ class VideoEnrichmentWorker:
|
||||||
try:
|
try:
|
||||||
r = self.client.ratings(item["imdb_id"])
|
r = self.client.ratings(item["imdb_id"])
|
||||||
except OMDbAuthError as e:
|
except OMDbAuthError as e:
|
||||||
# Bad/expired key affects EVERY item — pause instead of churning the
|
msg = str(e)
|
||||||
# whole library + flooding the log. Transient pause (not persisted) so
|
if "limit" in msg.lower():
|
||||||
# fixing the key (which rebuilds the engine) resumes automatically. The
|
# Free-tier daily quota (1,000/req) — resets at midnight UTC. Cool
|
||||||
# item is NOT marked synced, so it'll be tried again once the key works.
|
# 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:
|
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.note = "OMDb API key rejected"
|
||||||
self.pause(persist=False)
|
self.pause(persist=False)
|
||||||
return False
|
return False
|
||||||
|
|
@ -182,6 +202,7 @@ class VideoEnrichmentWorker:
|
||||||
self._rating_errors = 0
|
self._rating_errors = 0
|
||||||
return False
|
return False
|
||||||
self._rating_errors = 0
|
self._rating_errors = 0
|
||||||
|
self._cooldown_until = 0.0
|
||||||
self.note = None
|
self.note = None
|
||||||
if r:
|
if r:
|
||||||
self.db.apply_ratings(item["kind"], item["id"], r) # marks synced
|
self.db.apply_ratings(item["kind"], item["id"], r) # marks synced
|
||||||
|
|
@ -258,7 +279,8 @@ class VideoEnrichmentWorker:
|
||||||
pending += self.db.episode_sync_pending_count()
|
pending += self.db.episode_sync_pending_count()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
idle = running and pending == 0 and self.current_item is None
|
||||||
progress = {}
|
progress = {}
|
||||||
for kind, b in breakdown.items():
|
for kind, b in breakdown.items():
|
||||||
|
|
@ -269,10 +291,11 @@ class VideoEnrichmentWorker:
|
||||||
return {
|
return {
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"running": running,
|
"running": running,
|
||||||
"paused": self.paused,
|
"paused": self.paused or cooling, # cooldown reads as paused in the UI
|
||||||
"idle": idle,
|
"idle": idle,
|
||||||
"current_item": self.current_item,
|
"current_item": self.current_item,
|
||||||
"note": self.note,
|
"note": self.note,
|
||||||
|
"cooldown": cooling,
|
||||||
"stats": {**self.stats, "pending": pending},
|
"stats": {**self.stats, "pending": pending},
|
||||||
"progress": progress,
|
"progress": progress,
|
||||||
"breakdown": breakdown,
|
"breakdown": breakdown,
|
||||||
|
|
|
||||||
|
|
@ -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'
|
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):
|
def test_omdb_worker_keeps_item_on_transient_error(db):
|
||||||
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"})
|
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue