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:
parent
a30a70fafd
commit
c35ec43466
7 changed files with 172 additions and 22 deletions
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue