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)
|
logger.exception("youtube channel detail failed for %r", channel_id)
|
||||||
return jsonify({"success": False, "error": "Could not load channel"}), 500
|
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"])
|
@bp.route("/youtube/wishlist/add", methods=["POST"])
|
||||||
def video_youtube_wishlist_add():
|
def video_youtube_wishlist_add():
|
||||||
"""Wish specific videos (per-video add from the channel page). Body:
|
"""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):
|
def _ydl_opts(limit, db=None):
|
||||||
opts = {
|
opts = {
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
|
|
@ -164,17 +176,46 @@ def _ydl_opts(limit, db=None):
|
||||||
"playlistend": int(limit),
|
"playlistend": int(limit),
|
||||||
"user_agent": _UA,
|
"user_agent": _UA,
|
||||||
}
|
}
|
||||||
# Reuse the music client's cookie convention so age/region-gated channels work.
|
opts.update(_cookie_opts())
|
||||||
try:
|
|
||||||
from config.settings import config_manager
|
|
||||||
cb = config_manager.get("youtube.cookies_browser", "")
|
|
||||||
if cb:
|
|
||||||
opts["cookiesfrombrowser"] = (cb,)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return 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):
|
def _extract(url, opts, ydl_factory=None):
|
||||||
"""Run yt-dlp's extract_info; isolated for test injection. Returns a dict or 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)
|
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.)"""
|
for the detail page's per-video toggle.)"""
|
||||||
return self.remove_youtube_from_wishlist("video", video_id)
|
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:
|
def youtube_wishlist_counts(self) -> dict:
|
||||||
"""{'channel': n distinct channels, 'video': n videos} in the wishlist."""
|
"""{'channel': n distinct channels, 'video': n videos} in the wishlist."""
|
||||||
conn = self._get_connection()
|
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}
|
assert r.get_json()["counts"] == {"channel": 1, "video": 1}
|
||||||
r = client.post("/api/video/youtube/wishlist/remove", json={"scope": "video", "source_id": "solo1"})
|
r = client.post("/api/video/youtube/wishlist/remove", json={"scope": "video", "source_id": "solo1"})
|
||||||
assert r.get_json()["counts"] == {"channel": 0, "video": 0}
|
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
|
return {"entries": [{"id": "v", "title": "t"}]} # no channel_id/id
|
||||||
|
|
||||||
assert yt.resolve_channel("@x", ydl_factory=_NoId) is None
|
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-hero { flex-direction: column; align-items: flex-start; gap: 16px; padding-top: 90px; }
|
||||||
.vc-name { font-size: 28px; }
|
.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 synEl = group && group.querySelector('[data-vwsh-syn]');
|
||||||
var castEl = group && group.querySelector('[data-vwsh-cast]');
|
var castEl = group && group.querySelector('[data-vwsh-cast]');
|
||||||
if (!synEl || !castEl) return;
|
if (!synEl || !castEl) return;
|
||||||
// YouTube: a video carries its own description; there's no cast and no
|
// YouTube: no cast. A selected video shows its FULL metadata — description,
|
||||||
// tmdb episode endpoint, so just paint the selected video's synopsis.
|
// 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 (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 = '';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
var si = state.showInfo[tmdb] || {};
|
var si = state.showInfo[tmdb] || {};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue