diff --git a/api/video/enrichment.py b/api/video/enrichment.py index 9695784f..e3e50bde 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -58,7 +58,17 @@ def register_routes(bp): if "tvdb_api_key" in body: db.set_setting("tvdb_api_key", body.get("tvdb_api_key") or "") if "omdb_api_key" in body: - db.set_setting("omdb_api_key", body.get("omdb_api_key") or "") + new_key = body.get("omdb_api_key") or "" + changed = new_key != (db.get_setting("omdb_api_key") or "") + db.set_setting("omdb_api_key", new_key) + # A new/changed OMDb key → re-try every title that still has no rating + # (covers items wrongly marked 'synced' during a prior bad-key run). + if new_key and changed: + try: + for kind in ("movie", "show"): + db.enrichment_retry("omdb", kind, scope="failed") + except Exception: + logger.exception("video enrichment: omdb re-try reset failed") try: from core.video.enrichment.engine import rebuild_video_enrichment_engine rebuild_video_enrichment_engine() diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 1fb13471..df740aac 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -481,6 +481,12 @@ class TVDBClient: return {"id": tvdb_id, "metadata": {k: v for k, v in meta.items() if v}} +class OMDbAuthError(Exception): + """OMDb rejected the API key (HTTP 401 / 'Invalid API key!'). Distinct from a + transient error or a genuine 'no rating' so the worker can pause instead of + churning the whole library on a bad key.""" + + class OMDBClient: """Ratings provider — IMDb / Rotten Tomatoes / Metacritic by imdb_id. Not a matcher (we already have the id), so it's used as a ratings backfill, not a @@ -515,10 +521,16 @@ class OMDBClient: return None import requests r = requests.get(self.BASE, params={"apikey": self.api_key, "i": imdb_id}, timeout=12) + # A bad/expired key is a 401 (sometimes a 200 with "Invalid API key!") — a + # config problem that affects EVERY item, so flag it distinctly. + if r.status_code == 401: + raise OMDbAuthError("OMDb rejected the API key (HTTP 401)") r.raise_for_status() d = r.json() or {} if d.get("Response") != "True": - return None + if "invalid api key" in (d.get("Error") or "").lower(): + raise OMDbAuthError(d.get("Error") or "Invalid OMDb API key") + return None # genuine "no data for this title" out = {} ir = d.get("imdbRating") if ir and ir != "N/A": diff --git a/core/video/enrichment/worker.py b/core/video/enrichment/worker.py index 85f4912f..a56f40a2 100644 --- a/core/video/enrichment/worker.py +++ b/core/video/enrichment/worker.py @@ -36,6 +36,8 @@ class VideoEnrichmentWorker: self._stop = threading.Event() self.current_item = None 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) # ── lifecycle ───────────────────────────────────────────────────────────── def start(self): @@ -149,23 +151,45 @@ class VideoEnrichmentWorker: def _process_ratings_one(self) -> bool: """OMDb worker: fetch IMDb/RT/Metacritic for the next library item that has an imdb_id but no ratings yet.""" + from .clients import OMDbAuthError item = self.db.ratings_next() if not item: + self._rating_errors = 0 return False self.current_item = {"type": item["kind"], "name": item["title"]} try: r = self.client.ratings(item["imdb_id"]) - if r: - self.db.apply_ratings(item["kind"], item["id"], r) # marks synced - self.stats["matched"] += 1 - logger.info("Rated %s '%s' -> IMDb %s", item["kind"], item["title"], item["imdb_id"]) - else: - self.db.mark_ratings_synced(item["kind"], item["id"]) - self.stats["not_found"] += 1 - except Exception: - logger.exception("OMDb ratings fetch failed for '%s'", item["title"]) + 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. + if not self.paused: + logger.warning("OMDb rejected the API key — pausing ratings until it's fixed (%s)", e) + self.note = "OMDb API key rejected" + self.pause(persist=False) + return False + except Exception as e: + # Transient (network / rate-limit / 5xx) — don't burn the item to + # 'synced'; back off, and pause after a few in a row so we don't spin. self.stats["errors"] += 1 - self.db.mark_ratings_synced(item["kind"], item["id"]) # move on (no loop) + self._rating_errors = getattr(self, "_rating_errors", 0) + 1 + logger.warning("OMDb ratings fetch failed for '%s': %s", item["title"], e) + if self._rating_errors >= 3: + logger.warning("OMDb: pausing ratings after repeated errors") + self.note = "OMDb temporarily unavailable" + self.pause(persist=False) + self._rating_errors = 0 + return False + self._rating_errors = 0 + self.note = None + if r: + self.db.apply_ratings(item["kind"], item["id"], r) # marks synced + self.stats["matched"] += 1 + logger.info("Rated %s '%s' -> IMDb %s", item["kind"], item["title"], item["imdb_id"]) + else: + self.db.mark_ratings_synced(item["kind"], item["id"]) # genuine no-data + self.stats["not_found"] += 1 return True def _sync_episodes_once(self) -> bool: @@ -248,6 +272,7 @@ class VideoEnrichmentWorker: "paused": self.paused, "idle": idle, "current_item": self.current_item, + "note": self.note, "stats": {**self.stats, "pending": pending}, "progress": progress, "breakdown": breakdown, diff --git a/tests/test_video_api.py b/tests/test_video_api.py index a3221f59..9501e0b9 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -83,6 +83,18 @@ def test_tmdb_detail_endpoint(tmp_path, monkeypatch): assert client.get("/api/video/tmdb/bogus/1").status_code == 400 +def test_omdb_key_change_retries_unrated(tmp_path, monkeypatch): + client, videoapi = _make_client(tmp_path) + db = videoapi._video_db + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"}) + db.apply_ratings("movie", mid, {}) # burned: synced, but no rating + assert db.ratings_next() is None # not pending + monkeypatch.setattr("core.video.enrichment.engine.rebuild_video_enrichment_engine", lambda: None) + resp = client.post("/api/video/enrichment/config", json={"omdb_api_key": "NEWKEY"}) + assert resp.status_code == 200 + assert db.ratings_next() is not None # new key → re-queued for rating + + def test_show_detail_endpoint(tmp_path): client, videoapi = _make_client(tmp_path) try: diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index edcb160e..87a0b5eb 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -325,6 +325,48 @@ def test_omdb_worker_processes_ratings_queue(db): assert w.process_one() is False +def test_omdb_worker_pauses_on_bad_key_without_burning_items(db): + # A rejected key affects every title — the worker must pause (not churn the + # whole library) and must NOT mark items synced, so they retry once fixed. + 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("401") + + w = VideoEnrichmentWorker(db, "omdb", Omdb()) + assert w.process_one() is False # backed off, didn't process + assert w.paused is True and w.note # paused itself with a reason + 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"}) + + class Omdb: + enabled = True + def ratings(self, imdb_id): raise RuntimeError("network blip") + + w = VideoEnrichmentWorker(db, "omdb", Omdb()) + assert w.process_one() is False + assert db.ratings_next() is not None # not marked synced → retried later + assert w.stats["errors"] == 1 and w.paused is False # one blip doesn't pause + + +def test_omdb_ratings_raises_on_invalid_key(monkeypatch): + from core.video.enrichment.clients import OMDbAuthError + + class _R: + status_code = 401 + text = "" + def raise_for_status(self): raise AssertionError("should short-circuit on 401") + def json(self): return {} + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _R())) + with pytest.raises(OMDbAuthError): + OMDBClient("BADKEY").ratings("tt0111161") + + def test_omdb_breakdown_is_ratings_coverage(db): a = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"}) # pending db.upsert_movie("plex", {"server_id": "m2", "title": "B", "imdb_id": "tt2"})