From 87750e24644b04d0c33279dcb3b7d17441bebad0 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 08:51:53 -0700 Subject: [PATCH] Channel page (backend): channel tags/views + playlists + playlist videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shape_channel now carries tags (≤12) + channel view_count for the stats ribbon. - Refactored entry-shaping into _shape_entries (shared by uploads + playlists). - channel_playlists(id) → playlists (as 'seasons'); playlist_videos(id) → its videos. API: GET /youtube/playlists/, GET /youtube/playlist/ (with per-video wished hydration). 79 youtube+api tests green. --- api/video/youtube.py | 26 +++++++++++++++++++ core/video/youtube.py | 51 ++++++++++++++++++++++++++++++++----- tests/test_video_api.py | 17 +++++++++++++ tests/test_video_youtube.py | 39 ++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/api/video/youtube.py b/api/video/youtube.py index 5155c09c..e50195ce 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -183,6 +183,32 @@ def register_routes(bp): logger.exception("youtube video detail failed for %r", video_id) return jsonify({"success": False, "error": "Could not load video"}), 500 + @bp.route("/youtube/playlists/", methods=["GET"]) + def video_youtube_playlists(channel_id): + """The channel's playlists (rendered as 'seasons' on the channel page).""" + from core.video import youtube as yt + try: + return jsonify({"success": True, "playlists": yt.channel_playlists(channel_id)}) + except Exception: + logger.exception("youtube playlists failed for %r", channel_id) + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/youtube/playlist/", methods=["GET"]) + def video_youtube_playlist(playlist_id): + """A playlist's videos (lazy-loaded when a playlist section expands), with + per-video wished flags so the toggles hydrate.""" + from . import get_video_db + from core.video import youtube as yt + try: + vids = yt.playlist_videos(playlist_id) + wished = get_video_db().youtube_video_wish_state([v.get("youtube_id") for v in vids]) + for v in vids: + v["wished"] = v.get("youtube_id") in wished + return jsonify({"success": True, "videos": vids}) + except Exception: + logger.exception("youtube playlist failed for %r", playlist_id) + return jsonify({"success": False, "error": "Failed"}), 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 d6aeafe1..1217b67b 100644 --- a/core/video/youtube.py +++ b/core/video/youtube.py @@ -118,17 +118,16 @@ def _entry_date(e): return None -def shape_channel(info, limit=30): - """Map a yt-dlp channel info dict to our followable-channel shape.""" - info = info or {} - entries = [e for e in (info.get("entries") or []) if isinstance(e, dict)] - videos = [] - for e in entries[:limit]: +def _shape_entries(entries, limit): + """Flat playlist/channel entries → our lightweight video shape (id-less and + null entries dropped).""" + out = [] + for e in [x for x in (entries or []) if isinstance(x, dict)][:limit]: vid = e.get("id") if not vid: continue dur = e.get("duration") - videos.append({ + out.append({ "youtube_id": vid, "title": e.get("title") or "", "published_at": _entry_date(e), @@ -137,7 +136,13 @@ def shape_channel(info, limit=30): "view_count": e.get("view_count"), "description": e.get("description"), }) + return out + +def shape_channel(info, limit=30): + """Map a yt-dlp channel info dict to our followable-channel shape.""" + info = info or {} + videos = _shape_entries(info.get("entries"), limit) handle = info.get("uploader_id") or info.get("channel_id_handle") if handle and not str(handle).startswith("@"): handle = None # uploader_id is sometimes the UC id, not a handle @@ -150,6 +155,8 @@ def shape_channel(info, limit=30): "banner_url": _thumb_by_id(thumbs, "banner"), "description": info.get("description"), "subscriber_count": info.get("channel_follower_count"), + "view_count": info.get("view_count"), + "tags": (info.get("tags") or [])[:12], "video_count": info.get("playlist_count") or len(videos), "videos": videos, } @@ -241,3 +248,33 @@ def resolve_channel(raw, limit=30, ydl_factory=None, db=None): return None shaped = shape_channel(info, limit) return shaped if shaped.get("youtube_id") else None + + +def channel_playlists(channel_id, limit=50, ydl_factory=None): + """The channel's playlists (flat) → [{playlist_id, title, video_count, + thumbnail_url}] — rendered as "seasons" on the channel page. Lazy-loaded.""" + if not channel_id: + return [] + url = "https://www.youtube.com/channel/" + str(channel_id) + "/playlists" + info = _extract(url, _ydl_opts(limit), ydl_factory) + out = [] + for e in [x for x in ((info or {}).get("entries") or []) if isinstance(x, dict)][:limit]: + pid = e.get("id") + if not pid: + continue + out.append({ + "playlist_id": pid, + "title": e.get("title") or "", + "video_count": e.get("playlist_count") or e.get("video_count"), + "thumbnail_url": _best_thumb(e.get("thumbnails")) or e.get("thumbnail"), + }) + return out + + +def playlist_videos(playlist_id, limit=50, ydl_factory=None): + """A playlist's videos (flat), in the same shape as channel uploads.""" + if not playlist_id: + return [] + url = "https://www.youtube.com/playlist?list=" + str(playlist_id) + info = _extract(url, _ydl_opts(limit), ydl_factory) + return _shape_entries((info or {}).get("entries"), limit) diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 0ac355b3..9bd9c824 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -684,3 +684,20 @@ def test_img_proxy_allows_youtube_cdn_only(tmp_path, monkeypatch): # still SSRF-safe: arbitrary hosts rejected assert client.get("/api/video/img?u=https://evil.example.com/x.jpg").status_code == 404 assert client.get("/api/video/img?u=http://yt3.googleusercontent.com/x").status_code == 404 # http + + +def test_youtube_playlists_and_playlist_videos(tmp_path, monkeypatch): + client, videoapi = _make_client(tmp_path) + import core.video.youtube as ytmod + monkeypatch.setattr(ytmod, "channel_playlists", + lambda cid: [{"playlist_id": "PL1", "title": "Trailers", "video_count": 3, "thumbnail_url": None}]) + pls = client.get("/api/video/youtube/playlists/UCx").get_json() + assert pls["success"] is True and pls["playlists"][0]["playlist_id"] == "PL1" + + monkeypatch.setattr(ytmod, "playlist_videos", + lambda pid: [{"youtube_id": "a", "title": "A"}, {"youtube_id": "b", "title": "B"}]) + # 'a' is wished → should hydrate wished=True + videoapi._video_db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "X"}, [{"youtube_id": "a", "title": "A"}]) + pv = client.get("/api/video/youtube/playlist/PL1").get_json() + wished = {v["youtube_id"]: v["wished"] for v in pv["videos"]} + assert wished == {"a": True, "b": False} diff --git a/tests/test_video_youtube.py b/tests/test_video_youtube.py index 2a72db13..b9f64f6d 100644 --- a/tests/test_video_youtube.py +++ b/tests/test_video_youtube.py @@ -246,3 +246,42 @@ def test_video_detail_accepts_watch_url_and_handles_failure(): raise RuntimeError("unavailable") assert yt.video_detail("x", ydl_factory=_Boom) is None assert yt.video_detail("", ydl_factory=_FakeVideoYDL) is None + + +# ── channel tags + playlists ───────────────────────────────────────────────── + +def test_shape_channel_includes_tags_and_views(): + info = dict(_flat_info()); info["tags"] = ["gaming", "ps5"]; info["view_count"] = 9_000_000 + out = yt.shape_channel(info) + assert out["tags"] == ["gaming", "ps5"] and out["view_count"] == 9_000_000 + + +class _PlaylistsYDL: + def __init__(self, opts): pass + def __enter__(self): return self + def __exit__(self, *a): return False + def extract_info(self, url, download=False): + return {"entries": [ + {"id": "PL1", "title": "Trailers", "playlist_count": 12, + "thumbnails": [{"url": "http://t/pl1.jpg", "width": 320, "height": 180}]}, + {"id": "PL2", "title": "Interviews", "video_count": 5}, + None, {"title": "no id"}, + ]} + + +def test_channel_playlists_shapes_and_filters(): + pls = yt.channel_playlists("UCx", ydl_factory=_PlaylistsYDL) + assert [p["playlist_id"] for p in pls] == ["PL1", "PL2"] + assert pls[0]["title"] == "Trailers" and pls[0]["video_count"] == 12 + assert pls[0]["thumbnail_url"] == "http://t/pl1.jpg" + + +def test_playlist_videos_reuses_entry_shape(): + vids = yt.playlist_videos("PL1", ydl_factory=_FakeYDL) # _FakeYDL returns _flat_info() + assert [v["youtube_id"] for v in vids] == ["vid1", "vid2", "vid3"] + assert vids[0]["published_at"] == "2023-11-14" + + +def test_channel_playlists_empty_on_no_id(): + assert yt.channel_playlists("", ydl_factory=_PlaylistsYDL) == [] + assert yt.playlist_videos("", ydl_factory=_FakeYDL) == []