feat: support for lockupViewModel inside of channels' video feed

this is rare and currently a/b tested
This commit is contained in:
2025-10-18 14:39:55 +02:00
parent 760aaccfff
commit 668e8c32aa
2 changed files with 66 additions and 5 deletions

View File

@@ -45,6 +45,7 @@ import ythdd_struct_parser
# IDEAS: # IDEAS:
# [*] /api/v1/popular returns last requested videos by the IP (serving as multi-device history?) # [*] /api/v1/popular returns last requested videos by the IP (serving as multi-device history?)
# [*] /api/v1/trending returns recently archived videos # [*] /api/v1/trending returns recently archived videos
# [*] produce continuations instead of extracting them
# ---------- # ----------
# NOT PLANNED/MAYBE IN THE FUTURE: # NOT PLANNED/MAYBE IN THE FUTURE:
# [ ] /api/v1/auth/subscriptions (stub? db?) # [ ] /api/v1/auth/subscriptions (stub? db?)
@@ -424,6 +425,7 @@ def videos(data):
if is_mix_or_playlist: if is_mix_or_playlist:
# neither mixes nor playlists are currently supported by the invidious api # neither mixes nor playlists are currently supported by the invidious api
continue 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=[]) lmvm = safeTraverse(y, ['metadata', 'lockupMetadataViewModel'], default=[])
related_entry['videoId'] = safeTraverse(y, ['contentId']) related_entry['videoId'] = safeTraverse(y, ['contentId'])
related_entry['title'] = safeTraverse(lmvm, ['title', 'content']) related_entry['title'] = safeTraverse(lmvm, ['title', 'content'])

View File

@@ -50,6 +50,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
match safeTraverse(list(entry.keys()), [0], default=""): match safeTraverse(list(entry.keys()), [0], default=""):
case "videoRenderer": # represents a video 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 = safeTraverse(entry, ["videoRenderer", "publishedTimeText", "simpleText"], default="now")
published_date = published_date.removeprefix("Streamed ") published_date = published_date.removeprefix("Streamed ")
@@ -118,6 +119,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
# retrieve the main channel's avatar # retrieve the main channel's avatar
avatar_url = safeTraverse(livm, [0, "listItemViewModel", "leadingAccessory", "avatarViewModel", "image", "sources", 0, "url"], default=DEFAULT_AVATAR) avatar_url = safeTraverse(livm, [0, "listItemViewModel", "leadingAccessory", "avatarViewModel", "image", "sources", 0, "url"], default=DEFAULT_AVATAR)
ythdd_globals.print_debug("videoRenderer fired")
return { return {
"type": "video", "type": "video",
"title": safeTraverse(entry, ["videoRenderer", "title", "runs", 0, "text"]), "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? # 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": if playlist_type == "MIX":
# mixes aren't currently supported # mixes aren't currently supported
return 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=[]) meta = safeTraverse(lvm, ["metadata"], default=[])
lmvm = safeTraverse(meta, ["lockupMetadataViewModel", "metadata", "contentMetadataViewModel", "metadataRows"], default=[]) lmvm = safeTraverse(meta, ["lockupMetadataViewModel", "metadata", "contentMetadataViewModel", "metadataRows"], default=[])
thumbnail = safeTraverse(lvm, ["contentImage", "collectionThumbnailViewModel", "primaryThumbnail", "thumbnailViewModel", "image", "sources", -1, "url"], default="no-url?") 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 = safeTraverse(lvm, ["contentImage", "collectionThumbnailViewModel", "primaryThumbnail", "thumbnailViewModel", "overlays", 0, "thumbnailOverlayBadgeViewModel", "thumbnailBadges", 0, "thumbnailBadgeViewModel", "text"], default="0 videos")
length = parseViewsFromViewText(length.split(" ")[0]) 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, # Data from context should be prioritized, thus even if something is found with safeTraverse,
# the parser will ignore it in favour of the context. # 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") 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) ucid = safeTraverse(context, ["author_ucid"], default=ucid)
author = safeTraverse(context, ["author_name"], default=author) author = safeTraverse(context, ["author_name"], default=author)
ythdd_globals.print_debug("lockupViewModel fired (playlist)")
return { return {
"type": "playlist", "type": "playlist",
"title": safeTraverse(meta, ["lockupMetadataViewModel", "title", "content"], default="ythdd: unknown title"), "title": safeTraverse(meta, ["lockupMetadataViewModel", "title", "content"], default="ythdd: unknown title"),
@@ -227,6 +282,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
else: else:
avatar_url = "unknown" avatar_url = "unknown"
ythdd_globals.print_debug("shortsLockupViewModel fired")
return { return {
"type": "video", "type": "video",
"title": title, "title": title,
@@ -269,6 +325,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
published_date = safeTraverse(entry, ["gridVideoRenderer", "publishedTimeText", "simpleText"], default="now") published_date = safeTraverse(entry, ["gridVideoRenderer", "publishedTimeText", "simpleText"], default="now")
published_date = published_date.removeprefix("Streamed ") published_date = published_date.removeprefix("Streamed ")
ythdd_globals.print_debug("gridVideoRenderer fired")
return { return {
"type": "video", "type": "video",
"title": safeTraverse(entry, ["gridVideoRenderer", "title", "simpleText"], default="unknown video title"), "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=[])) description, description_html = parseDescriptionSnippet(safeTraverse(entry, ["channelRenderer", "descriptionSnippet", "runs"], default=[]))
isVerified = ythdd_extractor.isVerified(safeTraverse(entry, ["channelRenderer", "ownerBadges", 0], default=[])) isVerified = ythdd_extractor.isVerified(safeTraverse(entry, ["channelRenderer", "ownerBadges", 0], default=[]))
ythdd_globals.print_debug("channelRenderer fired")
return { return {
"type": "channel", "type": "channel",
"author": safeTraverse(entry, ["channelRenderer", "title", "simpleText"], default="Unknown 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"]) 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) avatars = None if avatar_url is None else ythdd_extractor.generateChannelAvatarsFromUrl(avatar_url)
ythdd_globals.print_debug("playlistVideoRenderer fired")
return { return {
"type": "video", "type": "video",
"title": title, "title": title,
@@ -372,7 +431,7 @@ def parseRenderers(entry: dict, context: dict = {}) -> dict:
} }
case _: case _:
print("received an entry of unknown type:") print("received an entry of unknown type (thus can't be parsed):")
print(entry) print(entry)
print("") print("")
# breakpoint() # breakpoint()