feat: support for lockupViewModel inside of channels' video feed
this is rare and currently a/b tested
This commit is contained in:
@@ -45,6 +45,7 @@ import ythdd_struct_parser
|
||||
# IDEAS:
|
||||
# [*] /api/v1/popular returns last requested videos by the IP (serving as multi-device history?)
|
||||
# [*] /api/v1/trending returns recently archived videos
|
||||
# [*] produce continuations instead of extracting them
|
||||
# ----------
|
||||
# NOT PLANNED/MAYBE IN THE FUTURE:
|
||||
# [ ] /api/v1/auth/subscriptions (stub? db?)
|
||||
@@ -424,6 +425,7 @@ def videos(data):
|
||||
if is_mix_or_playlist:
|
||||
# neither mixes nor playlists are currently supported by the invidious api
|
||||
continue
|
||||
# note: this model is similar, but not identical to the one in ythdd_struct_parser. perhaps they can be both handled in the struct parser some time.
|
||||
lmvm = safeTraverse(y, ['metadata', 'lockupMetadataViewModel'], default=[])
|
||||
related_entry['videoId'] = safeTraverse(y, ['contentId'])
|
||||
related_entry['title'] = safeTraverse(lmvm, ['title', 'content'])
|
||||
|
||||
@@ -50,6 +50,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
match safeTraverse(list(entry.keys()), [0], default=""):
|
||||
|
||||
case "videoRenderer": # represents a video
|
||||
# as of october 2025 slowly phased out in favor of lockupViewModel(?)
|
||||
|
||||
published_date = safeTraverse(entry, ["videoRenderer", "publishedTimeText", "simpleText"], default="now")
|
||||
published_date = published_date.removeprefix("Streamed ")
|
||||
@@ -118,6 +119,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
# retrieve the main channel's avatar
|
||||
avatar_url = safeTraverse(livm, [0, "listItemViewModel", "leadingAccessory", "avatarViewModel", "image", "sources", 0, "url"], default=DEFAULT_AVATAR)
|
||||
|
||||
ythdd_globals.print_debug("videoRenderer fired")
|
||||
return {
|
||||
"type": "video",
|
||||
"title": safeTraverse(entry, ["videoRenderer", "title", "runs", 0, "text"]),
|
||||
@@ -149,15 +151,67 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
|
||||
# modify the premiere timestamp afterwards here?
|
||||
|
||||
case "lockupViewModel": # represents playlists/mixes
|
||||
case "lockupViewModel": # represents playlists/mixes (and videos since october 2025)
|
||||
# related videos lvms are handled in ythdd_inv_tl.videos()
|
||||
|
||||
playlist_type = safeTraverse(entry, ["lockupViewModel", "contentImage", "collectionThumbnailViewModel", "primaryThumbnail", "thumbnailViewModel", "overlays", 0, "thumbnailOverlayBadgeViewModel", "thumbnailBadges", 0, "thumbnailBadgeViewModel", "icon", "sources", 0, "clientResource", "imageName"], default="PLAYLISTS")
|
||||
lvm = entry["lockupViewModel"]
|
||||
playlist_type = safeTraverse(lvm, ["contentImage", "collectionThumbnailViewModel", "primaryThumbnail", "thumbnailViewModel", "overlays", 0, "thumbnailOverlayBadgeViewModel", "thumbnailBadges", 0, "thumbnailBadgeViewModel", "icon", "sources", 0, "clientResource", "imageName"], default="")
|
||||
|
||||
if playlist_type == "MIX":
|
||||
# mixes aren't currently supported
|
||||
return
|
||||
|
||||
lvm = entry["lockupViewModel"]
|
||||
if not playlist_type:
|
||||
# struct represents a video
|
||||
ythdd_globals.print_debug("lockupViewModel fired (not a playlist). this is an a/b test; any following errors stem from it.")
|
||||
|
||||
lmvm = safeTraverse(lvm, ['metadata', 'lockupMetadataViewModel'], default={})
|
||||
video_id = safeTraverse(lvm, ['contentId'])
|
||||
|
||||
author_name = safeTraverse(context, ["author_name"], default="Unknown author")
|
||||
author_ucid = safeTraverse(context, ["author_ucid"], default="UNKNOWNCHANNELID")
|
||||
verified = safeTraverse(context, ["verified"], default=False) # TODO: check if this can be retrieved here
|
||||
avatar_url = safeTraverse(context, ["avatar"], default=DEFAULT_AVATAR)
|
||||
|
||||
title = safeTraverse(lmvm, ["title", "content"], default="No title")
|
||||
video_metadata = safeTraverse(lmvm, ["metadata", "contentMetadataViewModel", "metadataRows", 0, "metadataParts"], default=[])
|
||||
view_count_text = safeTraverse(video_metadata, [0, "text", "content"], default="0 views")
|
||||
published_date = safeTraverse(video_metadata, [1, "text", "content"], default="now")
|
||||
length_text = safeTraverse(lvm, ["contentImage", "thumbnailViewModel", "overlays", ..., "thumbnailBottomOverlayViewModel", "badges", -1, "thumbnailBadgeViewModel", "text"], default="0:0")
|
||||
view_count = parseViewsFromViewText(view_count_text)
|
||||
length = parseLengthFromTimeBadge(length_text)
|
||||
|
||||
resp = {
|
||||
"type": "video",
|
||||
"title": title,
|
||||
"videoId": video_id,
|
||||
"author": author_name,
|
||||
"authorId": author_ucid,
|
||||
"authorUrl": "/channel/" + author_ucid,
|
||||
"authorVerified": verified, # TODO
|
||||
"authorThumbnails": ythdd_extractor.generateChannelAvatarsFromUrl(avatar_url),
|
||||
"videoThumbnails": ythdd_struct_builder.genThumbs(video_id),
|
||||
"description": "", # can't be retrieved from lockupViewModel
|
||||
"descriptionHtml": "",
|
||||
"viewCount": view_count,
|
||||
"viewCountText": view_count_text,
|
||||
"published": int(dateparser.parse(published_date).timestamp()), # sadly best we can do, invidious does this too
|
||||
"publishedText": published_date,
|
||||
"lengthSeconds": length,
|
||||
"liveNow": False, # can't be live if it's in creator's video feed
|
||||
"premium": False, # todo: check this
|
||||
"isUpcoming": False,
|
||||
"isNew": False,
|
||||
"is4k": False,
|
||||
"is8k": False,
|
||||
"isVr180": False,
|
||||
"isVr360": False,
|
||||
"is3d": False,
|
||||
"hasCaptions": False
|
||||
}
|
||||
return resp
|
||||
|
||||
# struct represents a playlist
|
||||
meta = safeTraverse(lvm, ["metadata"], default=[])
|
||||
lmvm = safeTraverse(meta, ["lockupMetadataViewModel", "metadata", "contentMetadataViewModel", "metadataRows"], default=[])
|
||||
thumbnail = safeTraverse(lvm, ["contentImage", "collectionThumbnailViewModel", "primaryThumbnail", "thumbnailViewModel", "image", "sources", -1, "url"], default="no-url?")
|
||||
@@ -168,7 +222,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
length = safeTraverse(lvm, ["contentImage", "collectionThumbnailViewModel", "primaryThumbnail", "thumbnailViewModel", "overlays", 0, "thumbnailOverlayBadgeViewModel", "thumbnailBadges", 0, "thumbnailBadgeViewModel", "text"], default="0 videos")
|
||||
length = parseViewsFromViewText(length.split(" ")[0])
|
||||
|
||||
# Turns out for some responses we do some data, while not on others.
|
||||
# Turns out for some responses we do have some data, while not on others.
|
||||
# Data from context should be prioritized, thus even if something is found with safeTraverse,
|
||||
# the parser will ignore it in favour of the context.
|
||||
ucid = safeTraverse(lmvm, [0, "metadataParts", 0, "text", "commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId"], default="UNKNOWNCHANNELID")
|
||||
@@ -176,6 +230,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
ucid = safeTraverse(context, ["author_ucid"], default=ucid)
|
||||
author = safeTraverse(context, ["author_name"], default=author)
|
||||
|
||||
ythdd_globals.print_debug("lockupViewModel fired (playlist)")
|
||||
return {
|
||||
"type": "playlist",
|
||||
"title": safeTraverse(meta, ["lockupMetadataViewModel", "title", "content"], default="ythdd: unknown title"),
|
||||
@@ -227,6 +282,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
else:
|
||||
avatar_url = "unknown"
|
||||
|
||||
ythdd_globals.print_debug("shortsLockupViewModel fired")
|
||||
return {
|
||||
"type": "video",
|
||||
"title": title,
|
||||
@@ -269,6 +325,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
published_date = safeTraverse(entry, ["gridVideoRenderer", "publishedTimeText", "simpleText"], default="now")
|
||||
published_date = published_date.removeprefix("Streamed ")
|
||||
|
||||
ythdd_globals.print_debug("gridVideoRenderer fired")
|
||||
return {
|
||||
"type": "video",
|
||||
"title": safeTraverse(entry, ["gridVideoRenderer", "title", "simpleText"], default="unknown video title"),
|
||||
@@ -303,6 +360,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
description, description_html = parseDescriptionSnippet(safeTraverse(entry, ["channelRenderer", "descriptionSnippet", "runs"], default=[]))
|
||||
isVerified = ythdd_extractor.isVerified(safeTraverse(entry, ["channelRenderer", "ownerBadges", 0], default=[]))
|
||||
|
||||
ythdd_globals.print_debug("channelRenderer fired")
|
||||
return {
|
||||
"type": "channel",
|
||||
"author": safeTraverse(entry, ["channelRenderer", "title", "simpleText"], default="Unknown channel"),
|
||||
@@ -353,6 +411,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
avatar_url = safeTraverse(entry, ["playlistVideoRenderer", "thumbnailOverlays", ..., "thumbnailOverlayAvatarStackViewModel", "avatarStack", "avatarStackViewModel", "avatars", 0, "avatarViewModel", "image", "sources", 0, "url"])
|
||||
avatars = None if avatar_url is None else ythdd_extractor.generateChannelAvatarsFromUrl(avatar_url)
|
||||
|
||||
ythdd_globals.print_debug("playlistVideoRenderer fired")
|
||||
return {
|
||||
"type": "video",
|
||||
"title": title,
|
||||
@@ -372,7 +431,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
|
||||
}
|
||||
|
||||
case _:
|
||||
print("received an entry of unknown type:")
|
||||
print("received an entry of unknown type (thus can't be parsed):")
|
||||
print(entry)
|
||||
print("")
|
||||
# breakpoint()
|
||||
|
||||
Reference in New Issue
Block a user