fix: fix channel videos and playlists on searches

Channel videos are now encapsulated in a `lockupViewModel`.

There is 3 types of content that can be inside a `lockupViewModel`:

- LOCKUP_CONTENT_TYPE_VIDEO
- LOCKUP_CONTENT_TYPE_PLAYLIST
- LOCKUP_CONTENT_TYPE_PODCAST

This commit parses `LOCKUP_CONTENT_TYPE_VIDEO` and `LOCKUP_CONTENT_TYPE_PLAYLIST` types
to fix videos in channels and playlists in channels

There is another bugs to fix:

- Since `LOCKUP_CONTENT_TYPE_PODCAST` needs to be handled differently compared to `LOCKUP_CONTENT_TYPE_PLAYLIST`, podcasts of a channel do not appear in the list of podcasts (Example channel with podcasts: https://www.youtube.com/@GrantSanderson/podcasts)
- The playlists of a channel need a better parsing for it's channel hyperlink, since the channel hyperlink is not actually the channel, it's the updated date of the playlist or just "View full playlist"

PRs to fix those issues are welcome!
This commit is contained in:
Fijxu 2026-05-21 21:26:53 -04:00
parent 99390d065d
commit 07b5ed8972
No known key found for this signature in database
GPG Key ID: 32C1DDF333EDA6A4

View File

@ -630,13 +630,14 @@ private module Parsers
end end
end end
# Parses an InnerTube lockupViewModel into a SearchPlaylist. # Parses an InnerTube lockupViewModel into a SearchPlaylist or a SearchVideo
# Returns nil when the given object is not a lockupViewModel. # Returns nil when the given object is not a lockupViewModel.
# #
# This structure is present since November 2024 on the "podcasts" and # This structure is present since November 2024 on the "podcasts" and
# "playlists" tabs of the channel page. It is usually encapsulated in either # "playlists" tabs of the channel page. It is usually encapsulated in either
# a richItemRenderer or a richGridRenderer. # a richItemRenderer or a richGridRenderer.
# #
# Since 2026-05-21, now channel videos are encapsulated in a lockupViewModel.
module LockupViewModelParser module LockupViewModelParser
extend self extend self
include BaseParser include BaseParser
@ -648,58 +649,108 @@ private module Parsers
end end
private def parse_internal(item_contents, author_fallback) private def parse_internal(item_contents, author_fallback)
playlist_id = item_contents["contentId"].as_s content_type = item_contents["contentType"].as_s
thumbnail_view_model = item_contents.dig( if content_type == "LOCKUP_CONTENT_TYPE_VIDEO"
"contentImage", "thumbnailViewModel" thumbnail_view_model = item_contents.dig(
) "contentImage", "thumbnailViewModel"
)
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s video_id = item_contents["contentId"].as_s
# This complicated sequences tries to extract the following data structure: metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
# "overlays": [{ title = metadata.dig("title", "content").as_s
# "thumbnailOverlayBadgeViewModel": { # Contains the views of the video and the published time of the video.
# "thumbnailBadges": [{ metadata_parts = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows", 0, "metadataParts")
# "thumbnailBadgeViewModel": {
# "text": "430 episodes", view_count_text = metadata_parts.dig(0, "text", "content").as_s
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT" view_count = short_text_to_number(view_count_text || "0")
# } published = metadata_parts.dig?(1, "text", "content").try { |t| decode_date(t.as_s) } || Time.local
# }]
# } author_verified = has_verified_badge?(item_contents["ownerBadges"]?)
# }]
# return SearchVideo.new({
# NOTE: this simplistic `.to_i` conversion might not work on larger title: title,
# playlists and hasn't been tested. id: video_id,
video_count = thumbnail_view_model.dig("overlays").as_a author: author_fallback.name,
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a) ucid: author_fallback.id,
.flatten published: published,
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node| views: view_count,
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) } description_html: "",
length_seconds: 0,
premiere_timestamp: Time.unix(0),
author_verified: false,
author_thumbnail: nil,
badges: VideoBadges::None,
}) })
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false) elsif content_type == "LOCKUP_CONTENT_TYPE_PODCAST"
# TODO
else # If it's a podcast, it's content_type would be "LOCKUP_CONTENT_TYPE_PODCAST"
thumbnail_view_model = item_contents.dig(
"contentImage", "collectionThumbnailViewModel",
"primaryThumbnail", "thumbnailViewModel"
)
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
metadata = item_contents.dig("metadata", "lockupMetadataViewModel") playlist_id = item_contents["contentId"].as_s
title = metadata.dig("title", "content").as_s
# TODO: Retrieve "updated" info from metadata parts # This complicated sequences tries to extract the following data structure:
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a # "overlays": [{
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s) # "thumbnailOverlayBadgeViewModel": {
# One of these parts should contain a string like: "Updated 2 days ago" # "thumbnailBadges": [{
# "thumbnailBadgeViewModel": {
# "text": "430 episodes",
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT"
# }
# }]
# }
# }]
#
# 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
.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)
# TODO: Maybe add a button to access the first video of the playlist? metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint") title = metadata.dig("title", "content").as_s
# Available fields: "videoId", "playlistId", "params"
return SearchPlaylist.new({ metadata_parts = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows", 0, "metadataParts", 0)
title: title,
id: playlist_id, if author_info = metadata_parts["text"]?
author: author_fallback.name, author = author_info["content"].as_s
ucid: author_fallback.id, author_id = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
video_count: video_count || -1, .try &.as_s || author_fallback.id
videos: [] of SearchPlaylistVideo, else
thumbnail: thumbnail, author = author_fallback.name
author_verified: false, author_id = author_fallback.id
}) end
# TODO: Retrieve "updated" info from metadata parts
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
# 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"
return SearchPlaylist.new({
title: title,
id: playlist_id,
author: author,
ucid: author_id,
video_count: video_count || -1,
videos: [] of SearchPlaylistVideo,
thumbnail: thumbnail,
author_verified: false,
})
end
end end
def self.parser_name def self.parser_name