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 {}
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

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
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 {

View file

@ -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

View file

@ -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

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
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"}

View file

@ -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"})

View file

@ -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) {

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-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; }