diff --git a/api/video/youtube.py b/api/video/youtube.py index ecf12fe6..7bbbda2a 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -378,10 +378,19 @@ def register_routes(bp): limit = 200 try: db = get_video_db() - pl = yt.resolve_playlist("https://www.youtube.com/playlist?list=" + playlist_id, - limit=max(1, min(500, limit))) + pl = yt.resolve_playlist("https://www.youtube.com/playlist?list=" + playlist_id, limit=60) if not pl: return jsonify({"success": False, "error": "Playlist not found"}), 404 + # yt-dlp flat caps playlists at ~100 and its count is often unset. Overlay + # the InnerTube catalog (curator order, up to ~200) + the TRUE header count. + try: + cat = yt.innertube_playlist_catalog(playlist_id) + if cat.get("videos"): + pl["videos"] = cat["videos"] + if cat.get("total"): + pl["video_count"] = cat["total"] + except Exception: + pass vids = pl.get("videos") or [] ids = [v.get("youtube_id") for v in vids] wished = db.youtube_video_wish_state(ids) diff --git a/core/video/youtube.py b/core/video/youtube.py index 5669d852..d1acba08 100644 --- a/core/video/youtube.py +++ b/core/video/youtube.py @@ -614,6 +614,70 @@ def innertube_channel_videos_page(channel_id, continuation=None, now=None, post= return {"videos": videos, "continuation": innertube_continuation(j)} +def _innertube_playlist_total(obj): + """The playlist's TRUE video count from its browse header ('512 videos'), or None. + Reliable even when yt-dlp's playlist_count isn't populated.""" + for t in _json_find_all(obj, "numVideosText", []): + m = re.search(r"([\d,]+)", "".join(_json_all_strings(t, []))) + if m: + try: + return int(m.group(1).replace(",", "")) + except ValueError: + pass + return None + + +def innertube_playlist_page(playlist_id, continuation=None, now=None, post=None): + """One InnerTube page of a PLAYLIST's videos (browseId VL) in the curator's + order: {"videos": [...], "continuation": token|None, "total": N|None}. Fixes + yt-dlp flat's ~100-item cap (browse pages to ~200) and exposes the real count. + Same item shape as innertube_channel_videos_page.""" + pid = str(playlist_id or "").strip() + if not continuation and not pid: + return {"videos": [], "continuation": None, "total": None} + if now is None: + now = datetime.now(timezone.utc).date() + payload = ({"context": _INNERTUBE_CTX, "continuation": continuation} if continuation + else {"context": _INNERTUBE_CTX, "browseId": "VL" + pid}) + try: + j = _innertube_post(payload, post) + except Exception: + return {"videos": [], "continuation": None, "total": None} + if not isinstance(j, dict): + return {"videos": [], "continuation": None, "total": None} + videos = [] + for it in innertube_parse_video_items(j): + it["published_at"] = relative_to_date(it.pop("relative", None), now) + videos.append(it) + return {"videos": videos, "continuation": innertube_continuation(j), "total": _innertube_playlist_total(j)} + + +def innertube_playlist_catalog(playlist_id, pages=40, now=None, post=None): + """A playlist's video list (curator order) + TRUE total via InnerTube: + {"videos": [...], "total": N|None}. yt-dlp flat caps at ~100; the browse pages + to ~200 and the header carries the real count. {} on failure.""" + pid = str(playlist_id or "").strip() + if not pid: + return {"videos": [], "total": None} + if now is None: + now = datetime.now(timezone.utc).date() + out, seen, token, total = [], set(), None, None + for _ in range(max(1, pages)): + page = innertube_playlist_page(pid, continuation=token, now=now, post=post) + if total is None: + total = page.get("total") + for v in page.get("videos") or []: + if v.get("youtube_id") and v["youtube_id"] not in seen: + seen.add(v["youtube_id"]) + out.append(v) + token = page.get("continuation") + if not token: + break + if post is None: + time.sleep(_INNERTUBE_DELAY) + return {"videos": out, "total": total} + + def innertube_channel_catalog(channel_id, pages=_INNERTUBE_PAGES, now=None, post=None): """A channel's video catalog (list + approximate dates) via InnerTube, up to ``pages`` pages: [{youtube_id, title, thumbnail_url, published_at}]. Like diff --git a/tests/test_video_api.py b/tests/test_video_api.py index ba39aafd..3e9e1d38 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -698,6 +698,7 @@ def test_youtube_playlist_follow_detail_and_watchlist(tmp_path, monkeypatch): "thumbnail_url": "t", "videos": [{"youtube_id": "a", "title": "A"}, {"youtube_id": "b", "title": "B"}]} monkeypatch.setattr(ytmod, "parse_playlist_id", lambda u: "PLx" if "list=" in (u or "") else None) monkeypatch.setattr(ytmod, "resolve_playlist", lambda *a, **k: dict(_PL)) + monkeypatch.setattr(ytmod, "innertube_playlist_catalog", lambda *a, **k: {"videos": [], "total": None}) # resolve detects a playlist link → returns a playlist (not a channel) r = client.get("/api/video/youtube/resolve?url=https://youtube.com/playlist?list=PLx").get_json() assert r["success"] and r["playlist"]["playlist_id"] == "PLx" and r["following"] is False @@ -778,6 +779,7 @@ def test_youtube_playlists_and_playlist_videos(tmp_path, monkeypatch): lambda *a, **k: {"playlist_id": "PL1", "title": "Trailers", "videos": [{"youtube_id": "a", "title": "A"}, {"youtube_id": "b", "title": "B"}]}) + monkeypatch.setattr(ytmod, "innertube_playlist_catalog", lambda *a, **k: {"videos": [], "total": None}) # '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() diff --git a/tests/test_video_youtube.py b/tests/test_video_youtube.py index 29f99894..eeb025e2 100644 --- a/tests/test_video_youtube.py +++ b/tests/test_video_youtube.py @@ -561,3 +561,22 @@ def test_innertube_channel_catalog_pages_until_no_token(): assert cat[0]["title"] == "Title v1" and cat[2]["published_at"] == "2026-06-12" assert seq["n"] == 2 # stopped when the token ran out assert yt.innertube_channel_catalog("not-uc", post=lambda p: None) == [] + + +def test_innertube_playlist_page_keeps_order_and_true_count(): + now = date(2026, 6, 17) + page = _page([_lk_item("a", "1 day ago"), _lk_item("b", "2 days ago")], token="TOK") + page["header"] = {"numVideosText": {"runs": [{"text": "512"}, {"text": " videos"}]}} # the real total + got = yt.innertube_playlist_page("PLx", now=now, post=lambda p: page) + assert [v["youtube_id"] for v in got["videos"]] == ["a", "b"] # curator order preserved + assert got["total"] == 512 and got["continuation"] == "TOK" + # catalog pages until no token, surfacing the header count + pages = [page, _page([_lk_item("c", "3 days ago")])] # 2nd page: no token → stop + seq = {"n": 0} + + def post(p): + i = seq["n"]; seq["n"] += 1; return pages[i] + + cat = yt.innertube_playlist_catalog("PLx", now=now, post=post) + assert [v["youtube_id"] for v in cat["videos"]] == ["a", "b", "c"] and cat["total"] == 512 + assert yt.innertube_playlist_catalog("", post=lambda p: None) == {"videos": [], "total": None} diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 12c0f706..97a676b1 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -1570,10 +1570,14 @@ var vids = pl.videos || []; ytVideoMap = {}; vids.forEach(function (v) { ytVideoMap[v.youtube_id] = v; }); + var total = pl.video_count || vids.length; + // YouTube only exposes ~200 of a playlist via browsing — be honest when partial. + var note = total > vids.length + ? 'Showing the first ' + vids.length + ' of ' + total + ' videos (YouTube limits playlist browsing).' : ''; var season = { season_number: 1, title: 'Videos', poster_url: ytProx(pl.thumbnail_url), episode_owned: 0, episode_total: vids.length, episodes: vids.map(ytEpisodeOf) }; return { kind: 'playlist', source: 'youtube', id: pl.playlist_id, title: pl.title || 'Playlist', - overview: '', poster_url: ytProx(pl.thumbnail_url), has_poster: !!pl.thumbnail_url, + overview: note, poster_url: ytProx(pl.thumbnail_url), has_poster: !!pl.thumbnail_url, backdrop_url: ytProx(pl.thumbnail_url), has_backdrop: !!pl.thumbnail_url, genres: pl.channel_title ? [pl.channel_title] : [], handle: null, subscriber_count: null, view_count: null, video_count: pl.video_count,