fix(extractors): distinguish video vs playlist in LockupViewModelParser

Video IDs are always 11 characters; playlists have a playlistId in their
watchEndpoint. Previously all lockupViewModel items were returned as
SearchPlaylist, causing video links on channel pages to route to
/playlist?list= instead of /watch?v=.

Authored-by: NorkzYT <richard@pcscorp.dev>
This commit is contained in:
Richard Lora 2026-05-24 03:55:15 +00:00
parent b426f74ba5
commit fc935d5175

View File

@ -630,11 +630,11 @@ private module Parsers
end
end
# Parses an InnerTube lockupViewModel into a SearchPlaylist.
# Parses an InnerTube lockupViewModel into a SearchPlaylist or SearchVideo.
# Returns nil when the given object is not a lockupViewModel.
#
# This structure is present since November 2024 on the "podcasts" and
# "playlists" tabs of the channel page. It is usually encapsulated in either
# This structure is present since November 2024 on the "podcasts", "playlists",
# and "videos" tabs of the channel page. It is usually encapsulated in either
# a richItemRenderer or a richGridRenderer.
#
module LockupViewModelParser
@ -648,7 +648,16 @@ private module Parsers
end
private def parse_internal(item_contents, author_fallback)
playlist_id = item_contents["contentId"].as_s
content_id = item_contents["contentId"].as_s
# A watchEndpoint with a playlistId means this item is a playlist.
# If it only has a videoId (no playlistId), it's a regular video.
# Fall back to content_id length: video IDs are always exactly 11 chars.
watch_endpoint = item_contents.dig?(
"rendererContext", "commandContext", "onTap",
"innertubeCommand", "watchEndpoint"
)
is_playlist = watch_endpoint.try(&.["playlistId"]?) || content_id.size != 11
thumbnail_view_model = item_contents.dig?(
"contentImage", "collectionThumbnailViewModel",
@ -672,24 +681,11 @@ private module Parsers
# }]
# }
# }]
#
# NOTE: this simplistic `.to_i` conversion might not work on larger
# playlists and hasn't been tested.
all_badge_texts = thumbnail_view_model.dig("overlays").as_a
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
.flatten
.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
@ -698,20 +694,51 @@ private module Parsers
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
# One of these parts should contain a string like: "Updated 2 days ago"
# TODO: Maybe add a button to access the first video of the playlist?
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
# Available fields: "videoId", "playlistId", "params"
if is_playlist
# NOTE: this simplistic `.to_i` conversion might not work on larger
# playlists and hasn't been tested.
return SearchPlaylist.new({
title: title,
id: playlist_id,
author: author_fallback.name,
ucid: author_fallback.id,
video_count: video_count || -1,
videos: [] of SearchPlaylistVideo,
thumbnail: thumbnail,
author_verified: false,
})
# 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)
return SearchPlaylist.new({
title: title,
id: content_id,
author: author_fallback.name,
ucid: author_fallback.id,
video_count: video_count || -1,
videos: [] of SearchPlaylistVideo,
thumbnail: thumbnail,
author_verified: false,
})
else
# Extract duration from a time-format badge (e.g. "5:30", "1:23:45")
length_seconds = all_badge_texts
.find { |t| /^\d+:\d+/.match(t) }
.try { |t| decode_length_seconds(t) } || 0
return SearchVideo.new({
title: title,
id: content_id,
author: author_fallback.name,
ucid: author_fallback.id,
published: Time.unix(0),
views: 0_i64,
description_html: "",
length_seconds: length_seconds,
premiere_timestamp: Time.unix(0),
author_verified: false,
author_thumbnail: nil,
badges: VideoBadges::None,
})
end
end
def self.parser_name