diff --git a/ythdd_inv_tl.py b/ythdd_inv_tl.py index b5438fd..37d0996 100644 --- a/ythdd_inv_tl.py +++ b/ythdd_inv_tl.py @@ -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']) diff --git a/ythdd_struct_parser.py b/ythdd_struct_parser.py index d8d3242..d49b074 100644 --- a/ythdd_struct_parser.py +++ b/ythdd_struct_parser.py @@ -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()