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:
parent
aff4ecccc6
commit
f06728b0a7
12 changed files with 259 additions and 18 deletions
|
|
@ -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/<service>/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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -834,6 +834,7 @@
|
|||
<div class="vd-meta" data-vd-meta></div>
|
||||
<!-- External-source links reuse the artist-hero-badge style. -->
|
||||
<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>
|
||||
<!-- Action buttons reuse the exact artist-detail button styles. -->
|
||||
<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-meta" data-vd-meta></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>
|
||||
<div class="vd-actions artist-hero-actions" data-vd-actions></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>
|
||||
</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>
|
||||
|
||||
<!-- Server Connections -->
|
||||
|
|
|
|||
|
|
@ -170,9 +170,32 @@
|
|||
return '<span class="vd-genre">' + esc(gn) + '</span>';
|
||||
}).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('<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) {
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue