diff --git a/spec/invidious/videos/related_video_parser_spec.cr b/spec/invidious/videos/related_video_parser_spec.cr new file mode 100644 index 000000000..1b5cda57f --- /dev/null +++ b/spec/invidious/videos/related_video_parser_spec.cr @@ -0,0 +1,74 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_related_video" do + it "extracts a channel id from a multi-creator collaborator dialog" do + related = JSON.parse(<<-JSON) + { + "videoId": "dbVdnL6HWOg", + "title": {"simpleText": "Example video"}, + "shortBylineText": { + "runs": [{ + "text": "Piers Morgan Uncensored y ProfSteveKeen", + "navigationEndpoint": { + "showDialogCommand": { + "panelLoadingStrategy": { + "inlineContent": { + "dialogViewModel": { + "customContent": { + "listViewModel": { + "listItems": [{ + "listItemViewModel": { + "rendererContext": { + "commandContext": { + "onTap": { + "innertubeCommand": { + "browseEndpoint": { + "browseId": "UCatt7TBjfBkiJWx8khav_Gg" + } + } + } + } + } + } + }] + } + } + } + } + } + } + } + }] + } + } + JSON + + parsed = Invidious::Videos::Parser.parse_related_video(related) + + expect(parsed).to_not be_nil + expect(parsed.not_nil!["author"]).to eq("Piers Morgan Uncensored y ProfSteveKeen") + expect(parsed.not_nil!["ucid"]).to eq("UCatt7TBjfBkiJWx8khav_Gg") + end + + it "keeps extracting a direct channel id" do + related = JSON.parse(<<-JSON) + { + "videoId": "example", + "title": {"simpleText": "Example video"}, + "shortBylineText": { + "runs": [{ + "text": "Example channel", + "navigationEndpoint": { + "browseEndpoint": {"browseId": "UCexample"} + } + }] + } + } + JSON + + parsed = Invidious::Videos::Parser.parse_related_video(related) + + expect(parsed).to_not be_nil + expect(parsed.not_nil!["ucid"]).to eq("UCexample") + end +end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b2226e74d..2840159be 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -1141,7 +1141,37 @@ module HelperExtractors # Retrieves the ID required for querying the InnerTube browse endpoint. # Returns an empty string when it's unable to do so def self.get_browse_id(container) - return container.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || "" + if browse_id = container.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s + return browse_id + end + + find_browse_id(container) || "" + end + + # YouTube wraps the channel links for videos with multiple creators in a + # collaborator dialog instead of putting a browse endpoint directly on the + # byline run. Search nested navigation data so those videos still get a + # usable channel link. + private def self.find_browse_id(container : JSON::Any) : String? + if browse_id = container.dig?("browseEndpoint", "browseId").try &.as_s + return browse_id + end + + if object = container.as_h? + object.each_value do |value| + if browse_id = find_browse_id(value) + return browse_id + end + end + elsif array = container.as_a? + array.each do |value| + if browse_id = find_browse_id(value) + return browse_id + end + end + end + + nil end end