From 22adc3bc5569721160104484c9ffcfd18c4e8c60 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 08:22:45 -0700 Subject: [PATCH] YouTube: pull FULL per-video metadata (lazy) for the wishlist info bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flat channel listing can't return descriptions, so selecting a video showed 'No description'. Now we do a non-flat single-video extract on demand — the way the TV nebula lazy-loads guest stars: - core: video_detail(id) / shape_video() — description, views, likes, duration, tags, channel, webpage_url (full extract, yt-dlp injectable for tests). - API: GET /youtube/video/ — returns it AND backfills the description onto the wishlist row (set_wishlist_video_overview), so re-opening is instant. - Wishlist info bar (youtube): on select, lazy-fetch → real description + eyebrow (date · duration · views) + a 'Watch on YouTube' link; cached on the episode object. 'Loading details…' while in flight. Mirrors the episode lazy-load + art-backfill patterns. 73 youtube+api tests green; brace/CSS balanced; music untouched. --- api/video/youtube.py | 21 ++++++++++ core/video/youtube.py | 57 +++++++++++++++++++++++---- database/video_database.py | 16 ++++++++ tests/test_video_api.py | 25 ++++++++++++ tests/test_video_youtube.py | 58 ++++++++++++++++++++++++++++ webui/static/video/video-side.css | 6 +++ webui/static/video/video-wishlist.js | 35 +++++++++++++---- 7 files changed, 203 insertions(+), 15 deletions(-) diff --git a/api/video/youtube.py b/api/video/youtube.py index 30b6ab42..29b3509f 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -156,6 +156,27 @@ def register_routes(bp): logger.exception("youtube channel detail failed for %r", channel_id) return jsonify({"success": False, "error": "Could not load channel"}), 500 + @bp.route("/youtube/video/", methods=["GET"]) + def video_youtube_video_detail(video_id): + """Full metadata for one video (description, views, likes, duration, tags) — + fetched lazily when a video is selected. Persists the description onto its + wishlist row so re-opening is instant.""" + from . import get_video_db + from core.video import youtube as yt + try: + v = yt.video_detail(video_id) + if not v or not v.get("youtube_id"): + return jsonify({"success": False, "error": "Video not found"}), 404 + if v.get("description"): + try: + get_video_db().set_wishlist_video_overview(v["youtube_id"], v["description"]) + except Exception: + pass # persistence is best-effort; the detail still returns + return jsonify({"success": True, "video": v}) + except Exception: + logger.exception("youtube video detail failed for %r", video_id) + return jsonify({"success": False, "error": "Could not load video"}), 500 + @bp.route("/youtube/wishlist/add", methods=["POST"]) def video_youtube_wishlist_add(): """Wish specific videos (per-video add from the channel page). Body: diff --git a/core/video/youtube.py b/core/video/youtube.py index 38a28e05..d6aeafe1 100644 --- a/core/video/youtube.py +++ b/core/video/youtube.py @@ -155,6 +155,18 @@ def shape_channel(info, limit=30): } +def _cookie_opts(): + """The music client's cookie convention so age/region-gated content works.""" + try: + from config.settings import config_manager + cb = config_manager.get("youtube.cookies_browser", "") + if cb: + return {"cookiesfrombrowser": (cb,)} + except Exception: + pass + return {} + + def _ydl_opts(limit, db=None): opts = { "quiet": True, @@ -164,17 +176,46 @@ def _ydl_opts(limit, db=None): "playlistend": int(limit), "user_agent": _UA, } - # Reuse the music client's cookie convention so age/region-gated channels work. - try: - from config.settings import config_manager - cb = config_manager.get("youtube.cookies_browser", "") - if cb: - opts["cookiesfrombrowser"] = (cb,) - except Exception: - pass + opts.update(_cookie_opts()) return opts +def shape_video(info): + """Map yt-dlp's FULL single-video info dict to our rich video shape (the data + flat-listing can't give: description, views, likes, duration, tags).""" + info = info or {} + dur = info.get("duration") + return { + "youtube_id": info.get("id"), + "title": info.get("title") or "", + "description": info.get("description"), + "duration_seconds": int(dur) if isinstance(dur, (int, float)) else None, + "view_count": info.get("view_count"), + "like_count": info.get("like_count"), + "published_at": _entry_date(info), + "thumbnail_url": _best_thumb(info.get("thumbnails")) or info.get("thumbnail"), + "channel_title": info.get("channel") or info.get("uploader"), + "channel_id": info.get("channel_id"), + "webpage_url": info.get("webpage_url") or ("https://www.youtube.com/watch?v=" + (info.get("id") or "")), + "tags": info.get("tags") or [], + } + + +def video_detail(video_id, ydl_factory=None): + """Full metadata for ONE video (non-flat extract) — done lazily on click, the + way the TV nebula lazy-loads guest stars. Accepts a raw id or a watch URL.""" + if not video_id: + return None + vid = str(video_id).strip() + url = vid if vid.startswith("http") else "https://www.youtube.com/watch?v=" + vid + opts = {"quiet": True, "no_warnings": True, "skip_download": True, "user_agent": _UA} + opts.update(_cookie_opts()) + info = _extract(url, opts, ydl_factory) + if not info or not info.get("id"): + return None + return shape_video(info) + + def _extract(url, opts, ydl_factory=None): """Run yt-dlp's extract_info; isolated for test injection. Returns a dict or None.""" factory = ydl_factory or (yt_dlp.YoutubeDL if yt_dlp else None) diff --git a/database/video_database.py b/database/video_database.py index 34e97c75..3e95b2ce 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1959,6 +1959,22 @@ class VideoDatabase: for the detail page's per-video toggle.)""" return self.remove_youtube_from_wishlist("video", video_id) + def set_wishlist_video_overview(self, video_id, overview) -> bool: + """Persist a video's lazily-fetched description onto its wishlist row (only + if it doesn't already have one) so re-opening is instant — mirrors the + episode-overview backfill. Returns True if a row was updated.""" + if not overview or not video_id: + return False + conn = self._get_connection() + try: + cur = conn.execute( + "UPDATE video_wishlist SET episode_overview=? WHERE kind='video' AND source_id=? " + "AND (episode_overview IS NULL OR episode_overview='')", (overview, str(video_id))) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + def youtube_wishlist_counts(self) -> dict: """{'channel': n distinct channels, 'video': n videos} in the wishlist.""" conn = self._get_connection() diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 6f9605f2..3b274aa3 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -642,3 +642,28 @@ def test_youtube_wishlist_add_single_video(tmp_path): assert r.get_json()["counts"] == {"channel": 1, "video": 1} r = client.post("/api/video/youtube/wishlist/remove", json={"scope": "video", "source_id": "solo1"}) assert r.get_json()["counts"] == {"channel": 0, "video": 0} + + +def test_youtube_video_detail_endpoint_persists_description(tmp_path, monkeypatch): + client, videoapi = _make_client(tmp_path) + import core.video.youtube as ytmod + full = {"youtube_id": "v1", "title": "State of Play", "description": "Full synopsis here.", + "duration_seconds": 600, "view_count": 5000, "like_count": 100, + "published_at": "2024-06-01", "webpage_url": "https://youtu.be/v1", "tags": []} + monkeypatch.setattr(ytmod, "video_detail", lambda vid: dict(full)) + # wish the video first (empty overview), then the detail call should backfill it + videoapi._video_db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "X"}, + [{"youtube_id": "v1", "title": "SoP"}]) + d = client.get("/api/video/youtube/video/v1").get_json() + assert d["success"] is True and d["video"]["description"] == "Full synopsis here." + # persisted onto the wishlist row + grp = videoapi._video_db.query_youtube_wishlist()["items"][0] + ov = grp["seasons"][0]["episodes"][0]["overview"] + assert ov == "Full synopsis here." + + +def test_youtube_video_detail_404(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path) + import core.video.youtube as ytmod + monkeypatch.setattr(ytmod, "video_detail", lambda vid: None) + assert client.get("/api/video/youtube/video/nope").status_code == 404 diff --git a/tests/test_video_youtube.py b/tests/test_video_youtube.py index 2673a62d..2a72db13 100644 --- a/tests/test_video_youtube.py +++ b/tests/test_video_youtube.py @@ -188,3 +188,61 @@ def test_resolve_channel_none_when_info_has_no_channel_id(): return {"entries": [{"id": "v", "title": "t"}]} # no channel_id/id assert yt.resolve_channel("@x", ydl_factory=_NoId) is None + + +# ── video_detail (full single-video metadata) ──────────────────────────────── + +def _video_info(): + return { + "id": "vid1", "title": "State of Play", "description": "Everything announced.", + "duration": 3725, "view_count": 1_250_000, "like_count": 42_000, + "timestamp": 1_700_000_000, "channel": "PlayStation", "channel_id": "UCPlay", + "webpage_url": "https://www.youtube.com/watch?v=vid1", + "tags": ["ps5", "trailer"], + "thumbnails": [{"url": "http://t/hi.jpg", "width": 1280, "height": 720}], + } + + +class _FakeVideoYDL: + last_url = None + + def __init__(self, opts): + self.opts = opts + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def extract_info(self, url, download=False): + _FakeVideoYDL.last_url = url + assert download is False + # full (non-flat) extraction: extract_flat must NOT be set + assert "extract_flat" not in self.opts + return _video_info() + + +def test_video_detail_pulls_full_metadata(): + out = yt.video_detail("vid1", ydl_factory=_FakeVideoYDL) + assert out["youtube_id"] == "vid1" + assert out["description"] == "Everything announced." + assert out["duration_seconds"] == 3725 + assert out["view_count"] == 1_250_000 and out["like_count"] == 42_000 + assert out["published_at"] == "2023-11-14" + assert out["channel_title"] == "PlayStation" and out["channel_id"] == "UCPlay" + assert out["webpage_url"] == "https://www.youtube.com/watch?v=vid1" + assert out["tags"] == ["ps5", "trailer"] + # a raw id is turned into a watch URL + assert _FakeVideoYDL.last_url == "https://www.youtube.com/watch?v=vid1" + + +def test_video_detail_accepts_watch_url_and_handles_failure(): + yt.video_detail("https://www.youtube.com/watch?v=abc", ydl_factory=_FakeVideoYDL) + assert _FakeVideoYDL.last_url == "https://www.youtube.com/watch?v=abc" + + class _Boom(_FakeVideoYDL): + def extract_info(self, url, download=False): + raise RuntimeError("unavailable") + assert yt.video_detail("x", ydl_factory=_Boom) is None + assert yt.video_detail("", ydl_factory=_FakeVideoYDL) is None diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 3c43ce55..88a729a6 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2868,3 +2868,9 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vc-hero { flex-direction: column; align-items: flex-start; gap: 16px; padding-top: 90px; } .vc-name { font-size: 28px; } } + +/* "Watch on YouTube" link inside the wishlist info-bar synopsis (YouTube videos) */ +.vwsh-nebula .vwsh-yt-watch { display: inline-block; margin-top: 10px; font-size: 12px; font-weight: 800; + color: #ff6b6b; text-decoration: none; padding: 6px 13px; border-radius: 999px; + border: 1px solid rgba(255, 59, 59, 0.35); transition: all 0.15s ease; } +.vwsh-nebula .vwsh-yt-watch:hover { background: rgba(255, 59, 59, 0.14); color: #fff; } diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js index c8fad6c7..6bcc2a93 100644 --- a/webui/static/video/video-wishlist.js +++ b/webui/static/video/video-wishlist.js @@ -168,15 +168,36 @@ var synEl = group && group.querySelector('[data-vwsh-syn]'); var castEl = group && group.querySelector('[data-vwsh-cast]'); if (!synEl || !castEl) return; - // YouTube: a video carries its own description; there's no cast and no - // tmdb episode endpoint, so just paint the selected video's synopsis. + // YouTube: no cast. A selected video shows its FULL metadata — description, + // duration, views — fetched lazily from yt-dlp on first select (like the + // TV nebula lazy-loads guest stars), then cached on the episode object. if (group.getAttribute('data-vwsh-source') === 'youtube') { - if (sel) { - var yd = fmtDate(sel.air_date); - synEl.innerHTML = (yd ? '' + esc(yd) + '' : '') + - esc(sel.overview || 'No description for this video.'); - } else { synEl.innerHTML = ''; } castEl.innerHTML = ''; + if (!sel) { synEl.innerHTML = ''; return; } + var vd = sel._ytd; // undefined = not fetched, null = fetched/empty + var bits = []; + var yd = fmtDate((vd && vd.published_at) || sel.air_date); if (yd) bits.push(esc(yd)); + if (vd && window.VideoYoutube) { + var du = VideoYoutube.fmtDuration(vd.duration_seconds); if (du) bits.push(esc(du)); + var vc = VideoYoutube.compactCount(vd.view_count); if (vc) bits.push(esc(vc) + ' views'); + } + var desc = (vd && vd.description) || sel.overview || ''; + var watch = 'https://www.youtube.com/watch?v=' + encodeURIComponent(sel.source_id); + synEl.innerHTML = + (bits.length ? '' + bits.join(' · ') + '' : '') + + (desc ? esc(desc) : (sel._ytd === undefined ? 'Loading details…' : 'No description for this video.')) + + 'Watch on YouTube ↗'; + if (sel._ytd === undefined) { + sel._ytd = null; // mark in-flight so we fetch once + fetch('/api/video/youtube/video/' + encodeURIComponent(sel.source_id), { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + sel._ytd = (d && d.video) || null; + var cur = group.querySelector('.vwsh-epc--sel'); + if (cur && cur.getAttribute('data-src-id') === sel.source_id) renderInfoBar(group, tmdb, sel); + }) + .catch(function () { sel._ytd = null; }); + } return; } var si = state.showInfo[tmdb] || {};