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:
parent
c35ec43466
commit
b916e6a2f3
8 changed files with 196 additions and 56 deletions
|
|
@ -186,13 +186,28 @@ def register_routes(bp):
|
|||
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)
|
||||
# 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({
|
||||
"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,
|
||||
"status": d.get("status"), "director": director, "episode": episode,
|
||||
"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,
|
||||
|
|
@ -203,6 +218,16 @@ def register_routes(bp):
|
|||
logger.exception("download meta failed for %s %s", kind, tmdb_id)
|
||||
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"])
|
||||
def video_quality_profile():
|
||||
from . import get_video_db
|
||||
|
|
|
|||
|
|
@ -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
|
||||
pool)."""
|
||||
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")]
|
||||
media_id = str(item.get("tmdb_id") if media_type == "movie" else item.get("show_tmdb_id"))
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1474,6 +1474,18 @@ class VideoDatabase:
|
|||
finally:
|
||||
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:
|
||||
"""(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
|
||||
|
|
|
|||
|
|
@ -50,11 +50,20 @@ def test_drawer_renders_the_rich_tmdb_fields():
|
|||
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
|
||||
from flask import Flask
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
|
||||
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/yt-meta/<video_id>" in rules
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,15 @@ def test_build_record_movie_shape():
|
|||
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():
|
||||
item = {"show_tmdb_id": 9, "show_title": "Breaking Bad", "season_number": 1,
|
||||
"episode_number": 3, "air_date": "2008-02-10"}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,18 @@ def test_downloaded_youtube_video_ids_only_completed_youtube(db):
|
|||
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):
|
||||
a = db.add_video_download({"kind": "youtube", "source": "youtube", "media_id": "v1",
|
||||
"title": "A", "status": "queued"})
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@
|
|||
}
|
||||
|
||||
// ── 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) : {}; }
|
||||
catch (e) { return {}; }
|
||||
}
|
||||
|
|
@ -181,48 +181,86 @@
|
|||
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 loading = meta === null && !isYt;
|
||||
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>' : '';
|
||||
|
||||
// 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
|
||||
function fmtViews(n) {
|
||||
n = +n || 0;
|
||||
return n >= 1e6 ? (Math.round(n / 1e5) / 10 + 'M') : n >= 1e3 ? (Math.round(n / 100) / 10 + 'K') : String(n);
|
||||
}
|
||||
function fmtSpeed(bps) {
|
||||
bps = +bps || 0; if (!bps) return '';
|
||||
return bps >= 1e6 ? (Math.round(bps / 1e5) / 10 + ' MB/s') : Math.max(1, Math.round(bps / 1e3)) + ' KB/s';
|
||||
}
|
||||
function pad2(n) { n = parseInt(n, 10) || 0; return (n < 10 ? '0' : '') + n; }
|
||||
function castHTMLOf(meta) {
|
||||
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
|
||||
? '<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>';
|
||||
}).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)
|
||||
var facts = '';
|
||||
|
|
@ -233,7 +271,14 @@
|
|||
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(' · '));
|
||||
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('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>';
|
||||
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 = [];
|
||||
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>');
|
||||
|
|
@ -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>');
|
||||
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>'
|
||||
: (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">' +
|
||||
'<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>' +
|
||||
return back + '<div class="vdpg-dr-body">' + head + lead + extra +
|
||||
'<div class="vdpg-dr-st">Download</div><div class="vdpg-dr-facts">' + facts + '</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) {
|
||||
var dr = el.querySelector('[data-f="drawer"]'); if (!dr) return;
|
||||
var open = !!_expanded[d.id];
|
||||
el.classList.toggle('vdpg-card-open', open);
|
||||
dr.hidden = !open;
|
||||
if (!open) { dr.innerHTML = ''; return; }
|
||||
dr.innerHTML = drawerHTML(d, _meta[d.id]);
|
||||
// lazily fetch TMDB detail for movie/TV (skip youtube + owned library re-grabs)
|
||||
if (_meta[d.id] === undefined && d.media_id && dlType(d.kind) !== 'youtube' && d.media_source !== 'library') {
|
||||
_meta[d.id] = null;
|
||||
var k = dlType(d.kind) === 'movie' ? 'movie' : 'show';
|
||||
getJSON('/api/video/downloads/meta/' + k + '/' + encodeURIComponent(d.media_id)).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]); }
|
||||
});
|
||||
// kick off the lazy detail fetch (TMDB for movie/TV, cached metadata for youtube) so the
|
||||
// first paint already shows 'Loading…' rather than 'no synopsis' flashing before content.
|
||||
if (_meta[d.id] === undefined && d.media_id) {
|
||||
var url = metaURL(d);
|
||||
if (!url) { _meta[d.id] = {}; }
|
||||
else {
|
||||
_meta[d.id] = null;
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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-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); }
|
||||
/* 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;
|
||||
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