diff --git a/api/video/downloads.py b/api/video/downloads.py index dff5ffec..47387d56 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -172,17 +172,33 @@ def register_routes(bp): @bp.route("/downloads/meta//", 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({}) diff --git a/core/automation/handlers/video_refresh_airing_schedules.py b/core/automation/handlers/video_refresh_airing_schedules.py index d2dbd34a..7c0db6c3 100644 --- a/core/automation/handlers/video_refresh_airing_schedules.py +++ b/core/automation/handlers/video_refresh_airing_schedules.py @@ -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( diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index f02bbb0b..f0d97373 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -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: diff --git a/tests/test_video_downloads_page.py b/tests/test_video_downloads_page.py index c96dfa38..a0540d77 100644 --- a/tests/test_video_downloads_page.py +++ b/tests/test_video_downloads_page.py @@ -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 diff --git a/tests/test_video_refresh_airing_schedules.py b/tests/test_video_refresh_airing_schedules.py index f673fdc9..6e20ab16 100644 --- a/tests/test_video_refresh_airing_schedules.py +++ b/tests/test_video_refresh_airing_schedules.py @@ -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 diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js index 467ef6ab..1c9de517 100644 --- a/webui/static/video/video-downloads-page.js +++ b/webui/static/video/video-downloads-page.js @@ -176,17 +176,49 @@ function fact(k, v) { return v ? '
' + esc(k) + '' + esc(v) + '
' : ''; } + 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 ? '
' : ''; - 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 + ? '' + : '
' + esc(meta.title || d.title || 'Download') + '
'; + 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 ? '
' + bits.join(' · ') + '
' : ''; + var tagline = meta.tagline ? '
' + esc(meta.tagline) + '
' : ''; + var genres = (meta.genres || []).slice(0, 4).join(' · '); + + // watch row: trailer + where-to-watch provider logos + var watch = ''; + if (meta.trailer_url) watch += '▶ Trailer'; + var provs = meta.providers || []; + if (provs.length) watch += 'Watch on' + provs.map(function (p) { + return p.logo ? '' + esc(p.name || '') + '' + : '' + esc(p.name || '') + ''; + }).join('') + ''; + var watchHTML = watch ? '
' + watch + '
' : ''; + + // cast with photos + var cast = (meta.cast || []).slice(0, 8); var castHTML = cast.length ? '
Cast
' + cast.map(function (c) { - var pic = c.profile_url - ? '' + var pic = c.photo + ? '' : '' + esc((c.name || '?').charAt(0)) + ''; return '
' + pic + '' + esc(c.name) + '' + (c.character ? '' + esc(c.character) + '' : '') + '
'; @@ -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 ? '

Loading…

' : (overview ? '

' + esc(overview) + '

' : (isYt ? '' : '

No synopsis available.

')); - return back + '
' + syn + - (genres ? '
' + esc(genres) + '
' : '') + + return back + '
' + + '
' + titleHTML + metaLine + tagline + '
' + + syn + (genres ? '
' + esc(genres) + '
' : '') + watchHTML + castHTML + '
Download
' + facts + '
' + actions + '
'; } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 06f1a730..343ca2ac 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; }