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
|
limit = 200
|
||||||
try:
|
try:
|
||||||
db = get_video_db()
|
db = get_video_db()
|
||||||
pl = yt.resolve_playlist("https://www.youtube.com/playlist?list=" + playlist_id,
|
pl = yt.resolve_playlist("https://www.youtube.com/playlist?list=" + playlist_id, limit=60)
|
||||||
limit=max(1, min(500, limit)))
|
|
||||||
if not pl:
|
if not pl:
|
||||||
return jsonify({"success": False, "error": "Playlist not found"}), 404
|
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 []
|
vids = pl.get("videos") or []
|
||||||
ids = [v.get("youtube_id") for v in vids]
|
ids = [v.get("youtube_id") for v in vids]
|
||||||
wished = db.youtube_video_wish_state(ids)
|
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)}
|
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):
|
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
|
"""A channel's video catalog (list + approximate dates) via InnerTube, up to
|
||||||
``pages`` pages: [{youtube_id, title, thumbnail_url, published_at}]. Like
|
``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"}]}
|
"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, "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, "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)
|
# 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()
|
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
|
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",
|
lambda *a, **k: {"playlist_id": "PL1", "title": "Trailers",
|
||||||
"videos": [{"youtube_id": "a", "title": "A"},
|
"videos": [{"youtube_id": "a", "title": "A"},
|
||||||
{"youtube_id": "b", "title": "B"}]})
|
{"youtube_id": "b", "title": "B"}]})
|
||||||
|
monkeypatch.setattr(ytmod, "innertube_playlist_catalog", lambda *a, **k: {"videos": [], "total": None})
|
||||||
# 'a' is wished → should hydrate wished=True
|
# 'a' is wished → should hydrate wished=True
|
||||||
videoapi._video_db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "X"}, [{"youtube_id": "a", "title": "A"}])
|
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()
|
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 cat[0]["title"] == "Title v1" and cat[2]["published_at"] == "2026-06-12"
|
||||||
assert seq["n"] == 2 # stopped when the token ran out
|
assert seq["n"] == 2 # stopped when the token ran out
|
||||||
assert yt.innertube_channel_catalog("not-uc", post=lambda p: None) == []
|
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 || [];
|
var vids = pl.videos || [];
|
||||||
ytVideoMap = {};
|
ytVideoMap = {};
|
||||||
vids.forEach(function (v) { ytVideoMap[v.youtube_id] = v; });
|
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),
|
var season = { season_number: 1, title: 'Videos', poster_url: ytProx(pl.thumbnail_url),
|
||||||
episode_owned: 0, episode_total: vids.length, episodes: vids.map(ytEpisodeOf) };
|
episode_owned: 0, episode_total: vids.length, episodes: vids.map(ytEpisodeOf) };
|
||||||
return { kind: 'playlist', source: 'youtube', id: pl.playlist_id, title: pl.title || 'Playlist',
|
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,
|
backdrop_url: ytProx(pl.thumbnail_url), has_backdrop: !!pl.thumbnail_url,
|
||||||
genres: pl.channel_title ? [pl.channel_title] : [], handle: null,
|
genres: pl.channel_title ? [pl.channel_title] : [], handle: null,
|
||||||
subscriber_count: null, view_count: null, video_count: pl.video_count,
|
subscriber_count: null, view_count: null, video_count: pl.video_count,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue