diff --git a/api/video/enrichment.py b/api/video/enrichment.py index b80fb586..8e170670 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -45,6 +45,7 @@ def register_routes(bp): return jsonify({ "tmdb_api_key": db.get_setting("tmdb_api_key") or "", "tvdb_api_key": db.get_setting("tvdb_api_key") or "", + "omdb_api_key": db.get_setting("omdb_api_key") or "", }) @bp.route("/enrichment/config", methods=["POST"]) @@ -56,6 +57,8 @@ def register_routes(bp): db.set_setting("tmdb_api_key", body.get("tmdb_api_key") or "") 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 "") try: from core.video.enrichment.engine import rebuild_video_enrichment_engine rebuild_video_enrichment_engine() @@ -101,11 +104,15 @@ def register_routes(bp): @bp.route("/enrichment//test", methods=["POST"]) def video_enrichment_test(service): - w = engine().worker(service) - if not w: + # OMDb is a ratings provider, not a worker. + client = engine().ratings_client if service == "omdb" else None + if client is None: + w = engine().worker(service) + client = w.client if w else None + if client is None: return jsonify({"success": False, "error": "unknown service"}), 404 try: - ok, msg = w.client.test() + ok, msg = client.test() return jsonify({"success": bool(ok), "message": msg, "error": None if ok else msg}) except Exception: logger.exception("video enrichment test failed for %s", service) diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index ecdc5840..ff070fdb 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -316,6 +316,66 @@ class TVDBClient: return {"id": tvdb_id, "metadata": {k: v for k, v in meta.items() if v}} +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 + worker.""" + BASE = "https://www.omdbapi.com/" + + def __init__(self, api_key): + self.api_key = api_key or None + + @property + def enabled(self): + return bool(self.api_key) + + def test(self): + if not self.api_key: + return False, "No OMDb API key set" + import requests + try: + r = requests.get(self.BASE, params={"apikey": self.api_key, "i": "tt0111161"}, timeout=12) + d = r.json() if r.status_code == 200 else {} + if d.get("Response") == "True": + return True, "OMDb connection OK" + if "invalid api key" in (d.get("Error") or "").lower(): + return False, "Invalid OMDb API key" + return False, "OMDb returned HTTP " + str(r.status_code) + except Exception: + logger.exception("OMDb test failed") + return False, "Could not reach OMDb" + + def ratings(self, imdb_id): + if not self.api_key or not imdb_id: + return None + import requests + r = requests.get(self.BASE, params={"apikey": self.api_key, "i": imdb_id}, timeout=12) + r.raise_for_status() + d = r.json() or {} + if d.get("Response") != "True": + return None + out = {} + ir = d.get("imdbRating") + if ir and ir != "N/A": + try: + out["imdb_rating"] = float(ir) + except (TypeError, ValueError): + pass + for rt in (d.get("Ratings") or []): + if rt.get("Source") == "Rotten Tomatoes": + try: + out["rt_rating"] = int((rt.get("Value") or "").rstrip("%")) + except (TypeError, ValueError): + pass + ms = d.get("Metascore") + if ms and ms != "N/A": + try: + out["metacritic"] = int(ms) + except (TypeError, ValueError): + pass + return out + + def build_clients(db) -> dict: """Construct the source clients from the saved API keys (in video_settings).""" return { diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 41d55ead..b84c61c9 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -19,16 +19,43 @@ _DISPLAY = {"tmdb": "TMDB", "tvdb": "TVDB"} class VideoEnrichmentEngine: - def __init__(self, db, clients: dict): + def __init__(self, db, clients: dict, ratings_client=None): self.db = db self.workers = { service: VideoEnrichmentWorker(db, service, client, display_name=_DISPLAY.get(service)) for service, client in clients.items() } + # OMDb ratings (IMDb/RT/Metacritic) — not a matcher, so not a worker; + # backfilled on the lazy detail refresh. + self.ratings_client = ratings_client # Restore each worker's persisted pause state (survives restart). for w in self.workers.values(): w.restore_paused() + def _backfill_ratings(self, kind, item_id): + rc = self.ratings_client + if not rc or not getattr(rc, "enabled", False): + return + info = (self.db.movie_match_info(item_id) if kind == "movie" + else self.db.show_match_info(item_id)) + # IMDb id lives on the row — fetch it directly. + row = None + try: + with self.db.connect() as c: + tbl = "movies" if kind == "movie" else "shows" + row = c.execute(f"SELECT imdb_id FROM {tbl} WHERE id=?", (item_id,)).fetchone() + except Exception: + return + imdb_id = row["imdb_id"] if row else None + if not imdb_id: + return + try: + ratings = rc.ratings(imdb_id) + if ratings: + self.db.apply_ratings(kind, item_id, ratings) + except Exception: + logger.exception("ratings backfill failed for %s %s", kind, item_id) + def start_all(self): for w in self.workers.values(): w.start() @@ -92,6 +119,7 @@ class VideoEnrichmentEngine: w._cascade_episodes(show_id, result["id"], nums) # full list: owned + missing except Exception: logger.exception("refresh_show_art: episode cascade failed for show %s", show_id) + self._backfill_ratings("show", show_id) return {"ok": True} def refresh_movie_art(self, movie_id) -> dict: @@ -114,6 +142,7 @@ class VideoEnrichmentEngine: return {"ok": False, "reason": "no_match"} self.db.enrichment_apply("tmdb", "movie", movie_id, matched=True, external_id=result["id"], metadata=result.get("metadata")) + self._backfill_ratings("movie", movie_id) return {"ok": True} def item_extras(self, kind, item_id) -> dict: @@ -150,9 +179,10 @@ def get_video_enrichment_engine(): with _lock: if _engine is None: from database.video_database import VideoDatabase - from .clients import build_clients + from .clients import build_clients, OMDBClient db = VideoDatabase() - eng = VideoEnrichmentEngine(db, build_clients(db)) + eng = VideoEnrichmentEngine(db, build_clients(db), + ratings_client=OMDBClient(db.get_setting("omdb_api_key"))) eng.start_all() _engine = eng return _engine diff --git a/database/video_database.py b/database/video_database.py index 629f1d78..addd613c 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -29,7 +29,7 @@ logger = get_logger("video_database") # Bump when video_schema.sql changes in a way worth recording. Stored in # PRAGMA user_version as a backstop indicator (nothing gates on it yet). -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 _DEFAULT_DB_PATH = "database/video_library.db" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" @@ -91,6 +91,10 @@ _COLUMN_MIGRATIONS = [ ("movies", "logo_url", "TEXT"), ("shows", "logo_url", "TEXT"), ("shows", "episodes_synced", "INTEGER NOT NULL DEFAULT 0"), + ("movies", "imdb_rating", "REAL"), ("movies", "rt_rating", "INTEGER"), + ("movies", "metacritic", "INTEGER"), + ("shows", "imdb_rating", "REAL"), ("shows", "rt_rating", "INTEGER"), + ("shows", "metacritic", "INTEGER"), ] @@ -328,6 +332,26 @@ class VideoDatabase: finally: conn.close() + def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None: + """Store IMDb / RT / Metacritic scores (from OMDb). Ratings are dynamic, so + these overwrite (unlike the gap-only metadata backfill).""" + table = {"movie": "movies", "show": "shows"}.get(kind) + cols = {"imdb_rating", "rt_rating", "metacritic"} + sets, params = [], [] + for c, v in (ratings or {}).items(): + if c in cols and v is not None: + sets.append(f"{c}=?") + params.append(v) + if not table or not sets: + return + params.append(item_id) + conn = self._get_connection() + try: + conn.execute(f"UPDATE {table} SET {', '.join(sets)} WHERE id=?", params) + conn.commit() + finally: + conn.close() + def mark_episodes_synced(self, show_id: int) -> None: """Flag that the show's FULL episode list has been pulled from metadata (so the lazy on-view refresh doesn't re-cascade every visit).""" @@ -986,6 +1010,8 @@ class VideoDatabase: "content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"], "tagline": show["tagline"], "rating": show["rating"], "first_air_date": show["first_air_date"], "last_air_date": show["last_air_date"], + "imdb_rating": show["imdb_rating"], "rt_rating": show["rt_rating"], + "metacritic": show["metacritic"], "genres": genres, "cast": credits["cast"], "crew": credits["crew"], "tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"], "has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]), @@ -1034,6 +1060,7 @@ class VideoDatabase: "release_date": m["release_date"], "runtime_minutes": m["runtime_minutes"], "content_rating": m["content_rating"], "tagline": m["tagline"], "rating": m["rating"], "rating_critic": m["rating_critic"], "genres": genres, + "imdb_rating": m["imdb_rating"], "rt_rating": m["rt_rating"], "metacritic": m["metacritic"], "cast": credits["cast"], "crew": credits["crew"], "tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"], "has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]), diff --git a/database/video_schema.sql b/database/video_schema.sql index ca6c3770..62ef229d 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -78,8 +78,11 @@ CREATE TABLE IF NOT EXISTS movies ( studio TEXT, content_rating TEXT, -- e.g. PG-13 tagline TEXT, - rating REAL, -- audience score (0-10) + rating REAL, -- TMDB audience score (0-10) rating_critic REAL, -- critic score (0-100) when offered + imdb_rating REAL, -- IMDb (0-10, via OMDb) + rt_rating INTEGER, -- Rotten Tomatoes (0-100) + metacritic INTEGER, -- Metacritic (0-100) poster_url TEXT, backdrop_url TEXT, logo_url TEXT, -- transparent title logo (clearlogo) @@ -119,7 +122,10 @@ CREATE TABLE IF NOT EXISTS shows ( runtime_minutes INTEGER, content_rating TEXT, tagline TEXT, - rating REAL, -- audience score (0-10) + rating REAL, -- TMDB audience score (0-10) + imdb_rating REAL, -- IMDb (0-10, via OMDb) + rt_rating INTEGER, -- Rotten Tomatoes (0-100) + metacritic INTEGER, -- Metacritic (0-100) first_air_date TEXT, last_air_date TEXT, poster_url TEXT, diff --git a/tests/test_video_api.py b/tests/test_video_api.py index bac4d976..64a39af0 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -213,11 +213,12 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch): client = app.test_client() try: assert client.get("/api/video/enrichment/config").get_json() == { - "tmdb_api_key": "", "tvdb_api_key": ""} - client.post("/api/video/enrichment/config", json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz"}) + "tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": ""} + client.post("/api/video/enrichment/config", + json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om"}) assert client.get("/api/video/enrichment/config").get_json() == { - "tmdb_api_key": "abc", "tvdb_api_key": "xyz"} - assert db.get_setting("tmdb_api_key") == "abc" + "tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om"} + assert db.get_setting("tmdb_api_key") == "abc" and db.get_setting("omdb_api_key") == "om" finally: videoapi._video_db = None diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 3d417bbd..c515a314 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -626,6 +626,17 @@ def test_backfill_inserts_missing_episodes_as_unowned(db): assert by[2]["title"] == "Two" and by[2]["air_date"] == "2020-01-08" +def test_apply_ratings_and_payload(db): + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune"}) + db.apply_ratings("movie", mid, {"imdb_rating": 8.4, "rt_rating": 95, "metacritic": 74}) + d = db.movie_detail(mid) + assert (d["imdb_rating"], d["rt_rating"], d["metacritic"]) == (8.4, 95, 74) + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) + db.apply_ratings("show", sid, {"imdb_rating": 9.1}) # partial is fine + sd = db.show_detail(sid) + assert sd["imdb_rating"] == 9.1 and sd["rt_rating"] is None + + def test_episodes_synced_flag_drives_lazy_refresh(db): sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) assert db.show_detail(sid)["episodes_synced"] is False # → lazy refresh will run diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 983516a5..c79b74fc 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -16,7 +16,7 @@ import pytest from database.video_database import VideoDatabase from core.video.enrichment.worker import VideoEnrichmentWorker from core.video.enrichment.engine import VideoEnrichmentEngine -from core.video.enrichment.clients import TMDBClient, TVDBClient +from core.video.enrichment.clients import TMDBClient, TVDBClient, OMDBClient class FakeClient: @@ -167,6 +167,39 @@ def test_show_match_info(db): assert db.show_match_info(999999) is None +def test_omdb_ratings_parse(monkeypatch): + class _Resp: + status_code = 200 + def raise_for_status(self): pass + def json(self): + return {"Response": "True", "imdbRating": "8.4", "Metascore": "74", + "Ratings": [{"Source": "Rotten Tomatoes", "Value": "95%"}, + {"Source": "Internet Movie Database", "Value": "8.4/10"}]} + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp())) + r = OMDBClient("KEY").ratings("tt1375666") + assert r == {"imdb_rating": 8.4, "rt_rating": 95, "metacritic": 74} + + +def test_refresh_show_art_backfills_omdb_ratings(db): + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 1396, + "imdb_id": "tt0903747", "seasons": []}) + + class Tmdb: + enabled = True + def match(self, *a, **k): return {"id": 1396, "metadata": {}} + def season_episodes(self, *a, **k): return None + class Omdb: + enabled = True + def ratings(self, imdb_id): + assert imdb_id == "tt0903747" + return {"imdb_rating": 9.5, "rt_rating": 96, "metacritic": 90} + + eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()}, ratings_client=Omdb()) + assert eng.refresh_show_art(sid)["ok"] is True + d = db.show_detail(sid) + assert (d["imdb_rating"], d["rt_rating"], d["metacritic"]) == (9.5, 96, 90) + + def test_refresh_movie_art_backfills_cast_and_genres(db): mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 438631}) diff --git a/webui/index.html b/webui/index.html index 34f35d99..2a0cbba5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -834,6 +834,7 @@
+

@@ -888,6 +889,7 @@
+

@@ -4991,6 +4993,24 @@ +
+
+ +

OMDb (Ratings)

+ +
+
+
+ + +
+
+
Get a free key from OMDb API
+
IMDb / Rotten Tomatoes / Metacritic ratings.
+
+ +
+
diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 23b1875b..dd21a364 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -170,9 +170,32 @@ return '' + esc(gn) + ''; }).join(''); } + renderRatings(d); renderCast(d); } + function renderRatings(d) { + var host = q('[data-vd-ratings]'); + if (!host) return; + var items = []; + if (d.imdb_rating) { + items.push('IMDb' + + (Math.round(d.imdb_rating * 10) / 10) + ''); + } + if (d.rt_rating != null) { + var fresh = d.rt_rating >= 60; + items.push('' + + (fresh ? '🍅' : '🤢') + '' + d.rt_rating + '%'); + } + if (d.metacritic != null) { + var cls = d.metacritic >= 61 ? 'good' : d.metacritic >= 40 ? 'mid' : 'bad'; + items.push('' + + 'MC' + d.metacritic + ''); + } + host.innerHTML = items.join(''); + host.hidden = !items.length; + } + function renderCast(d) { var section = q('[data-vd-cast-section]'); if (!section) return; @@ -468,7 +491,7 @@ function maybeRefreshMovie(id) { if (artAttemptedFor === id || !data || data.id !== id) return; var needs = !(data.cast && data.cast.length) || !(data.genres && data.genres.length) - || !data.has_backdrop || !data.logo; + || !data.has_backdrop || !data.logo || (data.imdb_id && !data.imdb_rating); if (!needs) return; artAttemptedFor = id; fetch(DETAIL_URL + 'movie/' + id + '/refresh-art', @@ -520,7 +543,8 @@ // Trigger if the full episode list hasn't been pulled yet (so missing // episodes show up), or any art is still missing. var needs = !data.episodes_synced || !data.logo - || (data.seasons || []).some(function (s) { return !s.has_poster; }); + || (data.seasons || []).some(function (s) { return !s.has_poster; }) + || (data.imdb_id && !data.imdb_rating); if (!needs) return; artAttemptedFor = id; fetch(DETAIL_URL + 'show/' + id + '/refresh-art', diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 6d7309b0..5ecc6c45 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -71,8 +71,10 @@ if (!d) return; var t = document.getElementById('tmdb-api-key'); var v = document.getElementById('tvdb-api-key'); + var o = document.getElementById('omdb-api-key'); if (t && d.tmdb_api_key != null) t.value = d.tmdb_api_key; if (v && d.tvdb_api_key != null) v.value = d.tvdb_api_key; + if (o && d.omdb_api_key != null) o.value = d.omdb_api_key; }) .catch(function () { /* ignore */ }); } @@ -80,10 +82,14 @@ function saveKeys() { var t = document.getElementById('tmdb-api-key'); var v = document.getElementById('tvdb-api-key'); + var o = document.getElementById('omdb-api-key'); return fetch(CONFIG_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, - body: JSON.stringify({ tmdb_api_key: t ? t.value : '', tvdb_api_key: v ? v.value : '' }) + body: JSON.stringify({ + tmdb_api_key: t ? t.value : '', tvdb_api_key: v ? v.value : '', + omdb_api_key: o ? o.value : '', + }) }).catch(function () { /* ignore */ }); } @@ -119,7 +125,7 @@ selects[i].addEventListener('change', save); } // Enrichment keys save on blur/change (turns the workers on). - ['tmdb-api-key', 'tvdb-api-key'].forEach(function (id) { + ['tmdb-api-key', 'tvdb-api-key', 'omdb-api-key'].forEach(function (id) { var el = document.getElementById(id); if (el) el.addEventListener('change', saveKeys); }); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 81f6d85b..0f3eebce 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -779,3 +779,19 @@ body[data-side="video"] .dashboard-header-sweep { font-size: 22px; line-height: 1; cursor: pointer; transition: background 0.2s ease; } .vd-trailer-close:hover { background: rgba(255, 255, 255, 0.25); } + +/* ── Rating badges (IMDb / Rotten Tomatoes / Metacritic, via OMDb) ────────── */ +.vd-ratings { display: flex; flex-wrap: wrap; gap: 10px; margin: -4px 0 16px; } +.vd-rt { + display: inline-flex; align-items: center; gap: 7px; padding: 5px 12px; border-radius: 8px; + font-size: 14px; font-weight: 800; color: #fff; background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.12); backdrop-filter: blur(6px); +} +.vd-rt-tag { font-size: 11px; font-weight: 900; padding: 1px 5px; border-radius: 4px; letter-spacing: 0.02em; } +.vd-rt-ic { font-size: 15px; line-height: 1; } +.vd-rt--imdb .vd-rt-tag { background: #f5c518; color: #000; } +.vd-rt--rt { } +.vd-rt--mc .vd-rt-tag { background: #fff; color: #000; } +.vd-rt--mc-good .vd-rt-tag { background: #6c3; color: #000; } +.vd-rt--mc-mid .vd-rt-tag { background: #fc3; color: #000; } +.vd-rt--mc-bad .vd-rt-tag { background: #f00; color: #fff; }