YouTube: pull FULL per-video metadata (lazy) for the wishlist info bar
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/<id> — 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.
This commit is contained in:
parent
5ab3ec90a8
commit
22adc3bc55
7 changed files with 203 additions and 15 deletions
|
|
@ -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/<video_id>", 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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 ? '<span class="vwsh-info-eyebrow">' + esc(yd) + '</span>' : '') +
|
||||
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 ? '<span class="vwsh-info-eyebrow">' + bits.join(' · ') + '</span>' : '') +
|
||||
(desc ? esc(desc) : (sel._ytd === undefined ? 'Loading details…' : 'No description for this video.')) +
|
||||
'<a class="vwsh-yt-watch" href="' + watch + '" target="_blank" rel="noopener">Watch on YouTube ↗</a>';
|
||||
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] || {};
|
||||
|
|
|
|||
Loading…
Reference in a new issue