From 9432cd048ff08c45a692bc3d0b4a6585681297ce Mon Sep 17 00:00:00 2001 From: EazyHood Date: Wed, 2 Sep 2026 11:15:52 -0500 Subject: [PATCH] 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(