fix(playlists): fall back to playlistHeaderRenderer when sidebar is absent

YouTube stopped returning sidebar.playlistSidebarRenderer for many
playlists; extract the same fields from header.playlistHeaderRenderer
instead to prevent 500 errors on /playlist pages.

Also fix the -1 video count on lockupViewModel playlist cards: make
badge text matching case-insensitive and add a numeric-prefix fallback
so counts are recovered even when YouTube changes the badge wording.
This commit is contained in:
Richard Lora 2026-05-24 02:23:50 +00:00
parent 347f84c276
commit b426f74ba5
2 changed files with 73 additions and 39 deletions

View File

@ -345,8 +345,35 @@ def fetch_playlist(plid : String)
initial_data = YoutubeAPI.browse("VL" + plid, params: "")
playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items")
raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer
header_renderer = initial_data.dig?("header", "playlistHeaderRenderer")
raise InfoException.new("Could not extract playlist data.") if playlist_sidebar_renderer.nil? && header_renderer.nil?
views = 0_i64
updated = Time.utc
video_count = 0
subtitle = extract_text(header_renderer.try &.["subtitle"]?)
# stats parsing is the same format in both sidebar and header renderers
stats_source = playlist_sidebar_renderer.try(&.dig?(0, "playlistSidebarPrimaryInfoRenderer", "stats")) ||
header_renderer.try(&.["stats"]?)
stats_source.try &.as_a.each do |stat|
text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
next if !text
if text.includes? "video"
video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "episode"
video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "view"
views = text.gsub(/\D/, "").to_i64? || 0_i64
elsif !text.includes? "Pay to watch"
updated = decode_date(text.lchop("Last updated on ").lchop("Updated "))
end
end
if playlist_sidebar_renderer
playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer")
raise InfoException.new("Could not extract playlist info") if !playlist_info
@ -368,27 +395,6 @@ def fetch_playlist(plid : String)
"thumbnail", "thumbnails", 0, "url"
).try &.as_s
views = 0_i64
updated = Time.utc
video_count = 0
subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle"))
playlist_info["stats"]?.try &.as_a.each do |stat|
text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
next if !text
if text.includes? "video"
video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "episode"
video_count = text.gsub(/\D/, "").to_i? || 0
elsif text.includes? "view"
views = text.gsub(/\D/, "").to_i64? || 0_i64
elsif !text.includes? "Pay to watch"
updated = decode_date(text.lchop("Last updated on ").lchop("Updated "))
end
end
if playlist_sidebar_renderer.size < 2
author = ""
author_thumbnail = ""
@ -404,6 +410,27 @@ def fetch_playlist(plid : String)
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || ""
ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
end
else
# Sidebar gone — fall back to playlistHeaderRenderer (YouTube changed response format)
hr = header_renderer.not_nil!
title = extract_text(hr["title"]?) || ""
desc_item = hr["descriptionText"]? || hr["description"]?
description_txt = desc_item.try &.["runs"]?.try &.as_a
.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
description_html = desc_item.try &.["runs"]?.try &.as_a
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
thumbnail = hr.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s
owner_run = hr.dig?("ownerText", "runs", 0)
author = owner_run.try &.["text"]?.try &.as_s || ""
ucid = owner_run.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
author_thumbnail = hr.dig?("channelAvatar", "decoratedAvatarViewModel", "avatar",
"avatarViewModel", "image", "sources", 0, "url").try &.as_s ||
hr.dig?("avatar", "thumbnails", 0, "url").try &.as_s || ""
end
return Playlist.new({
title: title,

View File

@ -675,13 +675,20 @@ private module Parsers
#
# NOTE: this simplistic `.to_i` conversion might not work on larger
# playlists and hasn't been tested.
video_count = thumbnail_view_model.dig("overlays").as_a
all_badge_texts = thumbnail_view_model.dig("overlays").as_a
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
.flatten
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
})
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
.compact_map(&.dig?("thumbnailBadgeViewModel", "text").try &.as_s)
# Prefer badges explicitly labeled "videos" or "episodes" (case-insensitive)
video_count = all_badge_texts
.find { |t| {"episodes", "videos"}.any? { |s| t.downcase.ends_with?(s) } }
.try &.to_i(strict: false)
# Fallback: any badge whose text starts with a digit
video_count ||= all_badge_texts
.find { |t| t[0]?.try &.ascii_number? }
.try &.to_i(strict: false)
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
title = metadata.dig("title", "content").as_s