diff --git a/spec/invidious/videos/related_video_collaboration_spec.cr b/spec/invidious/videos/related_video_collaboration_spec.cr new file mode 100644 index 000000000..68887cdac --- /dev/null +++ b/spec/invidious/videos/related_video_collaboration_spec.cr @@ -0,0 +1,103 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_related_video" do + it "extracts the channel of a video published as a collaboration" do + # A video published as a collaboration between two channels. Its byline + # carries no "browseEndpoint"; it opens a "Collaborators" dialog instead, + # and the channel of each collaborator sits inside that dialog. + # + # See: https://github.com/iv-org/invidious/issues/5722 + related = JSON.parse(<<-JSON) + { + "videoId": "dbVdnL6HWOg", + "title": { "simpleText": "Professor Jiang Debates Iran War, Trump And China Ties" }, + "lengthInSeconds": 2241, + "shortViewCountText": { "simpleText": "1M views" }, + "publishedTimeText": { "simpleText": "3 months ago" }, + "shortBylineText": { + "runs": [ + { + "text": "Piers Morgan Uncensored and ProfSteveKeen", + "navigationEndpoint": { + "showDialogCommand": { + "panelLoadingStrategy": { + "inlineContent": { + "dialogViewModel": { + "customContent": { + "listViewModel": { + "listItems": [ + { + "listItemViewModel": { + "title": { "content": "Piers Morgan Uncensored" }, + "rendererContext": { + "commandContext": { + "onTap": { + "innertubeCommand": { + "browseEndpoint": { "browseId": "UCatt7TBjfBkiJWx8khav_Gg" } + } + } + } + } + } + }, + { + "listItemViewModel": { + "title": { "content": "ProfSteveKeen" }, + "rendererContext": { + "commandContext": { + "onTap": { + "innertubeCommand": { + "browseEndpoint": { "browseId": "UCM1ubsbE-tG9ru61mc3zX8A" } + } + } + } + } + } + } + ] + } + } + } + } + } + } + } + } + ] + } + } + JSON + + parsed = Invidious::Videos::Parser.parse_related_video(related).not_nil! + + expect(parsed["ucid"].as_s).to eq("UCatt7TBjfBkiJWx8khav_Gg") + expect(parsed["author"].as_s).to eq("Piers Morgan Uncensored and ProfSteveKeen") + end + + it "keeps extracting the channel of a single author video" do + related = JSON.parse(<<-JSON) + { + "videoId": "_g4l7YkDQwA", + "title": { "simpleText": "The Diary Of A CEO episode" }, + "lengthInSeconds": 1337, + "shortViewCountText": { "simpleText": "2.1M views" }, + "publishedTimeText": { "simpleText": "1 month ago" }, + "shortBylineText": { + "runs": [ + { + "text": "The Diary Of A CEO", + "navigationEndpoint": { + "browseEndpoint": { "browseId": "UCGq-a57w-aPwyi3pW7XLiHw" } + } + } + ] + } + } + JSON + + parsed = Invidious::Videos::Parser.parse_related_video(related).not_nil! + + expect(parsed["ucid"].as_s).to eq("UCGq-a57w-aPwyi3pW7XLiHw") + expect(parsed["author"].as_s).to eq("The Diary Of A CEO") + end +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 8367fcd78..ee931e4ba 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -28,6 +28,13 @@ module Invidious::Videos::Parser ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } + # Videos published as a collaboration have no "browseEndpoint" in their + # byline, so the channel has to be read from the "Collaborators" dialog + # that the byline opens instead. See `get_collaborator_browse_id`. + if ucid.nil? || ucid.empty? + ucid = channel_info.try { |ci| HelperExtractors.get_collaborator_browse_id(ci) } + end + short_view_count = related.try do |r| HelperExtractors.get_short_view_count(r).to_s end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b2226e74d..bd491b65d 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -1143,6 +1143,21 @@ module HelperExtractors def self.get_browse_id(container) return container.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || "" end + + # Videos published as a collaboration have no "browseEndpoint" in their + # byline. Their byline opens a "Collaborators" dialog instead, which holds + # the channel of every collaborator. Retrieves the ID of the first entry, + # which is the channel the video was published on. + # + # Returns an empty string when it's unable to do so + def self.get_collaborator_browse_id(container) + return container.dig?( + "navigationEndpoint", "showDialogCommand", "panelLoadingStrategy", + "inlineContent", "dialogViewModel", "customContent", "listViewModel", + "listItems", 0, "listItemViewModel", "rendererContext", "commandContext", + "onTap", "innertubeCommand", "browseEndpoint", "browseId" + ).try &.as_s || "" + end end # Parses an item from Youtube's JSON response into a more usable structure.