From 2ffed4fa03909550d9fa00647689b4a10bf1b6c2 Mon Sep 17 00:00:00 2001 From: Cameron Radmore Date: Sat, 12 Sep 2026 16:44:41 -0400 Subject: [PATCH 1/5] feat(channel): add support for shows tab --- locales/en-US.json | 1 + src/invidious/channels/playlists.cr | 9 ++++++ src/invidious/frontend/channel_page.cr | 1 + src/invidious/routes/api/v1/channels.cr | 29 ++++++++++++++++++ src/invidious/routes/channels.cr | 22 +++++++++++++- src/invidious/routing.cr | 2 ++ src/invidious/views/channel.ecr | 1 + src/invidious/yt_backend/extractors.cr | 39 +++++++++++++++++++++++++ 8 files changed, 103 insertions(+), 1 deletion(-) diff --git a/locales/en-US.json b/locales/en-US.json index 8032cf987..353bf6d1b 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -501,6 +501,7 @@ "channel_tab_podcasts_label": "Podcasts", "channel_tab_releases_label": "Releases", "channel_tab_courses_label": "Courses", + "channel_tab_shows_label": "Shows", "channel_tab_playlists_label": "Playlists", "channel_tab_community_label": "Community", "channel_tab_posts_label": "Posts", diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index cba1abd9c..3374ee98b 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -53,3 +53,12 @@ def fetch_channel_courses(ucid, author, continuation) end return extract_items(initial_data, author, ucid) end + +def fetch_channel_shows(ucid, author, continuation) + if continuation + initial_data = YoutubeAPI.browse(continuation) + else + initial_data = YoutubeAPI.browse(ucid, params: "EgVzaG93c_IGBAoCYgA%3D") + end + return extract_items(initial_data, author, ucid) +end diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr index 4af3b4f54..2096f90f6 100644 --- a/src/invidious/frontend/channel_page.cr +++ b/src/invidious/frontend/channel_page.cr @@ -8,6 +8,7 @@ module Invidious::Frontend::ChannelPage Podcasts Releases Courses + Shows Playlists Posts Channels diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index b72aa6cf3..d4dc2aea5 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -397,6 +397,35 @@ module Invidious::Routes::API::V1::Channels end end + def self.shows(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_shows(channel.ucid, channel.author, continuation) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + def self.community(env) locale = env.get("preferences").as(Preferences).locale diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index ff8b0f270..30332b46c 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -217,6 +217,26 @@ module Invidious::Routes::Channels templated "channel" end + def self.shows(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_by = "" + sort_options = [] of String + + items, next_continuation = fetch_channel_shows( + channel.ucid, channel.author, continuation + ) + + items = items.select(SearchPlaylist) + items.each(&.author = "") + + selected_tab = Frontend::ChannelPage::TabsAvailable::Shows + templated "channel" + end + def self.community(env) return env.redirect env.request.path.sub("posts", "community") if env.request.path.split("/").last == "posts" @@ -331,7 +351,7 @@ module Invidious::Routes::Channels private KNOWN_TABS = { "home", "videos", "shorts", "streams", "podcasts", - "releases", "courses", "playlists", "community", "channels", "about", + "releases", "courses", "shows", "playlists", "community", "channels", "about", "posts", } diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 12bf5459f..643a8a5ac 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -122,6 +122,7 @@ module Invidious::Routing get "/channel/:ucid/podcasts", Routes::Channels, :podcasts get "/channel/:ucid/releases", Routes::Channels, :releases get "/channel/:ucid/courses", Routes::Channels, :courses + get "/channel/:ucid/shows", Routes::Channels, :shows get "/channel/:ucid/playlists", Routes::Channels, :playlists get "/channel/:ucid/community", Routes::Channels, :community get "/channel/:ucid/posts", Routes::Channels, :community @@ -265,6 +266,7 @@ module Invidious::Routing get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases get "/api/v1/channels/:ucid/courses", {{namespace}}::Channels, :courses + get "/api/v1/channels/:ucid/shows", {{namespace}}::Channels, :shows get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community get "/api/v1/channels/:ucid/posts", {{namespace}}::Channels, :community diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 686de6bd4..5193ccf2d 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -12,6 +12,7 @@ when .podcasts? then "/channel/#{ucid}/podcasts" when .releases? then "/channel/#{ucid}/releases" when .courses? then "/channel/#{ucid}/courses" + when .shows? then "/channel/#{ucid}/shows" else "/channel/#{ucid}" end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b2226e74d..efd89580b 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -15,6 +15,7 @@ private ITEM_PARSERS = { Parsers::VideoRendererParser, Parsers::ChannelRendererParser, Parsers::GridPlaylistRendererParser, + Parsers::GridShowRenderer, Parsers::PlaylistRendererParser, Parsers::CategoryRendererParser, Parsers::ReelItemRendererParser, @@ -338,6 +339,43 @@ private module Parsers end end + module GridShowRenderer + extend self + include BaseParser + + def process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["gridShowRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def parse_internal(item_contents, author_fallback) + title = extract_text(item_contents["title"]) || "" + plid = item_contents.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s.gsub("/show/VL", "").split("?sbp")[0] || "" + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) + + video_count_node = item_contents.dig?("thumbnaiext") + video_count_node ||= item_contents.dig?("thumbnailOverlays", 0, "thumbnailOverlayBottomPanelRenderer", "text", "runs", 0, "text") + + video_count = video_count_node.try(&.as_s.to_i) || 0 + playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents.dig("thumbnailRenderer", "showCustomThumbnailRenderer")) + + SearchPlaylist.new({ + title: title, + id: plid, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, + author_verified: author_verified, + }) + end + + def self.parser_name + return {{@type.name}} + end + end # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer # # A playlistRenderer renders a playlist to click on within the YouTube and Invidious UI. It is **not** the playlist itself. @@ -522,6 +560,7 @@ private module Parsers child ||= ReelItemRendererParser.process(item_contents, author_fallback) child ||= PlaylistRendererParser.process(item_contents, author_fallback) child ||= LockupViewModelParser.process(item_contents, author_fallback) + child ||= GridShowRenderer.process(item_contents, author_fallback) child ||= ShortsLockupViewModelParser.process(item_contents, author_fallback) return child end From df298c1161eb510784c6c9b60187dd1020ed4775 Mon Sep 17 00:00:00 2001 From: Cameron Radmore Date: Sat, 12 Sep 2026 20:09:38 -0400 Subject: [PATCH 2/5] feat: add support for /show/ urls --- src/invidious/routes/playlists.cr | 1 + src/invidious/routing.cr | 1 + 2 files changed, 2 insertions(+) diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 3a633d507..bdd114fc3 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -387,6 +387,7 @@ module Invidious::Routes::Playlists referer = get_referer(env) plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") + plid ||= env.params.url["id"]?.try &.[2..] if !plid return env.redirect "/" end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 643a8a5ac..4ada2dcc6 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -178,6 +178,7 @@ module Invidious::Routing def register_yt_playlist_routes get "/playlist", Routes::Playlists, :show + get "/show/:id", Routes::Playlists, :show get "/mix", Routes::Playlists, :mix get "/watch_videos", Routes::Playlists, :watch_videos end From f3f045299ba91ea54536b52217bdba41934f2559 Mon Sep 17 00:00:00 2001 From: Cameron Radmore Date: Sat, 12 Sep 2026 21:32:19 -0400 Subject: [PATCH 3/5] chore(docs): add comment for GridShowRenderer and fix format --- src/invidious/yt_backend/extractors.cr | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index efd89580b..c2a7bb41c 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -339,6 +339,15 @@ private module Parsers end end + # Parses a InnerTube gridShowRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridShowRenderer + # + # A gridShowRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI. + # It is **not** the playlist itself. + # + # See specs for example. + # + # `gridShowRenderer`s can be found on the "shows" tab of channels. + # module GridShowRenderer extend self include BaseParser @@ -376,6 +385,7 @@ private module Parsers return {{@type.name}} end end + # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer # # A playlistRenderer renders a playlist to click on within the YouTube and Invidious UI. It is **not** the playlist itself. From f7139af4ef01195c59765e6efda4988e7f556c52 Mon Sep 17 00:00:00 2001 From: Cameron Radmore Date: Mon, 14 Sep 2026 17:39:30 -0400 Subject: [PATCH 4/5] fix: check that shows tabs exists before trying to fetch/parse --- src/invidious/routes/channels.cr | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 30332b46c..2a2a69c4c 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -223,6 +223,10 @@ module Invidious::Routes::Channels locale, user, subscriptions, continuation, ucid, channel = data + if !channel.tabs.includes? "shows" + return env.redirect "/channel/#{channel.ucid}" + end + sort_by = "" sort_options = [] of String From 6969e61f5e83138a71d1a2420e83504ccdd702e7 Mon Sep 17 00:00:00 2001 From: Cameron Radmore Date: Fri, 18 Sep 2026 13:38:29 -0400 Subject: [PATCH 5/5] fix: Remove `thumbnaiext` This property doesn't exist. Not exactly sure how it happened but I'm guessing I combined thumbnailOverlays and text somehow when I was doing a find and replace (Ctrl + H) and then formatting json with prettier --- src/invidious/yt_backend/extractors.cr | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index c2a7bb41c..37bd2a665 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -363,10 +363,7 @@ private module Parsers plid = item_contents.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s.gsub("/show/VL", "").split("?sbp")[0] || "" author_verified = has_verified_badge?(item_contents["ownerBadges"]?) - video_count_node = item_contents.dig?("thumbnaiext") - video_count_node ||= item_contents.dig?("thumbnailOverlays", 0, "thumbnailOverlayBottomPanelRenderer", "text", "runs", 0, "text") - - video_count = video_count_node.try(&.as_s.to_i) || 0 + video_count = item_contents.dig?("thumbnailOverlays", 0, "thumbnailOverlayBottomPanelRenderer", "text", "runs", 0, "text").try(&.as_s.to_i) || 0 playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents.dig("thumbnailRenderer", "showCustomThumbnailRenderer")) SearchPlaylist.new({