video detail: IMDb / Rotten Tomatoes / Metacritic ratings (OMDb)

Next-level: real critic/audience scores beyond the TMDB star. OMDb (free key,
keyed by the imdb_id we already capture) returns IMDb / RT / Metacritic.

- OMDBClient (ratings + test); built as a non-worker 'ratings_client' on the
  engine. _backfill_ratings runs in both lazy detail refreshes (overwrites, since
  ratings are dynamic). schema v6: imdb_rating / rt_rating / metacritic on
  movies + shows; show/movie payloads return them.
- Billboard renders branded rating badges (IMDb yellow, RT tomato/splat by
  fresh/rotten, Metacritic green/yellow/red by score). Lazy refresh also triggers
  when an imdb_id exists but ratings are missing.
- OMDb API-key frame in Settings (parity with TMDB/TVDB) + config GET/POST +
  /enrichment/omdb/test.

Seam tests: OMDb parse, engine ratings backfill, apply_ratings + payload, config
includes omdb.
This commit is contained in:
BoulderBadgeDad 2026-06-14 22:54:53 -07:00
parent aff4ecccc6
commit f06728b0a7
12 changed files with 259 additions and 18 deletions

View file

@ -45,6 +45,7 @@ def register_routes(bp):
return jsonify({ return jsonify({
"tmdb_api_key": db.get_setting("tmdb_api_key") or "", "tmdb_api_key": db.get_setting("tmdb_api_key") or "",
"tvdb_api_key": db.get_setting("tvdb_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"]) @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 "") db.set_setting("tmdb_api_key", body.get("tmdb_api_key") or "")
if "tvdb_api_key" in body: if "tvdb_api_key" in body:
db.set_setting("tvdb_api_key", body.get("tvdb_api_key") or "") 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: try:
from core.video.enrichment.engine import rebuild_video_enrichment_engine from core.video.enrichment.engine import rebuild_video_enrichment_engine
rebuild_video_enrichment_engine() rebuild_video_enrichment_engine()
@ -101,11 +104,15 @@ def register_routes(bp):
@bp.route("/enrichment/<service>/test", methods=["POST"]) @bp.route("/enrichment/<service>/test", methods=["POST"])
def video_enrichment_test(service): def video_enrichment_test(service):
w = engine().worker(service) # OMDb is a ratings provider, not a worker.
if not w: 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 return jsonify({"success": False, "error": "unknown service"}), 404
try: try:
ok, msg = w.client.test() ok, msg = client.test()
return jsonify({"success": bool(ok), "message": msg, "error": None if ok else msg}) return jsonify({"success": bool(ok), "message": msg, "error": None if ok else msg})
except Exception: except Exception:
logger.exception("video enrichment test failed for %s", service) logger.exception("video enrichment test failed for %s", service)

View file

@ -316,6 +316,66 @@ class TVDBClient:
return {"id": tvdb_id, "metadata": {k: v for k, v in meta.items() if v}} 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: def build_clients(db) -> dict:
"""Construct the source clients from the saved API keys (in video_settings).""" """Construct the source clients from the saved API keys (in video_settings)."""
return { return {

View file

@ -19,16 +19,43 @@ _DISPLAY = {"tmdb": "TMDB", "tvdb": "TVDB"}
class VideoEnrichmentEngine: class VideoEnrichmentEngine:
def __init__(self, db, clients: dict): def __init__(self, db, clients: dict, ratings_client=None):
self.db = db self.db = db
self.workers = { self.workers = {
service: VideoEnrichmentWorker(db, service, client, display_name=_DISPLAY.get(service)) service: VideoEnrichmentWorker(db, service, client, display_name=_DISPLAY.get(service))
for service, client in clients.items() 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). # Restore each worker's persisted pause state (survives restart).
for w in self.workers.values(): for w in self.workers.values():
w.restore_paused() 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): def start_all(self):
for w in self.workers.values(): for w in self.workers.values():
w.start() w.start()
@ -92,6 +119,7 @@ class VideoEnrichmentEngine:
w._cascade_episodes(show_id, result["id"], nums) # full list: owned + missing w._cascade_episodes(show_id, result["id"], nums) # full list: owned + missing
except Exception: except Exception:
logger.exception("refresh_show_art: episode cascade failed for show %s", show_id) logger.exception("refresh_show_art: episode cascade failed for show %s", show_id)
self._backfill_ratings("show", show_id)
return {"ok": True} return {"ok": True}
def refresh_movie_art(self, movie_id) -> dict: def refresh_movie_art(self, movie_id) -> dict:
@ -114,6 +142,7 @@ class VideoEnrichmentEngine:
return {"ok": False, "reason": "no_match"} return {"ok": False, "reason": "no_match"}
self.db.enrichment_apply("tmdb", "movie", movie_id, matched=True, self.db.enrichment_apply("tmdb", "movie", movie_id, matched=True,
external_id=result["id"], metadata=result.get("metadata")) external_id=result["id"], metadata=result.get("metadata"))
self._backfill_ratings("movie", movie_id)
return {"ok": True} return {"ok": True}
def item_extras(self, kind, item_id) -> dict: def item_extras(self, kind, item_id) -> dict:
@ -150,9 +179,10 @@ def get_video_enrichment_engine():
with _lock: with _lock:
if _engine is None: if _engine is None:
from database.video_database import VideoDatabase from database.video_database import VideoDatabase
from .clients import build_clients from .clients import build_clients, OMDBClient
db = VideoDatabase() 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() eng.start_all()
_engine = eng _engine = eng
return _engine return _engine

View file

@ -29,7 +29,7 @@ logger = get_logger("video_database")
# Bump when video_schema.sql changes in a way worth recording. Stored in # 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). # 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" _DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
@ -91,6 +91,10 @@ _COLUMN_MIGRATIONS = [
("movies", "logo_url", "TEXT"), ("movies", "logo_url", "TEXT"),
("shows", "logo_url", "TEXT"), ("shows", "logo_url", "TEXT"),
("shows", "episodes_synced", "INTEGER NOT NULL DEFAULT 0"), ("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: finally:
conn.close() 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: def mark_episodes_synced(self, show_id: int) -> None:
"""Flag that the show's FULL episode list has been pulled from metadata """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).""" (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"], "content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"],
"tagline": show["tagline"], "rating": show["rating"], "tagline": show["tagline"], "rating": show["rating"],
"first_air_date": show["first_air_date"], "last_air_date": show["last_air_date"], "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"], "genres": genres, "cast": credits["cast"], "crew": credits["crew"],
"tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"], "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"]), "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"], "release_date": m["release_date"], "runtime_minutes": m["runtime_minutes"],
"content_rating": m["content_rating"], "tagline": m["tagline"], "content_rating": m["content_rating"], "tagline": m["tagline"],
"rating": m["rating"], "rating_critic": m["rating_critic"], "genres": genres, "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"], "cast": credits["cast"], "crew": credits["crew"],
"tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"], "tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"],
"has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]), "has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]),

View file

@ -78,8 +78,11 @@ CREATE TABLE IF NOT EXISTS movies (
studio TEXT, studio TEXT,
content_rating TEXT, -- e.g. PG-13 content_rating TEXT, -- e.g. PG-13
tagline TEXT, tagline TEXT,
rating REAL, -- audience score (0-10) rating REAL, -- TMDB audience score (0-10)
rating_critic REAL, -- critic score (0-100) when offered 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, poster_url TEXT,
backdrop_url TEXT, backdrop_url TEXT,
logo_url TEXT, -- transparent title logo (clearlogo) logo_url TEXT, -- transparent title logo (clearlogo)
@ -119,7 +122,10 @@ CREATE TABLE IF NOT EXISTS shows (
runtime_minutes INTEGER, runtime_minutes INTEGER,
content_rating TEXT, content_rating TEXT,
tagline 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, first_air_date TEXT,
last_air_date TEXT, last_air_date TEXT,
poster_url TEXT, poster_url TEXT,

View file

@ -213,11 +213,12 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch):
client = app.test_client() client = app.test_client()
try: try:
assert client.get("/api/video/enrichment/config").get_json() == { assert client.get("/api/video/enrichment/config").get_json() == {
"tmdb_api_key": "", "tvdb_api_key": ""} "tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": ""}
client.post("/api/video/enrichment/config", json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz"}) 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() == { assert client.get("/api/video/enrichment/config").get_json() == {
"tmdb_api_key": "abc", "tvdb_api_key": "xyz"} "tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om"}
assert db.get_setting("tmdb_api_key") == "abc" assert db.get_setting("tmdb_api_key") == "abc" and db.get_setting("omdb_api_key") == "om"
finally: finally:
videoapi._video_db = None videoapi._video_db = None

View file

@ -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" 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): def test_episodes_synced_flag_drives_lazy_refresh(db):
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) 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 assert db.show_detail(sid)["episodes_synced"] is False # → lazy refresh will run

View file

@ -16,7 +16,7 @@ import pytest
from database.video_database import VideoDatabase from database.video_database import VideoDatabase
from core.video.enrichment.worker import VideoEnrichmentWorker from core.video.enrichment.worker import VideoEnrichmentWorker
from core.video.enrichment.engine import VideoEnrichmentEngine 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: class FakeClient:
@ -167,6 +167,39 @@ def test_show_match_info(db):
assert db.show_match_info(999999) is None 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): def test_refresh_movie_art_backfills_cast_and_genres(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 438631}) mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 438631})

View file

@ -834,6 +834,7 @@
<div class="vd-meta" data-vd-meta></div> <div class="vd-meta" data-vd-meta></div>
<!-- External-source links reuse the artist-hero-badge style. --> <!-- External-source links reuse the artist-hero-badge style. -->
<div class="artist-hero-badges vd-links" data-vd-links></div> <div class="artist-hero-badges vd-links" data-vd-links></div>
<div class="vd-ratings" data-vd-ratings hidden></div>
<p class="vd-overview" data-vd-overview></p> <p class="vd-overview" data-vd-overview></p>
<!-- Action buttons reuse the exact artist-detail button styles. --> <!-- Action buttons reuse the exact artist-detail button styles. -->
<div class="vd-actions artist-hero-actions" data-vd-actions></div> <div class="vd-actions artist-hero-actions" data-vd-actions></div>
@ -888,6 +889,7 @@
<div class="vd-tagline" data-vd-tagline hidden></div> <div class="vd-tagline" data-vd-tagline hidden></div>
<div class="vd-meta" data-vd-meta></div> <div class="vd-meta" data-vd-meta></div>
<div class="artist-hero-badges vd-links" data-vd-links></div> <div class="artist-hero-badges vd-links" data-vd-links></div>
<div class="vd-ratings" data-vd-ratings hidden></div>
<p class="vd-overview" data-vd-overview></p> <p class="vd-overview" data-vd-overview></p>
<div class="vd-actions artist-hero-actions" data-vd-actions></div> <div class="vd-actions artist-hero-actions" data-vd-actions></div>
<div class="vd-genres" data-vd-genres></div> <div class="vd-genres" data-vd-genres></div>
@ -4991,6 +4993,24 @@
<button class="test-button" type="button" data-video-test-service="tvdb">Test TVDB</button> <button class="test-button" type="button" data-video-test-service="tvdb">Test TVDB</button>
</div> </div>
</div> </div>
<div class="api-service-frame stg-service" data-video-service="omdb">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #f5c518;"></span>
<h4 class="service-title">OMDb (Ratings)</h4>
<span class="stg-service-chevron"></span>
</div>
<div class="stg-service-body">
<div class="form-group">
<label>API Key:</label>
<input type="password" id="omdb-api-key" placeholder="OMDb API Key">
</div>
<div class="callback-info">
<div class="callback-help">Get a free key from <a href="https://www.omdbapi.com/apikey.aspx" target="_blank" style="color: #f5c518;">OMDb API</a></div>
<div class="callback-help">IMDb / Rotten Tomatoes / Metacritic ratings.</div>
</div>
<button class="test-button" type="button" data-video-test-service="omdb">Test OMDb</button>
</div>
</div>
</div> </div>
<!-- Server Connections --> <!-- Server Connections -->

View file

@ -170,9 +170,32 @@
return '<span class="vd-genre">' + esc(gn) + '</span>'; return '<span class="vd-genre">' + esc(gn) + '</span>';
}).join(''); }).join('');
} }
renderRatings(d);
renderCast(d); renderCast(d);
} }
function renderRatings(d) {
var host = q('[data-vd-ratings]');
if (!host) return;
var items = [];
if (d.imdb_rating) {
items.push('<span class="vd-rt vd-rt--imdb"><span class="vd-rt-tag">IMDb</span>' +
(Math.round(d.imdb_rating * 10) / 10) + '</span>');
}
if (d.rt_rating != null) {
var fresh = d.rt_rating >= 60;
items.push('<span class="vd-rt vd-rt--rt"><span class="vd-rt-ic">' +
(fresh ? '🍅' : '🤢') + '</span>' + d.rt_rating + '%</span>');
}
if (d.metacritic != null) {
var cls = d.metacritic >= 61 ? 'good' : d.metacritic >= 40 ? 'mid' : 'bad';
items.push('<span class="vd-rt vd-rt--mc vd-rt--mc-' + cls + '">' +
'<span class="vd-rt-tag">MC</span>' + d.metacritic + '</span>');
}
host.innerHTML = items.join('');
host.hidden = !items.length;
}
function renderCast(d) { function renderCast(d) {
var section = q('[data-vd-cast-section]'); var section = q('[data-vd-cast-section]');
if (!section) return; if (!section) return;
@ -468,7 +491,7 @@
function maybeRefreshMovie(id) { function maybeRefreshMovie(id) {
if (artAttemptedFor === id || !data || data.id !== id) return; if (artAttemptedFor === id || !data || data.id !== id) return;
var needs = !(data.cast && data.cast.length) || !(data.genres && data.genres.length) 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; if (!needs) return;
artAttemptedFor = id; artAttemptedFor = id;
fetch(DETAIL_URL + 'movie/' + id + '/refresh-art', fetch(DETAIL_URL + 'movie/' + id + '/refresh-art',
@ -520,7 +543,8 @@
// Trigger if the full episode list hasn't been pulled yet (so missing // Trigger if the full episode list hasn't been pulled yet (so missing
// episodes show up), or any art is still missing. // episodes show up), or any art is still missing.
var needs = !data.episodes_synced || !data.logo 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; if (!needs) return;
artAttemptedFor = id; artAttemptedFor = id;
fetch(DETAIL_URL + 'show/' + id + '/refresh-art', fetch(DETAIL_URL + 'show/' + id + '/refresh-art',

View file

@ -71,8 +71,10 @@
if (!d) return; if (!d) return;
var t = document.getElementById('tmdb-api-key'); var t = document.getElementById('tmdb-api-key');
var v = document.getElementById('tvdb-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 (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 (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 */ }); .catch(function () { /* ignore */ });
} }
@ -80,10 +82,14 @@
function saveKeys() { function saveKeys() {
var t = document.getElementById('tmdb-api-key'); var t = document.getElementById('tmdb-api-key');
var v = document.getElementById('tvdb-api-key'); var v = document.getElementById('tvdb-api-key');
var o = document.getElementById('omdb-api-key');
return fetch(CONFIG_URL, { return fetch(CONFIG_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, 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 */ }); }).catch(function () { /* ignore */ });
} }
@ -119,7 +125,7 @@
selects[i].addEventListener('change', save); selects[i].addEventListener('change', save);
} }
// Enrichment keys save on blur/change (turns the workers on). // 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); var el = document.getElementById(id);
if (el) el.addEventListener('change', saveKeys); if (el) el.addEventListener('change', saveKeys);
}); });

View file

@ -779,3 +779,19 @@ body[data-side="video"] .dashboard-header-sweep {
font-size: 22px; line-height: 1; cursor: pointer; transition: background 0.2s ease; font-size: 22px; line-height: 1; cursor: pointer; transition: background 0.2s ease;
} }
.vd-trailer-close:hover { background: rgba(255, 255, 255, 0.25); } .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; }