From 129869132c3ba77bc6eaaea31617b3e416c6293c Mon Sep 17 00:00:00 2001 From: EazyHood <209367218+EazyHood@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:47:32 -0500 Subject: [PATCH 1/7] channels: support the carouselHeaderRenderer channel header --- spec/invidious/channels/about_spec.cr | 251 ++++++++++++++++++++++++++ src/invidious/channels/about.cr | 113 ++++++++++-- 2 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 spec/invidious/channels/about_spec.cr diff --git a/spec/invidious/channels/about_spec.cr b/spec/invidious/channels/about_spec.cr new file mode 100644 index 000000000..66f8e0294 --- /dev/null +++ b/spec/invidious/channels/about_spec.cr @@ -0,0 +1,251 @@ +require "../../../src/invidious/exceptions" +require "../../spec_helper" + +Spectator.describe "extract_auto_generated_channel_header" do + it "parses the carouselHeaderRenderer shape" do + # ex: https://www.youtube.com/channel/UCEgdi0XIXXZ-qJOFPf4JSKw (Sports) + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": [ + { + "carouselItemRenderer": { + "carouselItems": [] + } + }, + { + "topicChannelDetailsRenderer": { + "title": {"simpleText": "Sports"}, + "avatar": { + "thumbnails": [ + {"url": "//yt3.example/topic-avatar", "width": 88, "height": 88} + ] + }, + "subtitle": {"simpleText": "74.3M subscribers"} + } + } + ] + } + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCEgdi0XIXXZ-qJOFPf4JSKw") + + expect(header[:author]).to eq("Sports") + expect(header[:author_url]).to eq("https://www.youtube.com/channel/UCEgdi0XIXXZ-qJOFPf4JSKw") + expect(header[:author_thumbnail]).to eq("//yt3.example/topic-avatar") + expect(header[:banner]).to be_nil + expect(header[:description_node]).to be_nil + expect(header[:tags]).to be_empty + expect(header[:is_family_friendly]).to be_true + end + + it "finds the topic details regardless of their position in the carousel" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": [ + { + "topicChannelDetailsRenderer": { + "title": {"simpleText": "Sports"}, + "avatar": {"thumbnails": [{"url": "//yt3.example/first"}]} + } + }, + { + "carouselItemRenderer": {"carouselItems": []} + } + ] + } + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCEgdi0XIXXZ-qJOFPf4JSKw") + + expect(header[:author]).to eq("Sports") + expect(header[:author_thumbnail]).to eq("//yt3.example/first") + end + + it "falls back to the ucid when the carousel carries no topic details" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": [ + {"carouselItemRenderer": {"carouselItems": []}} + ] + } + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCEgdi0XIXXZ-qJOFPf4JSKw") + + expect(header[:author]).to eq("UCEgdi0XIXXZ-qJOFPf4JSKw") + expect(header[:author_thumbnail]).to eq("") + end + + it "parses the current pageHeaderRenderer shape" do + # ex: https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg (Gaming) + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "pageHeaderRenderer": { + "pageTitle": "Gaming", + "content": { + "pageHeaderViewModel": { + "title": { + "dynamicTextViewModel": { + "text": {"content": "Gaming"} + } + }, + "animatedImage": { + "contentPreviewImageViewModel": { + "image": { + "sources": [ + {"url": "//yt3.example/avatar", "width": 48, "height": 48} + ] + } + } + } + } + } + } + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCOpNcN46UbXVtpKMrmU4Abg") + + expect(header[:author]).to eq("Gaming") + expect(header[:author_url]).to eq("https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg") + expect(header[:author_thumbnail]).to eq("//yt3.example/avatar") + end + + it "preserves the legacy interactiveTabbedHeaderRenderer shape" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "interactiveTabbedHeaderRenderer": { + "title": {"simpleText": "Legacy gaming"}, + "boxArt": {"thumbnails": [{"url": "//yt3.example/legacy-avatar"}]}, + "banner": {"thumbnails": [{"url": "//yt3.example/legacy-banner"}]}, + "description": {"simpleText": "A legacy description"}, + "badges": [ + {"metadataBadgeRenderer": {"label": "Gaming"}} + ] + } + }, + "microformat": { + "microformatDataRenderer": { + "urlCanonical": "https://www.youtube.com/channel/UCLegacy" + } + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCLegacy") + + expect(header[:author]).to eq("Legacy gaming") + expect(header[:author_url]).to eq("https://www.youtube.com/channel/UCLegacy") + expect(header[:author_thumbnail]).to eq("//yt3.example/legacy-avatar") + expect(header[:banner]).to eq("//yt3.example/legacy-banner") + expect(header[:tags]).to eq(["Gaming"]) + end + + it "keeps an explicit familySafe: false" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "pageHeaderRenderer": {"pageTitle": "Gaming"} + }, + "microformat": { + "microformatDataRenderer": {"familySafe": false} + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCOpNcN46UbXVtpKMrmU4Abg") + + expect(header[:is_family_friendly]).to be_false + end + + it "raises when the header shape is unknown" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "someFutureHeaderRenderer": {} + } + } + JSON + + expect do + extract_auto_generated_channel_header(initdata, "UCUnknown") + end.to raise_error(InfoException) + end +end + +Spectator.describe "extract_topic_channel_details" do + it "returns nil when the payload carries no carousel header" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "pageHeaderRenderer": {"pageTitle": "Gaming"} + } + } + JSON + + expect(extract_topic_channel_details(initdata)).to be_nil + end + + it "returns nil when the carousel carries no topic details" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": [ + {"carouselItemRenderer": {"carouselItems": []}} + ] + } + } + } + JSON + + expect(extract_topic_channel_details(initdata)).to be_nil + end + + it "exposes the subscriber count carried by the subtitle" do + # `subscriberCountText` is part of the renderer but comes back null, so the + # count is only available as free text in the subtitle. + # ex: https://www.youtube.com/channel/UCEgdi0XIXXZ-qJOFPf4JSKw (Sports) + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": [ + {"carouselItemRenderer": {"carouselItems": []}}, + { + "topicChannelDetailsRenderer": { + "title": {"simpleText": "Sports"}, + "avatar": {"thumbnails": [{"url": "//yt3.example/topic-avatar"}]}, + "subscriberCountText": null, + "subtitle": {"simpleText": "74.3M subscribers"} + } + } + ] + } + } + } + JSON + + details = extract_topic_channel_details(initdata) + + expect(details).not_to be_nil + sub_text = details.not_nil!.dig("subtitle", "simpleText").as_s + expect(sub_text).to eq("74.3M subscribers") + expect(short_text_to_number(sub_text.split(" ")[0])).to eq(74_300_000_i64) + end +end diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 247f628a0..5e1d9bb50 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -19,6 +19,84 @@ record AboutChannel, verified : Bool, is_age_gated : Bool +# Topic channels keep the channel details inside one of the carousel entries. +# The position of that entry varies between channels, so it is looked up by key +# rather than by index. +# ex: https://www.youtube.com/channel/UCEgdi0XIXXZ-qJOFPf4JSKw +def extract_topic_channel_details(initdata : Hash(String, JSON::Any)) : JSON::Any? + contents = initdata.dig?("header", "carouselHeaderRenderer", "contents") + return nil if contents.nil? + + contents.as_a + .find { |content| !content.dig?("topicChannelDetailsRenderer").nil? } + .try &.dig?("topicChannelDetailsRenderer") +end + +# Auto-generated channels come with one of three header shapes. This is only +# reached when the payload has no `metadata` object, i.e. when the regular +# `channelMetadataRenderer` path is not available. +def extract_auto_generated_channel_header(initdata : Hash(String, JSON::Any), ucid : String) + banner = nil + description_node = nil + tags = [] of String + + if header = initdata.dig?("header", "interactiveTabbedHeaderRenderer") + author = header.dig("title", "simpleText").as_s + author_url = initdata.dig("microformat", "microformatDataRenderer", "urlCanonical").as_s + author_thumbnail = header.dig("boxArt", "thumbnails", 0, "url").as_s + + banner = header.dig?("banner", "thumbnails").try &.[-1]?.try &.["url"].as_s? + + description_base_node = header["description"] + # some channels have the description in a simpleText + # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/ + description_node = description_base_node.dig?("simpleText") || description_base_node + + tags = header.dig?("badges") + .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String + elsif header = initdata.dig?("header", "pageHeaderRenderer") + # ex: https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg + view_model = header.dig?("content", "pageHeaderViewModel") + + author = view_model.try &.dig?("title", "dynamicTextViewModel", "text", "content").try &.as_s + author ||= header.dig?("pageTitle").try &.as_s + author ||= ucid + + author_url = "https://www.youtube.com/channel/#{ucid}" + + author_thumbnail = view_model.try &.dig?("image", "decoratedAvatarViewModel", "avatar", "avatarViewModel", "image", "sources", 0, "url").try &.as_s + author_thumbnail ||= view_model.try &.dig?("animatedImage", "contentPreviewImageViewModel", "image", "sources", 0, "url").try &.as_s + author_thumbnail ||= "" + + banner = view_model.try &.dig?("banner", "imageBannerViewModel", "image", "sources") + .try &.[-1]?.try &.["url"].as_s? + elsif initdata.dig?("header", "carouselHeaderRenderer") + # ex: https://www.youtube.com/channel/UCEgdi0XIXXZ-qJOFPf4JSKw + # This shape carries neither a banner nor a description. + details = extract_topic_channel_details(initdata) + + author = details.try &.dig?("title", "simpleText").try &.as_s || ucid + author_url = "https://www.youtube.com/channel/#{ucid}" + author_thumbnail = details.try &.dig?("avatar", "thumbnails", 0, "url").try &.as_s || "" + else + raise InfoException.new("Could not extract the header of channel #{ucid}") + end + + # `microformat` is absent from these payloads, so a missing flag defaults to + # safe. An explicit `false` is still preserved. + family_safe = initdata.dig?("microformat", "microformatDataRenderer", "familySafe").try(&.as_bool) + + { + author: author, + author_url: author_url, + author_thumbnail: author_thumbnail, + banner: banner, + description_node: description_node, + tags: tags, + is_family_friendly: family_safe.nil? ? true : family_safe, + } +end + def get_about_info(ucid) : AboutChannel begin # Fetch channel information from channel home page @@ -64,21 +142,14 @@ def get_about_info(ucid) : AboutChannel auto_generated = false else if auto_generated - author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s - author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s - author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s - - # Raises a KeyError on failure. - banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? - - description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] - # some channels have the description in a simpleText - # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/ - description_node = description_base_node.dig?("simpleText") || description_base_node - - tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges") - .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String + channel_header = extract_auto_generated_channel_header(initdata, ucid) + author = channel_header[:author] + author_url = channel_header[:author_url] + author_thumbnail = channel_header[:author_thumbnail] + banner = channel_header[:banner] + description_node = channel_header[:description_node] + tags = channel_header[:tags] + is_family_friendly = channel_header[:is_family_friendly] else author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s @@ -103,9 +174,9 @@ def get_about_info(ucid) : AboutChannel description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool end - is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]? # Get the name of the tabs available on this channel tab_names = tabs_json.as_a.compact_map do |entry| @@ -182,6 +253,16 @@ def get_about_info(ucid) : AboutChannel break if sub_count != 0 && !pronouns.nil? end + elsif (topic_details = extract_topic_channel_details(initdata)) + # Topic channels carry the subscriber count as free text in the subtitle, + # ex: "74.3M subscribers". `subscriberCountText` is part of the same + # renderer but comes back null, so it is only used as a first choice. + sub_text = topic_details.dig?("subscriberCountText", "simpleText").try &.as_s + sub_text ||= topic_details.dig?("subtitle", "simpleText").try &.as_s + + if sub_text && sub_text.includes?("subscriber") + sub_count = short_text_to_number(sub_text.split(" ")[0]).to_i32 + end end AboutChannel.new( From c68630813ec028596cbd3f483b79030df1b40fa0 Mon Sep 17 00:00:00 2001 From: EazyHood <209367218+EazyHood@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:10:56 -0500 Subject: [PATCH 2/7] channels: handle incomplete auto-generated payloads --- spec/invidious/channels/about_spec.cr | 29 +++++++++++++++++--- spec/invidious/yt_backend/extractors_spec.cr | 22 +++++++++++++++ src/invidious/channels/about.cr | 10 +++++-- src/invidious/yt_backend/extractors.cr | 3 +- 4 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 spec/invidious/yt_backend/extractors_spec.cr diff --git a/spec/invidious/channels/about_spec.cr b/spec/invidious/channels/about_spec.cr index 66f8e0294..abf218099 100644 --- a/spec/invidious/channels/about_spec.cr +++ b/spec/invidious/channels/about_spec.cr @@ -69,7 +69,7 @@ Spectator.describe "extract_auto_generated_channel_header" do expect(header[:author_thumbnail]).to eq("//yt3.example/first") end - it "falls back to the ucid when the carousel carries no topic details" do + it "raises when the carousel carries no topic details" do initdata = JSON.parse(<<-JSON).as_h { "header": { @@ -82,10 +82,31 @@ Spectator.describe "extract_auto_generated_channel_header" do } JSON - header = extract_auto_generated_channel_header(initdata, "UCEgdi0XIXXZ-qJOFPf4JSKw") + expect do + extract_auto_generated_channel_header(initdata, "UCEgdi0XIXXZ-qJOFPf4JSKw") + end.to raise_error(InfoException) + end - expect(header[:author]).to eq("UCEgdi0XIXXZ-qJOFPf4JSKw") - expect(header[:author_thumbnail]).to eq("") + it "raises when the carousel topic details have no title" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": [ + { + "topicChannelDetailsRenderer": { + "avatar": {"thumbnails": [{"url": "//yt3.example/avatar"}]} + } + } + ] + } + } + } + JSON + + expect do + extract_auto_generated_channel_header(initdata, "UCEgdi0XIXXZ-qJOFPf4JSKw") + end.to raise_error(InfoException) end it "parses the current pageHeaderRenderer shape" do diff --git a/spec/invidious/yt_backend/extractors_spec.cr b/spec/invidious/yt_backend/extractors_spec.cr new file mode 100644 index 000000000..a4c2dc0aa --- /dev/null +++ b/spec/invidious/yt_backend/extractors_spec.cr @@ -0,0 +1,22 @@ +require "../../parsers_helper" + +Spectator.describe "YouTubeTabs" do + it "treats a selected tab without content as empty" do + initdata = JSON.parse(<<-JSON).as_h + { + "contents": { + "twoColumnBrowseResultsRenderer": { + "tabs": [ + {"tabRenderer": {"selected": true}} + ] + } + } + } + JSON + + items, continuation = extract_items(initdata) + + expect(items).to be_empty + expect(continuation).to be_nil + end +end diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 5e1d9bb50..d74414083 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -75,9 +75,15 @@ def extract_auto_generated_channel_header(initdata : Hash(String, JSON::Any), uc # This shape carries neither a banner nor a description. details = extract_topic_channel_details(initdata) - author = details.try &.dig?("title", "simpleText").try &.as_s || ucid + unless details + raise InfoException.new("Could not extract the carousel header of channel #{ucid}") + end + + author = details.dig?("title", "simpleText").try &.as_s + author ||= raise InfoException.new("Could not extract the carousel title of channel #{ucid}") author_url = "https://www.youtube.com/channel/#{ucid}" - author_thumbnail = details.try &.dig?("avatar", "thumbnails", 0, "url").try &.as_s || "" + author_thumbnail = details.dig?("avatar", "thumbnails", 0, "url").try &.as_s + author_thumbnail ||= raise InfoException.new("Could not extract the carousel thumbnail of channel #{ucid}") else raise InfoException.new("Could not extract the header of channel #{ucid}") end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b2226e74d..72c3e1441 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -964,7 +964,8 @@ private module Extractors private def self.extract(target) raw_items = [] of JSON::Any - content = extract_selected_tab(target["tabs"])["content"] + content = extract_selected_tab(target["tabs"])["content"]? + return raw_items if content.nil? if section_list_contents = content.dig?("sectionListRenderer", "contents") raw_items = unpack_section_list(section_list_contents) From 66478817cd5c15c5614f4ce7ec64a802d8eacb11 Mon Sep 17 00:00:00 2001 From: EazyHood <209367218+EazyHood@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:35:12 -0500 Subject: [PATCH 3/7] channels: keep rendering carousel channels without an avatar A missing avatar is a cosmetic gap, not a broken payload: raising on it brings back the 500 this PR is meant to remove. The pageHeaderRenderer branch already falls back to an empty string, so this matches it. Co-Authored-By: Claude Opus 5 --- spec/invidious/channels/about_spec.cr | 23 +++++++++++++++++++++++ src/invidious/channels/about.cr | 6 ++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/spec/invidious/channels/about_spec.cr b/spec/invidious/channels/about_spec.cr index abf218099..55936cbe4 100644 --- a/spec/invidious/channels/about_spec.cr +++ b/spec/invidious/channels/about_spec.cr @@ -109,6 +109,29 @@ Spectator.describe "extract_auto_generated_channel_header" do end.to raise_error(InfoException) end + it "still renders a carousel channel that carries no avatar" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": [ + { + "topicChannelDetailsRenderer": { + "title": {"simpleText": "Sports"} + } + } + ] + } + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCEgdi0XIXXZ-qJOFPf4JSKw") + + expect(header[:author]).to eq("Sports") + expect(header[:author_thumbnail]).to eq("") + end + it "parses the current pageHeaderRenderer shape" do # ex: https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg (Gaming) initdata = JSON.parse(<<-JSON).as_h diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index d74414083..8241478b4 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -82,8 +82,10 @@ def extract_auto_generated_channel_header(initdata : Hash(String, JSON::Any), uc author = details.dig?("title", "simpleText").try &.as_s author ||= raise InfoException.new("Could not extract the carousel title of channel #{ucid}") author_url = "https://www.youtube.com/channel/#{ucid}" - author_thumbnail = details.dig?("avatar", "thumbnails", 0, "url").try &.as_s - author_thumbnail ||= raise InfoException.new("Could not extract the carousel thumbnail of channel #{ucid}") + + # A missing avatar is not worth failing the whole page over, and the + # `pageHeaderRenderer` branch above falls back to an empty string too. + author_thumbnail = details.dig?("avatar", "thumbnails", 0, "url").try &.as_s || "" else raise InfoException.new("Could not extract the header of channel #{ucid}") end From a85f13034bd65714a87731d2abb262c53009dd8c Mon Sep 17 00:00:00 2001 From: EazyHood <209367218+EazyHood@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:00:45 -0500 Subject: [PATCH 4/7] channels: tolerate missing tabbed-header fields and non-count subscriber text interactiveTabbedHeaderRenderer payloads may omit boxArt or description; use safe access and keep the empty-avatar and nil-description defaults the rest of get_about_info already expects. Topic channels can carry a handle in subscriberCountText; fall back to the subtitle count when the first value is not a subscriber count, so sub_count is not silently left at zero. Addresses the two CodeRabbit review comments from 2026-08-20. Co-Authored-By: Claude Fable 5 --- src/invidious/channels/about.cr | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 8241478b4..ece26b393 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -43,14 +43,16 @@ def extract_auto_generated_channel_header(initdata : Hash(String, JSON::Any), uc if header = initdata.dig?("header", "interactiveTabbedHeaderRenderer") author = header.dig("title", "simpleText").as_s author_url = initdata.dig("microformat", "microformatDataRenderer", "urlCanonical").as_s - author_thumbnail = header.dig("boxArt", "thumbnails", 0, "url").as_s + author_thumbnail = header.dig?("boxArt", "thumbnails", 0, "url").try &.as_s || "" banner = header.dig?("banner", "thumbnails").try &.[-1]?.try &.["url"].as_s? - description_base_node = header["description"] + description_base_node = header["description"]? # some channels have the description in a simpleText # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/ - description_node = description_base_node.dig?("simpleText") || description_base_node + description_node = description_base_node.try do |node| + node.dig?("simpleText") || node + end tags = header.dig?("badges") .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String @@ -266,7 +268,9 @@ def get_about_info(ucid) : AboutChannel # ex: "74.3M subscribers". `subscriberCountText` is part of the same # renderer but comes back null, so it is only used as a first choice. sub_text = topic_details.dig?("subscriberCountText", "simpleText").try &.as_s - sub_text ||= topic_details.dig?("subtitle", "simpleText").try &.as_s + unless sub_text.try &.includes?("subscriber") + sub_text = topic_details.dig?("subtitle", "simpleText").try &.as_s + end if sub_text && sub_text.includes?("subscriber") sub_count = short_text_to_number(sub_text.split(" ")[0]).to_i32 From d1962c9f216cdc820b3e95d836a8dfbf302cbb91 Mon Sep 17 00:00:00 2001 From: EazyHood Date: Wed, 2 Sep 2026 10:50:36 -0500 Subject: [PATCH 5/7] channels: complete carousel header review fixes Fall back to the ucid-based channel URL when a legacy auto-generated header omits urlCanonical, and cover it with a focused regression spec. Remove the unrelated selected-tab guard and its spec so this change remains scoped to auto-generated channel headers. --- spec/invidious/channels/about_spec.cr | 21 +++++++++++++++++++ spec/invidious/yt_backend/extractors_spec.cr | 22 -------------------- src/invidious/channels/about.cr | 3 ++- src/invidious/yt_backend/extractors.cr | 3 +-- 4 files changed, 24 insertions(+), 25 deletions(-) delete mode 100644 spec/invidious/yt_backend/extractors_spec.cr diff --git a/spec/invidious/channels/about_spec.cr b/spec/invidious/channels/about_spec.cr index 55936cbe4..abac6e2ad 100644 --- a/spec/invidious/channels/about_spec.cr +++ b/spec/invidious/channels/about_spec.cr @@ -200,6 +200,27 @@ Spectator.describe "extract_auto_generated_channel_header" do expect(header[:tags]).to eq(["Gaming"]) end + it "falls back to the channel URL when the canonical URL is missing" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "interactiveTabbedHeaderRenderer": { + "title": {"simpleText": "Legacy gaming"} + } + }, + "microformat": { + "microformatDataRenderer": { + "familySafe": true + } + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCMissingCanonical") + + expect(header[:author_url]).to eq("https://www.youtube.com/channel/UCMissingCanonical") + end + it "keeps an explicit familySafe: false" do initdata = JSON.parse(<<-JSON).as_h { diff --git a/spec/invidious/yt_backend/extractors_spec.cr b/spec/invidious/yt_backend/extractors_spec.cr deleted file mode 100644 index a4c2dc0aa..000000000 --- a/spec/invidious/yt_backend/extractors_spec.cr +++ /dev/null @@ -1,22 +0,0 @@ -require "../../parsers_helper" - -Spectator.describe "YouTubeTabs" do - it "treats a selected tab without content as empty" do - initdata = JSON.parse(<<-JSON).as_h - { - "contents": { - "twoColumnBrowseResultsRenderer": { - "tabs": [ - {"tabRenderer": {"selected": true}} - ] - } - } - } - JSON - - items, continuation = extract_items(initdata) - - expect(items).to be_empty - expect(continuation).to be_nil - end -end diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index ece26b393..4677f08d5 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -42,7 +42,8 @@ def extract_auto_generated_channel_header(initdata : Hash(String, JSON::Any), uc if header = initdata.dig?("header", "interactiveTabbedHeaderRenderer") author = header.dig("title", "simpleText").as_s - author_url = initdata.dig("microformat", "microformatDataRenderer", "urlCanonical").as_s + author_url = initdata.dig?("microformat", "microformatDataRenderer", "urlCanonical").try &.as_s? + author_url ||= "https://www.youtube.com/channel/#{ucid}" author_thumbnail = header.dig?("boxArt", "thumbnails", 0, "url").try &.as_s || "" banner = header.dig?("banner", "thumbnails").try &.[-1]?.try &.["url"].as_s? diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 72c3e1441..b2226e74d 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -964,8 +964,7 @@ private module Extractors private def self.extract(target) raw_items = [] of JSON::Any - content = extract_selected_tab(target["tabs"])["content"]? - return raw_items if content.nil? + content = extract_selected_tab(target["tabs"])["content"] if section_list_contents = content.dig?("sectionListRenderer", "contents") raw_items = unpack_section_list(section_list_contents) From e86cd8a7779826e5a34753ab84d724ecbaa29192 Mon Sep 17 00:00:00 2001 From: EazyHood Date: Wed, 2 Sep 2026 10:58:38 -0500 Subject: [PATCH 6/7] channels: report missing interactive titles cleanly Use optional lookup for title.simpleText and raise the same InfoException used for incomplete carousel headers. Add a focused regression fixture for the missing nested title text. --- spec/invidious/channels/about_spec.cr | 16 ++++++++++++++++ src/invidious/channels/about.cr | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/spec/invidious/channels/about_spec.cr b/spec/invidious/channels/about_spec.cr index abac6e2ad..faf776135 100644 --- a/spec/invidious/channels/about_spec.cr +++ b/spec/invidious/channels/about_spec.cr @@ -200,6 +200,22 @@ Spectator.describe "extract_auto_generated_channel_header" do expect(header[:tags]).to eq(["Gaming"]) end + it "raises an InfoException when the legacy interactive title is missing" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "interactiveTabbedHeaderRenderer": { + "title": {} + } + } + } + JSON + + expect do + extract_auto_generated_channel_header(initdata, "UCMissingTitle") + end.to raise_error(InfoException, /interactive title/) + end + it "falls back to the channel URL when the canonical URL is missing" do initdata = JSON.parse(<<-JSON).as_h { diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 4677f08d5..97b2fc884 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -41,7 +41,8 @@ def extract_auto_generated_channel_header(initdata : Hash(String, JSON::Any), uc tags = [] of String if header = initdata.dig?("header", "interactiveTabbedHeaderRenderer") - author = header.dig("title", "simpleText").as_s + author = header.dig?("title", "simpleText").try &.as_s + author ||= raise InfoException.new("Could not extract the interactive title of channel #{ucid}") author_url = initdata.dig?("microformat", "microformatDataRenderer", "urlCanonical").try &.as_s? author_url ||= "https://www.youtube.com/channel/#{ucid}" author_thumbnail = header.dig?("boxArt", "thumbnails", 0, "url").try &.as_s || "" From 9432cd048ff08c45a692bc3d0b4a6585681297ce Mon Sep 17 00:00:00 2001 From: EazyHood Date: Wed, 2 Sep 2026 11:15:52 -0500 Subject: [PATCH 7/7] channels: handle null topic metadata safely --- spec/invidious/channels/about_spec.cr | 58 ++++++++++++++++++++++++--- src/invidious/channels/about.cr | 43 +++++++++++--------- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/spec/invidious/channels/about_spec.cr b/spec/invidious/channels/about_spec.cr index faf776135..29bcc70f3 100644 --- a/spec/invidious/channels/about_spec.cr +++ b/spec/invidious/channels/about_spec.cr @@ -254,6 +254,23 @@ Spectator.describe "extract_auto_generated_channel_header" do expect(header[:is_family_friendly]).to be_false end + it "defaults an explicit familySafe: null to true" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "pageHeaderRenderer": {"pageTitle": "Gaming"} + }, + "microformat": { + "microformatDataRenderer": {"familySafe": null} + } + } + JSON + + header = extract_auto_generated_channel_header(initdata, "UCNullFamilySafe") + + expect(header[:is_family_friendly]).to be_true + end + it "raises when the header shape is unknown" do initdata = JSON.parse(<<-JSON).as_h { @@ -298,6 +315,40 @@ Spectator.describe "extract_topic_channel_details" do expect(extract_topic_channel_details(initdata)).to be_nil end + it "returns nil when carousel contents are explicitly null" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": null + } + } + } + JSON + + expect(extract_topic_channel_details(initdata)).to be_nil + end + + it "skips an explicitly null topic renderer before a valid one" do + initdata = JSON.parse(<<-JSON).as_h + { + "header": { + "carouselHeaderRenderer": { + "contents": [ + {"topicChannelDetailsRenderer": null}, + {"topicChannelDetailsRenderer": {"title": {"simpleText": "Sports"}}} + ] + } + } + } + JSON + + details = extract_topic_channel_details(initdata) + + expect(details).not_to be_nil + expect(details.not_nil!.dig("title", "simpleText").as_s).to eq("Sports") + end + it "exposes the subscriber count carried by the subtitle" do # `subscriberCountText` is part of the renderer but comes back null, so the # count is only available as free text in the subtitle. @@ -322,11 +373,6 @@ Spectator.describe "extract_topic_channel_details" do } JSON - details = extract_topic_channel_details(initdata) - - expect(details).not_to be_nil - sub_text = details.not_nil!.dig("subtitle", "simpleText").as_s - expect(sub_text).to eq("74.3M subscribers") - expect(short_text_to_number(sub_text.split(" ")[0])).to eq(74_300_000_i64) + expect(extract_topic_channel_subscriber_count(initdata)).to eq(74_300_000) end end diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 97b2fc884..016908fc4 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -24,12 +24,29 @@ record AboutChannel, # rather than by index. # ex: https://www.youtube.com/channel/UCEgdi0XIXXZ-qJOFPf4JSKw def extract_topic_channel_details(initdata : Hash(String, JSON::Any)) : JSON::Any? - contents = initdata.dig?("header", "carouselHeaderRenderer", "contents") - return nil if contents.nil? + contents = initdata.dig?("header", "carouselHeaderRenderer", "contents").try &.as_a? + return nil unless contents - contents.as_a - .find { |content| !content.dig?("topicChannelDetailsRenderer").nil? } - .try &.dig?("topicChannelDetailsRenderer") + contents.each do |content| + details = content.dig?("topicChannelDetailsRenderer") + return details if details.try &.as_h? + end + + nil +end + +def extract_topic_channel_subscriber_count(initdata : Hash(String, JSON::Any)) : Int32 + details = extract_topic_channel_details(initdata) + return 0 unless details + + sub_text = details.dig?("subscriberCountText", "simpleText").try &.as_s? + unless sub_text.try &.includes?("subscriber") + sub_text = details.dig?("subtitle", "simpleText").try &.as_s? + end + + return 0 unless sub_text.try &.includes?("subscriber") + + short_text_to_number(sub_text.split(" ")[0]).to_i32 end # Auto-generated channels come with one of three header shapes. This is only @@ -96,7 +113,7 @@ def extract_auto_generated_channel_header(initdata : Hash(String, JSON::Any), uc # `microformat` is absent from these payloads, so a missing flag defaults to # safe. An explicit `false` is still preserved. - family_safe = initdata.dig?("microformat", "microformatDataRenderer", "familySafe").try(&.as_bool) + family_safe = initdata.dig?("microformat", "microformatDataRenderer", "familySafe").try &.as_bool? { author: author, @@ -265,18 +282,8 @@ def get_about_info(ucid) : AboutChannel break if sub_count != 0 && !pronouns.nil? end - elsif (topic_details = extract_topic_channel_details(initdata)) - # Topic channels carry the subscriber count as free text in the subtitle, - # ex: "74.3M subscribers". `subscriberCountText` is part of the same - # renderer but comes back null, so it is only used as a first choice. - sub_text = topic_details.dig?("subscriberCountText", "simpleText").try &.as_s - unless sub_text.try &.includes?("subscriber") - sub_text = topic_details.dig?("subtitle", "simpleText").try &.as_s - end - - if sub_text && sub_text.includes?("subscriber") - sub_count = short_text_to_number(sub_text.split(" ")[0]).to_i32 - end + elsif initdata.dig?("header", "carouselHeaderRenderer") + sub_count = extract_topic_channel_subscriber_count(initdata) end AboutChannel.new(