From b916e6a2f3560a99724b5d8b9b4fc6ad117ca311 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 13:06:44 -0700 Subject: [PATCH] downloads drawer: episode detail + youtube treatment + availability line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- api/video/downloads.py | 27 ++- .../handlers/video_process_wishlist.py | 5 + database/video_database.py | 12 ++ tests/test_video_downloads_page.py | 11 +- tests/test_video_process_wishlist.py | 9 + tests/test_video_process_youtube_wishlist.py | 12 ++ webui/static/video/video-downloads-page.js | 163 ++++++++++++------ webui/static/video/video-side.css | 13 ++ 8 files changed, 196 insertions(+), 56 deletions(-) diff --git a/api/video/downloads.py b/api/video/downloads.py index 47387d56..02eb22d4 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -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/", 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 diff --git a/core/automation/handlers/video_process_wishlist.py b/core/automation/handlers/video_process_wishlist.py index 03c87ec9..c1e0795c 100644 --- a/core/automation/handlers/video_process_wishlist.py +++ b/core/automation/handlers/video_process_wishlist.py @@ -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 { diff --git a/database/video_database.py b/database/video_database.py index c3fd61be..27e5c15d 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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 diff --git a/tests/test_video_downloads_page.py b/tests/test_video_downloads_page.py index a0540d77..fb6a005d 100644 --- a/tests/test_video_downloads_page.py +++ b/tests/test_video_downloads_page.py @@ -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//" in rules + assert "/api/video/downloads/yt-meta/" in rules diff --git a/tests/test_video_process_wishlist.py b/tests/test_video_process_wishlist.py index b5b63076..cc00d178 100644 --- a/tests/test_video_process_wishlist.py +++ b/tests/test_video_process_wishlist.py @@ -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"} diff --git a/tests/test_video_process_youtube_wishlist.py b/tests/test_video_process_youtube_wishlist.py index e54f790d..42eeaabf 100644 --- a/tests/test_video_process_youtube_wishlist.py +++ b/tests/test_video_process_youtube_wishlist.py @@ -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"}) diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js index 1c9de517..0486b7c5 100644 --- a/webui/static/video/video-downloads-page.js +++ b/webui/static/video/video-downloads-page.js @@ -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 - ? '
' : ''; - - // 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 + 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 ? '
Cast
' + cast.map(function (c) { + return cast.length ? '
Cast
' + cast.map(function (c) { var pic = c.photo ? '' : '' + esc((c.name || '?').charAt(0)) + ''; return '
' + pic + '' + esc(c.name) + '' + (c.character ? '' + esc(c.character) + '' : '') + '
'; }).join('') + '
' : ''; + } + + 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 = '
' + + (thumb ? '
' : '') + + '
' + esc(d.title || meta.title || 'Video') + '
' + + (yb.length ? '
' + yb.join(' · ') + '
' : '') + '
'; + lead = ctx.description ? '

' + esc(ctx.description) + '

' : ''; + } else { + back = meta.backdrop_url + ? '
' : ''; + 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 (meta.status) bits.push(esc(meta.status)); + var tagline = meta.tagline ? '
' + esc(meta.tagline) + '
' : ''; + head = '
' + titleHTML + + (bits.length ? '
' + bits.join(' · ') + '
' : '') + tagline + '
'; + + var ep = meta.episode; + if (ep) { // the SPECIFIC episode: still + SxE · air date + episode title + its own synopsis + lead = '
' + + (ep.still_url ? '
' : '') + + '
S' + pad2(ep.season) + 'E' + pad2(ep.episode) + + (ep.air_date ? ' · ' + esc(ep.air_date) : '') + '
' + + '
' + esc(ep.title || '') + '
' + + (ep.overview ? '

' + esc(ep.overview) + '

' : '') + '
'; + } else { + lead = loading ? '

Loading…

' + : (meta.overview ? '

' + esc(meta.overview) + '

' + : '

No synopsis available.

'); + } + + var genres = (meta.genres || []).slice(0, 4).join(' · '); + 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('') + ''; + extra = (genres ? '
' + esc(genres) + '
' : '') + + (watch ? '
' + watch + '
' : '') + 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 @@ '
'; if (isFail(d.status) && d.error) facts += '
Error' + esc(d.error) + '
'; - // big actions var btns = []; if (d.media_id && !isYt) btns.push(''); @@ -251,31 +295,42 @@ else if (isFail(d.status)) btns.push(''); var actions = btns.length ? '
' + btns.join('') + '
' : ''; - var syn = loading ? '

Loading…

' - : (overview ? '

' + esc(overview) + '

' - : (isYt ? '' : '

No synopsis available.

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