Playlist count: show the TRUE total + load ~200 (was capped at ~100 via yt-dlp)
A big playlist read '97' because yt-dlp flat caps playlist extraction at ~100 and its playlist_count is often unset, so we showed the FETCHED count. Now the detail endpoint overlays the InnerTube playlist browse (browseId VL<id>): curator-ordered videos up to ~200 (vs yt-dlp's ~100) AND the real count from the header's numVideosText (e.g. '512 videos'). The billboard shows the true total; when the list is partial it says 'Showing the first N of TOTAL videos (YouTube limits playlist browsing).' Live-validated (Veritasium uploads: total 512, 200 loaded). 103 tests green. Honest cap: YouTube's browse only exposes ~200 of a playlist (page 2 returns no continuation), so very large playlists show the right COUNT but list ~200.
This commit is contained in:
parent
5506155abe
commit
8e56273c9d
5 changed files with 101 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<id>) 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue