downloads drawer: rich TMDB detail + fix cast photos + stop OMDb quota burn

drawer upgrades (the TMDB call we already make returns all this — just render it):
- FIX cast photos — used c.profile_url; the field is c.photo. now shows headshots.
- title LOGO header (falls back to text) + meta line (year · rating · runtime · network).
- tagline, director/creator, ▶ trailer link, and a 'Watch on' provider-logo row.
- meta endpoint widened to return logo/cast-photo/rating/runtime/crew/trailer/providers.

OMDb spam fix (the 'Request limit reached!' tracebacks):
- the new bulk schedule-refresh called refresh_show_art per show, which did an OMDb
  ratings backfill each → blew the daily quota. refresh_show_art gains with_ratings;
  the automation passes False (it only needs episode schedules).
- _backfill_ratings now latches _omdb_blocked on OMDbAuthError → one quiet warning +
  stops calling OMDb for the rest of the process, instead of a traceback per show.
tested (cast/logo/trailer contract, no-ratings plumbing, the latch-off behavior).
This commit is contained in:
BoulderBadgeDad 2026-06-26 12:49:11 -07:00
parent a30a70fafd
commit c35ec43466
7 changed files with 172 additions and 22 deletions

View file

@ -172,17 +172,33 @@ def register_routes(bp):
@bp.route("/downloads/meta/<kind>/<int:tmdb_id>", methods=["GET"])
def video_download_meta(kind, tmdb_id):
"""Lazy TMDB detail (overview + cast + backdrop) for a download's expand drawer,
keyed by the grabbed title's TMDB id. Best-effort — the drawer still shows the
download facts without it."""
"""Lazy TMDB detail for a download's expand drawer (logo, cast w/ photos, trailer,
where-to-watch, rating/runtime/genres), keyed by the grabbed title's TMDB id.
Best-effort the drawer still shows the download facts without it."""
if kind not in ("movie", "show"):
return jsonify({}), 400
try:
from core.video.enrichment.engine import get_video_enrichment_engine
data = get_video_enrichment_engine().tmdb_full_detail(kind, tmdb_id) or {}
return jsonify({k: data.get(k) for k in
("title", "overview", "tagline", "backdrop_url", "poster_url",
"genres", "rating", "cast", "year", "runtime", "status")})
d = get_video_enrichment_engine().tmdb_full_detail(kind, tmdb_id) or {}
if not d:
return jsonify({})
extras = d.get("_extras") or {}
tr = extras.get("trailer") or {}
director = next((c.get("name") for c in (d.get("crew") or [])
if (c.get("job") or "").lower() in ("director", "creator")), None)
return jsonify({
"title": d.get("title"), "overview": d.get("overview"), "tagline": d.get("tagline"),
"backdrop_url": d.get("backdrop_url"), "logo": d.get("logo"),
"genres": d.get("genres") or [], "rating": d.get("rating"),
"runtime_minutes": d.get("runtime_minutes"), "year": d.get("year"),
"network": d.get("network"), "studio": d.get("studio"),
"status": d.get("status"), "director": director,
"cast": [{"name": c.get("name"), "character": c.get("character"), "photo": c.get("photo")}
for c in (d.get("cast") or [])[:10]],
"trailer_url": ("https://www.youtube.com/watch?v=" + tr["key"]) if tr.get("key") else None,
"providers": (extras.get("providers") or [])[:6],
"providers_link": extras.get("providers_link"),
})
except Exception:
logger.exception("download meta failed for %s %s", kind, tmdb_id)
return jsonify({})

View file

@ -32,9 +32,11 @@ def _default_fetch_shows() -> List[Dict[str, Any]]:
def _default_refresh_show(library_id: Any) -> Dict[str, Any]:
"""Re-pull a library show's TMDB season/episode schedule (the lazy on-view backfill,
invoked deliberately). Re-matches + cascades episodes, so air dates/stills refresh."""
invoked deliberately). Re-matches + cascades episodes, so air dates/stills refresh.
``with_ratings=False`` we only need schedules, and the per-show OMDb ratings call
would burn the daily quota across a whole watchlist."""
from core.video.enrichment.engine import get_video_enrichment_engine
return get_video_enrichment_engine().refresh_show_art(library_id) or {}
return get_video_enrichment_engine().refresh_show_art(library_id, with_ratings=False) or {}
def auto_video_refresh_airing_schedules(

View file

@ -12,6 +12,7 @@ import threading
from utils.logging_config import get_logger
from .cache import TTLCache
from .clients import OMDbAuthError
from .worker import VideoEnrichmentWorker
logger = get_logger("video_enrichment.engine")
@ -62,7 +63,10 @@ class VideoEnrichmentEngine:
# for tests that don't build a worker).
w = self.workers.get("omdb")
rc = w.client if w else self.ratings_client
if not rc or not getattr(rc, "enabled", False):
# _omdb_blocked latches once the daily request limit / a bad key is hit — it affects
# EVERY item, so we stop calling OMDb for the rest of the process instead of failing
# (and logging a traceback) once per show.
if not rc or not getattr(rc, "enabled", False) or getattr(self, "_omdb_blocked", False):
return
info = (self.db.movie_match_info(item_id) if kind == "movie"
else self.db.show_match_info(item_id))
@ -81,6 +85,10 @@ class VideoEnrichmentEngine:
ratings = rc.ratings(imdb_id)
if ratings:
self.db.apply_ratings(kind, item_id, ratings)
except OMDbAuthError as e:
# daily limit / bad key — hits every item; latch off + one quiet warning, no spam.
self._omdb_blocked = True
logger.warning("OMDb ratings paused for this run: %s", e)
except Exception:
logger.exception("ratings backfill failed for %s %s", kind, item_id)
@ -142,11 +150,15 @@ class VideoEnrichmentEngine:
", ".join(sorted(self._scan_paused)))
self._scan_paused = set()
def refresh_show_art(self, show_id) -> dict:
def refresh_show_art(self, show_id, *, with_ratings: bool = True) -> dict:
"""On-demand (lazy) backfill of a show's season posters + episode art from
TMDB, used when the detail page is opened and art is missing. Works
regardless of the show's match status (sidesteps 'already matched, never
re-runs'), and caches the result so it's a one-time cost per show."""
re-runs'), and caches the result so it's a one-time cost per show.
``with_ratings=False`` skips the OMDb ratings backfill used by the bulk
'Refresh Airing TV Schedules' automation, which only needs episode schedules and
would otherwise burn the daily OMDb quota one call per show."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return {"ok": False, "reason": "tmdb_not_configured"}
@ -169,7 +181,8 @@ 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)
if with_ratings:
self._backfill_ratings("show", show_id)
return {"ok": True}
def refresh_movie_art(self, movie_id) -> dict:

View file

@ -42,6 +42,14 @@ def test_cards_expand_into_a_detail_drawer():
assert "/downloads/meta/" in _JS
def test_drawer_renders_the_rich_tmdb_fields():
# cast PHOTOS (the bug was the wrong field name) + logo header + trailer + providers
assert "c.photo" in _JS # correct TMDB cast-photo field
assert "vdpg-dr-logo" in _JS # title logo header
assert "vdpg-dr-trailer" in _JS and "vdpg-prov" in _JS # trailer + where-to-watch
assert "trailer_url" in _JS and "providers" in _JS
def test_download_meta_route_is_registered():
import api.video as videoapi
from flask import Flask

View file

@ -87,6 +87,70 @@ def test_watchlist_continuing_shows_skips_ended_tmdbonly_and_dupes(tmp_path, mon
assert [s["library_id"] for s in out] == [1, 4]
# ── OMDb quota safety ─────────────────────────────────────────────────────────
def test_refresh_skips_omdb_ratings(monkeypatch):
# the bulk refresh must NOT do the per-show OMDb ratings call (it'd burn the daily quota)
import core.automation.handlers.video_refresh_airing_schedules as mod
seen = {}
class _Eng:
def refresh_show_art(self, lib, *, with_ratings=True):
seen["lib"], seen["with_ratings"] = lib, with_ratings
return {"ok": True}
monkeypatch.setattr("core.video.enrichment.engine.get_video_enrichment_engine", lambda: _Eng())
assert mod._default_refresh_show(7) == {"ok": True}
assert seen == {"lib": 7, "with_ratings": False}
def test_omdb_limit_latches_off_and_stops_hammering():
from core.video.enrichment.engine import VideoEnrichmentEngine
from core.video.enrichment.clients import OMDbAuthError
class _Row: # truthy row carrying an imdb id
def __getitem__(self, k):
return "tt123"
class _Conn:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def execute(self, *a):
return type("C", (), {"fetchone": lambda s: _Row()})()
class _DB:
def show_match_info(self, i):
return {"title": "X"}
def connect(self):
return _Conn()
def apply_ratings(self, *a):
pass
class _RC:
enabled = True
def __init__(self):
self.n = 0
def ratings(self, imdb):
self.n += 1
raise OMDbAuthError("Request limit reached!")
eng = VideoEnrichmentEngine.__new__(VideoEnrichmentEngine)
eng.db, eng.ratings_client = _DB(), None
rc = _RC()
eng.workers = {"omdb": type("W", (), {"client": rc})()}
eng._backfill_ratings("show", 1) # hits the limit → latches off (no raise)
assert getattr(eng, "_omdb_blocked", False) is True
eng._backfill_ratings("show", 2) # now short-circuits before calling OMDb
assert rc.n == 1 # only the first attempt ever reached OMDb
# ── wiring contract ───────────────────────────────────────────────────────────
def test_seeded_before_the_airing_automation():
import core.automation_engine as ae

View file

@ -176,17 +176,49 @@
function fact(k, v) {
return v ? '<div class="vdpg-f"><span class="vdpg-fk">' + esc(k) + '</span><span class="vdpg-fv">' + esc(v) + '</span></div>' : '';
}
function fmtRuntime(m) {
m = parseInt(m, 10); if (!m) return '';
var h = Math.floor(m / 60), mm = m % 60;
return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm');
}
function drawerHTML(d, meta) {
var isYt = dlType(d.kind) === 'youtube', ctx = isYt ? ytCtx(d) : {};
var overview = (meta && meta.overview) || ctx.description || '';
var loading = meta === null && !isYt;
var back = (meta && meta.backdrop_url)
meta = meta || {};
var overview = meta.overview || ctx.description || '';
var back = meta.backdrop_url
? '<div class="vdpg-dr-back" style="background-image:url(\'' + esc(meta.backdrop_url) + '\')"></div>' : '';
var genres = (meta && (meta.genres || []).slice(0, 4).join(' · ')) || '';
var cast = (meta && meta.cast || []).slice(0, 8);
// header: title logo (or text) + a meta line (year · ⭐rating · runtime · network) + tagline
var titleHTML = meta.logo
? '<img class="vdpg-dr-logo" src="' + esc(meta.logo) + '" alt="' + esc(meta.title || d.title || '') + '">'
: '<div class="vdpg-dr-title">' + esc(meta.title || d.title || 'Download') + '</div>';
var bits = [];
if (meta.year || d.year) bits.push(esc(meta.year || d.year));
if (meta.rating) bits.push('⭐ ' + (Math.round(meta.rating * 10) / 10));
var rt = fmtRuntime(meta.runtime_minutes); if (rt) bits.push(rt);
if (meta.network || meta.studio) bits.push(esc(meta.network || meta.studio));
if (isYt && (ctx.channel || ctx.channel_title)) bits.push(esc(ctx.channel || ctx.channel_title));
if (meta.status && !isYt) bits.push(esc(meta.status));
var metaLine = bits.length ? '<div class="vdpg-dr-metaline">' + bits.join(' · ') + '</div>' : '';
var tagline = meta.tagline ? '<div class="vdpg-dr-tagline">' + esc(meta.tagline) + '</div>' : '';
var genres = (meta.genres || []).slice(0, 4).join(' · ');
// watch row: trailer + where-to-watch provider logos
var watch = '';
if (meta.trailer_url) watch += '<a class="vdpg-dr-btn vdpg-dr-trailer" href="' + esc(meta.trailer_url) + '" target="_blank" rel="noopener">▶ Trailer</a>';
var provs = meta.providers || [];
if (provs.length) watch += '<span class="vdpg-dr-provs"><span class="vdpg-dr-provs-t">Watch on</span>' + provs.map(function (p) {
return p.logo ? '<img class="vdpg-prov" src="' + esc(p.logo) + '" alt="' + esc(p.name || '') + '" title="' + esc(p.name || '') + '">'
: '<span class="vdpg-prov vdpg-prov-txt">' + esc(p.name || '') + '</span>';
}).join('') + '</span>';
var watchHTML = watch ? '<div class="vdpg-dr-watch">' + watch + '</div>' : '';
// cast with photos
var cast = (meta.cast || []).slice(0, 8);
var castHTML = cast.length ? '<div class="vdpg-dr-st">Cast</div><div class="vdpg-dr-cast">' + cast.map(function (c) {
var pic = c.profile_url
? '<span class="vdpg-cast-pic" style="background-image:url(\'' + esc(c.profile_url) + '\')"></span>'
var pic = c.photo
? '<span class="vdpg-cast-pic" style="background-image:url(\'' + esc(c.photo) + '\')"></span>'
: '<span class="vdpg-cast-pic vdpg-cast-none">' + esc((c.name || '?').charAt(0)) + '</span>';
return '<div class="vdpg-cast">' + pic + '<span class="vdpg-cast-nm">' + esc(c.name) +
'</span>' + (c.character ? '<span class="vdpg-cast-ch">' + esc(c.character) + '</span>' : '') + '</div>';
@ -197,6 +229,7 @@
facts += fact('Status', (STATUS[d.status] || {}).label);
if (isYt) { facts += fact('Channel', ctx.channel || ctx.channel_title); facts += fact('Quality', d.quality_label); }
else {
facts += fact(dlType(d.kind) === 'movie' ? 'Director' : 'Creator', meta.director);
facts += fact('Quality target', d.quality_label);
facts += fact('Release', d.release_title);
facts += fact('Format', [d.resolution, d.source, d.codec].filter(Boolean).join(' · '));
@ -221,8 +254,9 @@
var syn = loading ? '<p class="vdpg-dr-syn vdpg-dr-muted">Loading…</p>'
: (overview ? '<p class="vdpg-dr-syn">' + esc(overview) + '</p>'
: (isYt ? '' : '<p class="vdpg-dr-syn vdpg-dr-muted">No synopsis available.</p>'));
return back + '<div class="vdpg-dr-body">' + syn +
(genres ? '<div class="vdpg-dr-genres">' + esc(genres) + '</div>' : '') +
return back + '<div class="vdpg-dr-body">' +
'<div class="vdpg-dr-head">' + titleHTML + metaLine + tagline + '</div>' +
syn + (genres ? '<div class="vdpg-dr-genres">' + esc(genres) + '</div>' : '') + watchHTML +
castHTML + '<div class="vdpg-dr-st">Download</div><div class="vdpg-dr-facts">' + facts + '</div>' +
actions + '</div>';
}

View file

@ -3806,7 +3806,20 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdpg-dr-body { position: relative; padding: 16px 18px 18px; }
.vdpg-dr-syn { font-size: 13px; line-height: 1.55; color: rgba(255, 255, 255, 0.82); margin: 0 0 10px; max-width: 80ch; }
.vdpg-dr-muted { color: rgba(255, 255, 255, 0.4); font-style: italic; }
.vdpg-dr-genres { font-size: 11px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: rgb(var(--vt)); }
.vdpg-dr-genres { font-size: 11px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: rgb(var(--vt)); margin-bottom: 2px; }
.vdpg-dr-head { margin-bottom: 12px; }
.vdpg-dr-logo { max-height: 54px; max-width: 62%; object-fit: contain; display: block; margin-bottom: 7px;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.55)); }
.vdpg-dr-title { font-size: 20px; font-weight: 800; color: #fff; line-height: 1.15; margin-bottom: 5px; }
.vdpg-dr-metaline { font-size: 12.5px; font-weight: 600; color: rgba(255, 255, 255, 0.62); }
.vdpg-dr-tagline { font-size: 12.5px; font-style: italic; color: rgba(255, 255, 255, 0.5); margin-top: 5px; }
.vdpg-dr-watch { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 14px 0 2px; }
.vdpg-dr-trailer { background: rgba(var(--vt), 0.18); border-color: rgba(var(--vt), 0.5); color: #fff; }
.vdpg-dr-provs { display: inline-flex; align-items: center; gap: 7px; }
.vdpg-dr-provs-t { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.4); }
.vdpg-prov { width: 28px; height: 28px; border-radius: 7px; object-fit: cover; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); }
.vdpg-prov-txt { display: inline-grid; place-items: center; min-width: 28px; height: 28px; padding: 0 7px; border-radius: 7px;
font-size: 10px; font-weight: 700; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); }
.vdpg-dr-st { font-size: 10.5px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase;
color: rgba(255, 255, 255, 0.4); margin: 16px 0 9px; }
.vdpg-dr-cast { display: flex; flex-wrap: wrap; gap: 12px; }