downloads drawer: episode detail + youtube treatment + availability line

- EPISODE downloads now show the specific episode: still + 'S02E05 · air date' + that
  episode's own title + synopsis (was just the show synopsis). meta endpoint takes
  ?season=&episode= → engine.tmdb_season; show cast/logo stay as context.
- YOUTUBE drawer: big 16:9 thumbnail + channel · duration · views · upload date +
  description. new yt-meta route → db.youtube_video_detail (cached duration/views).
- AVAILABILITY line: the chosen source's free-slot/queue/speed snapshot, stashed in
  search_ctx at grab time (build_download_record) → 'Availability: ✓ free slot · queue 0
  · 2.1 MB/s'.
drawer also reworked into clean youtube / episode / movie-show branches; first paint
shows 'Loading…' instead of flashing 'no synopsis'. tested.
This commit is contained in:
BoulderBadgeDad 2026-06-26 13:06:44 -07:00
parent c35ec43466
commit b916e6a2f3
8 changed files with 196 additions and 56 deletions

View file

@ -186,13 +186,28 @@ def register_routes(bp):
tr = extras.get("trailer") or {} tr = extras.get("trailer") or {}
director = next((c.get("name") for c in (d.get("crew") or []) director = next((c.get("name") for c in (d.get("crew") or [])
if (c.get("job") or "").lower() in ("director", "creator")), None) if (c.get("job") or "").lower() in ("director", "creator")), None)
# episode-specific detail (still + that episode's own title/overview/air date) when a
# specific episode is downloading — more relevant than the show synopsis.
episode = None
sn, en = request.args.get("season"), request.args.get("episode")
if kind == "show" and sn and en:
try:
season = get_video_enrichment_engine().tmdb_season(tmdb_id, int(sn)) or {}
ep = next((e for e in (season.get("episodes") or [])
if str(e.get("episode_number")) == str(int(en))), None)
if ep:
episode = {"season": int(sn), "episode": int(en), "title": ep.get("title"),
"overview": ep.get("overview"), "air_date": ep.get("air_date"),
"still_url": ep.get("still_url")}
except (ValueError, TypeError):
pass
return jsonify({ return jsonify({
"title": d.get("title"), "overview": d.get("overview"), "tagline": d.get("tagline"), "title": d.get("title"), "overview": d.get("overview"), "tagline": d.get("tagline"),
"backdrop_url": d.get("backdrop_url"), "logo": d.get("logo"), "backdrop_url": d.get("backdrop_url"), "logo": d.get("logo"),
"genres": d.get("genres") or [], "rating": d.get("rating"), "genres": d.get("genres") or [], "rating": d.get("rating"),
"runtime_minutes": d.get("runtime_minutes"), "year": d.get("year"), "runtime_minutes": d.get("runtime_minutes"), "year": d.get("year"),
"network": d.get("network"), "studio": d.get("studio"), "network": d.get("network"), "studio": d.get("studio"),
"status": d.get("status"), "director": director, "status": d.get("status"), "director": director, "episode": episode,
"cast": [{"name": c.get("name"), "character": c.get("character"), "photo": c.get("photo")} "cast": [{"name": c.get("name"), "character": c.get("character"), "photo": c.get("photo")}
for c in (d.get("cast") or [])[:10]], for c in (d.get("cast") or [])[:10]],
"trailer_url": ("https://www.youtube.com/watch?v=" + tr["key"]) if tr.get("key") else None, "trailer_url": ("https://www.youtube.com/watch?v=" + tr["key"]) if tr.get("key") else None,
@ -203,6 +218,16 @@ def register_routes(bp):
logger.exception("download meta failed for %s %s", kind, tmdb_id) logger.exception("download meta failed for %s %s", kind, tmdb_id)
return jsonify({}) return jsonify({})
@bp.route("/downloads/yt-meta/<video_id>", methods=["GET"])
def video_download_yt_meta(video_id):
"""Cached extra detail for a YouTube download's drawer (duration / views / thumbnail)."""
from . import get_video_db
try:
return jsonify(get_video_db().youtube_video_detail(video_id) or {})
except Exception:
logger.exception("yt meta failed for %s", video_id)
return jsonify({})
@bp.route("/downloads/quality", methods=["GET"]) @bp.route("/downloads/quality", methods=["GET"])
def video_quality_profile(): def video_quality_profile():
from . import get_video_db from . import get_video_db

View file

@ -83,6 +83,11 @@ def build_download_record(item: Dict[str, Any], best: Dict[str, Any], candidates
grab, so the monitor finishes it the same way (other accepted hits become the retry grab, so the monitor finishes it the same way (other accepted hits become the retry
pool).""" pool)."""
ctx = search_context(item, media_type) ctx = search_context(item, media_type)
# stash the chosen source's peer stats so the drawer can show its availability snapshot
# (free slot / queue depth / speed at grab time). Retry ignores the extra key.
peer = {k: best.get(k) for k in ("slots", "queue", "speed", "availability") if best.get(k) is not None}
if peer:
ctx = {**ctx, "peer": peer}
rest = [c for c in (candidates or []) if c.get("filename") != best.get("filename")] rest = [c for c in (candidates or []) if c.get("filename") != best.get("filename")]
media_id = str(item.get("tmdb_id") if media_type == "movie" else item.get("show_tmdb_id")) media_id = str(item.get("tmdb_id") if media_type == "movie" else item.get("show_tmdb_id"))
return { return {

View file

@ -1474,6 +1474,18 @@ class VideoDatabase:
finally: finally:
conn.close() conn.close()
def youtube_video_detail(self, youtube_id) -> dict | None:
"""Cached metadata for one YouTube video (title / thumbnail / duration / views) — the
extra detail the download drawer shows. None if it was never cached by a channel scan."""
conn = self._get_connection()
try:
r = conn.execute(
"SELECT title, thumbnail_url, duration, view_count "
"FROM youtube_channel_videos WHERE youtube_id=? LIMIT 1", (youtube_id,)).fetchone()
return dict(r) if r else None
finally:
conn.close()
def media_tmdb_id(self, kind: str, media_id) -> tuple: def media_tmdb_id(self, kind: str, media_id) -> tuple:
"""(tmdb_id, imdb_id) for a library movie/show row — used to resolve sidecar / """(tmdb_id, imdb_id) for a library movie/show row — used to resolve sidecar /
subtitle metadata for an owned re-grab (whose media_id is the library id, not a subtitle metadata for an owned re-grab (whose media_id is the library id, not a

View file

@ -50,11 +50,20 @@ def test_drawer_renders_the_rich_tmdb_fields():
assert "trailer_url" in _JS and "providers" in _JS assert "trailer_url" in _JS and "providers" in _JS
def test_download_meta_route_is_registered(): def test_drawer_has_episode_youtube_and_availability_blocks():
assert "vdpg-dr-ytthumb" in _JS # youtube big thumbnail header
assert "vdpg-dr-ep" in _JS and "vdpg-dr-epstill" in _JS # episode still + block
assert "ctx.peer" in _JS # grab-time availability snapshot
assert "yt-meta" in _JS # youtube metadata fetch
assert "season=" in _JS and "episode=" in _JS # episode params on the meta fetch
def test_download_meta_routes_are_registered():
import api.video as videoapi import api.video as videoapi
from flask import Flask from flask import Flask
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
rules = {r.rule for r in app.url_map.iter_rules()} rules = {r.rule for r in app.url_map.iter_rules()}
assert "/api/video/downloads/meta/<kind>/<int:tmdb_id>" in rules assert "/api/video/downloads/meta/<kind>/<int:tmdb_id>" in rules
assert "/api/video/downloads/yt-meta/<video_id>" in rules

View file

@ -69,6 +69,15 @@ def test_build_record_movie_shape():
assert [c["filename"] for c in json.loads(rec["candidates"])] == ["other.mkv"] # best excluded assert [c["filename"] for c in json.loads(rec["candidates"])] == ["other.mkv"] # best excluded
def test_build_record_stashes_peer_availability_in_ctx():
# the chosen source's free-slot/queue/speed snapshot rides in search_ctx for the drawer
item = {"tmdb_id": 5, "title": "M", "year": "1999"}
best = dict(_cand("M.1999.mkv"), slots=1, queue=0, speed=2100000, availability=0.15)
rec = build_download_record(item, best, [best], media_type="movie", target_dir="/m", query="q")
assert json.loads(rec["search_ctx"])["peer"] == {
"slots": 1, "queue": 0, "speed": 2100000, "availability": 0.15}
def test_build_record_episode_shape(): def test_build_record_episode_shape():
item = {"show_tmdb_id": 9, "show_title": "Breaking Bad", "season_number": 1, item = {"show_tmdb_id": 9, "show_title": "Breaking Bad", "season_number": 1,
"episode_number": 3, "air_date": "2008-02-10"} "episode_number": 3, "air_date": "2008-02-10"}

View file

@ -187,6 +187,18 @@ def test_downloaded_youtube_video_ids_only_completed_youtube(db):
assert set(db.downloaded_youtube_video_ids()) == {"v1"} assert set(db.downloaded_youtube_video_ids()) == {"v1"}
def test_youtube_video_detail(db):
conn = db._get_connection()
conn.execute("INSERT INTO youtube_channel_videos (channel_id, youtube_id, title, thumbnail_url, "
"duration, view_count) VALUES (?,?,?,?,?,?)",
("UC1", "vid9", "Cool Vid", "/t.jpg", "12:34", 50000))
conn.commit()
conn.close()
d = db.youtube_video_detail("vid9")
assert d["title"] == "Cool Vid" and d["duration"] == "12:34" and d["view_count"] == 50000
assert db.youtube_video_detail("missing") is None
def test_count_and_claim_queue(db): def test_count_and_claim_queue(db):
a = db.add_video_download({"kind": "youtube", "source": "youtube", "media_id": "v1", a = db.add_video_download({"kind": "youtube", "source": "youtube", "media_id": "v1",
"title": "A", "status": "queued"}) "title": "A", "status": "queued"})

View file

@ -169,7 +169,7 @@
} }
// ── expand drawer ───────────────────────────────────────────────────────────── // ── expand drawer ─────────────────────────────────────────────────────────────
function ytCtx(d) { function parseCtx(d) { // the download's search_ctx (peer/season/episode/channel/…)
try { return d.search_ctx ? (typeof d.search_ctx === 'string' ? JSON.parse(d.search_ctx) : d.search_ctx) : {}; } try { return d.search_ctx ? (typeof d.search_ctx === 'string' ? JSON.parse(d.search_ctx) : d.search_ctx) : {}; }
catch (e) { return {}; } catch (e) { return {}; }
} }
@ -181,48 +181,86 @@
var h = Math.floor(m / 60), mm = m % 60; var h = Math.floor(m / 60), mm = m % 60;
return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm'); return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm');
} }
function drawerHTML(d, meta) { function fmtViews(n) {
var isYt = dlType(d.kind) === 'youtube', ctx = isYt ? ytCtx(d) : {}; n = +n || 0;
var loading = meta === null && !isYt; return n >= 1e6 ? (Math.round(n / 1e5) / 10 + 'M') : n >= 1e3 ? (Math.round(n / 100) / 10 + 'K') : String(n);
meta = meta || {}; }
var overview = meta.overview || ctx.description || ''; function fmtSpeed(bps) {
var back = meta.backdrop_url bps = +bps || 0; if (!bps) return '';
? '<div class="vdpg-dr-back" style="background-image:url(\'' + esc(meta.backdrop_url) + '\')"></div>' : ''; return bps >= 1e6 ? (Math.round(bps / 1e5) / 10 + ' MB/s') : Math.max(1, Math.round(bps / 1e3)) + ' KB/s';
}
// header: title logo (or text) + a meta line (year · ⭐rating · runtime · network) + tagline function pad2(n) { n = parseInt(n, 10) || 0; return (n < 10 ? '0' : '') + n; }
var titleHTML = meta.logo function castHTMLOf(meta) {
? '<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 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) { return cast.length ? '<div class="vdpg-dr-st">Cast</div><div class="vdpg-dr-cast">' + cast.map(function (c) {
var pic = c.photo var pic = c.photo
? '<span class="vdpg-cast-pic" style="background-image:url(\'' + esc(c.photo) + '\')"></span>' ? '<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>'; : '<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) + 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>'; '</span>' + (c.character ? '<span class="vdpg-cast-ch">' + esc(c.character) + '</span>' : '') + '</div>';
}).join('') + '</div>' : ''; }).join('') + '</div>' : '';
}
function drawerHTML(d, meta) {
var isYt = dlType(d.kind) === 'youtube', ctx = parseCtx(d);
var loading = meta === null;
meta = meta || {};
var back = '', head = '', lead = '', extra = '';
if (isYt) {
// big thumbnail + channel · duration · views · upload date, then the description
var thumb = meta.thumbnail_url || d.poster_url;
var yb = [];
if (ctx.channel || ctx.channel_title) yb.push(esc(ctx.channel || ctx.channel_title));
if (meta.duration) yb.push(esc(meta.duration));
if (meta.view_count) yb.push(fmtViews(meta.view_count) + ' views');
if (ctx.published_at) yb.push(esc(String(ctx.published_at).slice(0, 10)));
head = '<div class="vdpg-dr-head">' +
(thumb ? '<div class="vdpg-dr-ytthumb" style="background-image:url(\'' + esc(thumb) + '\')"></div>' : '') +
'<div class="vdpg-dr-title">' + esc(d.title || meta.title || 'Video') + '</div>' +
(yb.length ? '<div class="vdpg-dr-metaline">' + yb.join(' · ') + '</div>' : '') + '</div>';
lead = ctx.description ? '<p class="vdpg-dr-syn">' + esc(ctx.description) + '</p>' : '';
} else {
back = meta.backdrop_url
? '<div class="vdpg-dr-back" style="background-image:url(\'' + esc(meta.backdrop_url) + '\')"></div>' : '';
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 (meta.status) bits.push(esc(meta.status));
var tagline = meta.tagline ? '<div class="vdpg-dr-tagline">' + esc(meta.tagline) + '</div>' : '';
head = '<div class="vdpg-dr-head">' + titleHTML +
(bits.length ? '<div class="vdpg-dr-metaline">' + bits.join(' · ') + '</div>' : '') + tagline + '</div>';
var ep = meta.episode;
if (ep) { // the SPECIFIC episode: still + SxE · air date + episode title + its own synopsis
lead = '<div class="vdpg-dr-ep">' +
(ep.still_url ? '<div class="vdpg-dr-epstill" style="background-image:url(\'' + esc(ep.still_url) + '\')"></div>' : '') +
'<div class="vdpg-dr-epbody"><div class="vdpg-dr-epnum">S' + pad2(ep.season) + 'E' + pad2(ep.episode) +
(ep.air_date ? ' · ' + esc(ep.air_date) : '') + '</div>' +
'<div class="vdpg-dr-eptitle">' + esc(ep.title || '') + '</div>' +
(ep.overview ? '<p class="vdpg-dr-epov">' + esc(ep.overview) + '</p>' : '') + '</div></div>';
} else {
lead = loading ? '<p class="vdpg-dr-syn vdpg-dr-muted">Loading…</p>'
: (meta.overview ? '<p class="vdpg-dr-syn">' + esc(meta.overview) + '</p>'
: '<p class="vdpg-dr-syn vdpg-dr-muted">No synopsis available.</p>');
}
var genres = (meta.genres || []).slice(0, 4).join(' · ');
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>';
extra = (genres ? '<div class="vdpg-dr-genres">' + esc(genres) + '</div>' : '') +
(watch ? '<div class="vdpg-dr-watch">' + watch + '</div>' : '') + castHTMLOf(meta);
}
// download facts (only the fields that exist render) // download facts (only the fields that exist render)
var facts = ''; var facts = '';
@ -233,7 +271,14 @@
facts += fact('Quality target', d.quality_label); facts += fact('Quality target', d.quality_label);
facts += fact('Release', d.release_title); facts += fact('Release', d.release_title);
facts += fact('Format', [d.resolution, d.source, d.codec].filter(Boolean).join(' · ')); facts += fact('Format', [d.resolution, d.source, d.codec].filter(Boolean).join(' · '));
facts += fact('Source', d.username ? ('👤 ' + d.username + (d.queue != null ? (' · queue ' + d.queue) : '')) : ''); facts += fact('Source', d.username ? ('👤 ' + d.username) : '');
if (ctx.peer) { // the chosen source's availability snapshot at grab time
var p = ctx.peer, av = [];
if (p.slots != null) av.push(p.slots > 0 ? '✓ free slot' : 'no free slot');
if (p.queue != null) av.push('queue ' + p.queue);
var sp = fmtSpeed(p.speed); if (sp) av.push(sp);
facts += fact('Availability', av.join(' · '));
}
} }
facts += fact('Size', d.size_bytes ? fmtSize(d.size_bytes) : ''); facts += fact('Size', d.size_bytes ? fmtSize(d.size_bytes) : '');
facts += fact('Attempts', d.attempts > 1 ? (d.attempts + 'x') : ''); facts += fact('Attempts', d.attempts > 1 ? (d.attempts + 'x') : '');
@ -242,7 +287,6 @@
'<button class="vdpg-copy" type="button" data-vdpg-copy="' + esc(d.dest_path) + '" title="Copy path">⧉</button></div>'; '<button class="vdpg-copy" type="button" data-vdpg-copy="' + esc(d.dest_path) + '" title="Copy path">⧉</button></div>';
if (isFail(d.status) && d.error) facts += '<div class="vdpg-f vdpg-f-wide vdpg-f-err"><span class="vdpg-fk">Error</span><span class="vdpg-fv">' + esc(d.error) + '</span></div>'; if (isFail(d.status) && d.error) facts += '<div class="vdpg-f vdpg-f-wide vdpg-f-err"><span class="vdpg-fk">Error</span><span class="vdpg-fv">' + esc(d.error) + '</span></div>';
// big actions
var btns = []; var btns = [];
if (d.media_id && !isYt) btns.push('<button class="vdpg-dr-btn" type="button" data-vdpg-open="' + esc(d.media_id) + if (d.media_id && !isYt) btns.push('<button class="vdpg-dr-btn" type="button" data-vdpg-open="' + esc(d.media_id) +
'" data-kind="' + esc(d.kind || 'movie') + '" data-source="' + esc(d.media_source || 'library') + '">Open in library</button>'); '" data-kind="' + esc(d.kind || 'movie') + '" data-source="' + esc(d.media_source || 'library') + '">Open in library</button>');
@ -251,31 +295,42 @@
else if (isFail(d.status)) btns.push('<button class="vdpg-dr-btn vdpg-dr-accent" type="button" data-vdpg-retry="' + d.id + '">Retry</button>'); else if (isFail(d.status)) btns.push('<button class="vdpg-dr-btn vdpg-dr-accent" type="button" data-vdpg-retry="' + d.id + '">Retry</button>');
var actions = btns.length ? '<div class="vdpg-dr-actions">' + btns.join('') + '</div>' : ''; var actions = btns.length ? '<div class="vdpg-dr-actions">' + btns.join('') + '</div>' : '';
var syn = loading ? '<p class="vdpg-dr-syn vdpg-dr-muted">Loading…</p>' return back + '<div class="vdpg-dr-body">' + head + lead + extra +
: (overview ? '<p class="vdpg-dr-syn">' + esc(overview) + '</p>' '<div class="vdpg-dr-st">Download</div><div class="vdpg-dr-facts">' + facts + '</div>' +
: (isYt ? '' : '<p class="vdpg-dr-syn vdpg-dr-muted">No synopsis available.</p>'));
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>'; actions + '</div>';
} }
function metaURL(d) {
var t = dlType(d.kind);
if (t === 'youtube') return '/api/video/downloads/yt-meta/' + encodeURIComponent(d.media_id);
if (d.media_source === 'library') return null; // owned re-grab: media_id isn't a tmdb id
var url = '/api/video/downloads/meta/' + (t === 'movie' ? 'movie' : 'show') + '/' + encodeURIComponent(d.media_id);
if (d.kind === 'episode') {
var c = parseCtx(d);
if (c.season != null && c.episode != null) url += '?season=' + encodeURIComponent(c.season) + '&episode=' + encodeURIComponent(c.episode);
}
return url;
}
function renderDrawer(el, d) { function renderDrawer(el, d) {
var dr = el.querySelector('[data-f="drawer"]'); if (!dr) return; var dr = el.querySelector('[data-f="drawer"]'); if (!dr) return;
var open = !!_expanded[d.id]; var open = !!_expanded[d.id];
el.classList.toggle('vdpg-card-open', open); el.classList.toggle('vdpg-card-open', open);
dr.hidden = !open; dr.hidden = !open;
if (!open) { dr.innerHTML = ''; return; } if (!open) { dr.innerHTML = ''; return; }
dr.innerHTML = drawerHTML(d, _meta[d.id]); // kick off the lazy detail fetch (TMDB for movie/TV, cached metadata for youtube) so the
// lazily fetch TMDB detail for movie/TV (skip youtube + owned library re-grabs) // first paint already shows 'Loading…' rather than 'no synopsis' flashing before content.
if (_meta[d.id] === undefined && d.media_id && dlType(d.kind) !== 'youtube' && d.media_source !== 'library') { if (_meta[d.id] === undefined && d.media_id) {
_meta[d.id] = null; var url = metaURL(d);
var k = dlType(d.kind) === 'movie' ? 'movie' : 'show'; if (!url) { _meta[d.id] = {}; }
getJSON('/api/video/downloads/meta/' + k + '/' + encodeURIComponent(d.media_id)).then(function (m) { else {
_meta[d.id] = m || {}; _meta[d.id] = null;
if (_expanded[d.id]) { var dr2 = el.querySelector('[data-f="drawer"]'); if (dr2) dr2.innerHTML = drawerHTML(d, _meta[d.id]); } getJSON(url).then(function (m) {
}); _meta[d.id] = m || {};
if (_expanded[d.id]) { var dr2 = el.querySelector('[data-f="drawer"]'); if (dr2) dr2.innerHTML = drawerHTML(d, _meta[d.id]); }
});
}
} }
dr.innerHTML = drawerHTML(d, _meta[d.id]);
} }
function render(list) { function render(list) {

View file

@ -3820,6 +3820,19 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.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 { 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; .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); } font-size: 10px; font-weight: 700; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); }
/* youtube: big 16:9 thumbnail in the header */
.vdpg-dr-ytthumb { width: 100%; max-width: 340px; aspect-ratio: 16 / 9; border-radius: 10px; margin-bottom: 10px;
background-size: cover; background-position: center; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); }
/* episode block: still + the specific episode's meta */
.vdpg-dr-ep { display: flex; gap: 16px; margin-bottom: 6px; }
.vdpg-dr-epstill { flex: 0 0 auto; width: 200px; aspect-ratio: 16 / 9; border-radius: 9px;
background-size: cover; background-position: center; background-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }
.vdpg-dr-epbody { min-width: 0; }
.vdpg-dr-epnum { font-size: 11px; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; color: rgb(var(--vt)); }
.vdpg-dr-eptitle { font-size: 15px; font-weight: 700; color: #fff; margin: 3px 0 6px; }
.vdpg-dr-epov { font-size: 12.5px; line-height: 1.5; color: rgba(255, 255, 255, 0.78); margin: 0; }
@media (max-width: 620px) { .vdpg-dr-ep { flex-direction: column; } .vdpg-dr-epstill { width: 100%; max-width: 320px; } }
.vdpg-dr-st { font-size: 10.5px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; .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; } color: rgba(255, 255, 255, 0.4); margin: 16px 0 9px; }
.vdpg-dr-cast { display: flex; flex-wrap: wrap; gap: 12px; } .vdpg-dr-cast { display: flex; flex-wrap: wrap; gap: 12px; }