From 92db7b0e2251470c03dc636b94fa7d400c8045de Mon Sep 17 00:00:00 2001 From: awer123456 <127011722+awer123456@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:34:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=A8=E8=8D=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=9C=89=E5=A4=9A=E4=B8=AA=E5=88=9B=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E9=A2=91=E9=81=93=E5=90=8D=E7=A7=B0=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E7=82=B9=E5=87=BB=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/invidious/videos/related_video_spec.cr | 349 ++++++++++++++++++++ src/invidious/jsonify/api_v1/video_json.cr | 26 +- src/invidious/videos.cr | 6 +- src/invidious/videos/parser.cr | 188 ++++++++++- src/invidious/views/watch.ecr | 32 +- 5 files changed, 561 insertions(+), 40 deletions(-) create mode 100644 spec/invidious/videos/related_video_spec.cr diff --git a/spec/invidious/videos/related_video_spec.cr b/spec/invidious/videos/related_video_spec.cr new file mode 100644 index 000000000..cf8df5d11 --- /dev/null +++ b/spec/invidious/videos/related_video_spec.cr @@ -0,0 +1,349 @@ +require "../../parsers_helper.cr" + +LEGACY_SINGLE_AUTHOR_RELATED_VIDEO = <<-JSON + { + "videoId": "XuoqKYxDHVc", + "title": {"simpleText": "Single-author recommendation"}, + "lengthInSeconds": 5107, + "shortViewCountText": {"simpleText": "3.1M views"}, + "publishedTimeText": {"simpleText": "4 days ago"}, + "shortBylineText": { + "runs": [{ + "text": "The Economist", + "navigationEndpoint": { + "browseEndpoint": {"browseId": "UC0p5jTq6Xx_DosDFxVXnWaQ"} + } + }] + } + } + JSON + +SINGLE_AUTHOR_LOCKUP_RELATED_VIDEO = <<-JSON + { + "contentId": "_g4l7YkDQwA", + "contentType": "LOCKUP_CONTENT_TYPE_VIDEO", + "contentImage": { + "thumbnailViewModel": { + "decoratedAvatarViewModel": { + "rendererContext": { + "commandContext": { + "onTap": { + "innertubeCommand": { + "browseEndpoint": {"browseId": "UCGq-a57w-aPwyi3pW7XLiHw"} + } + } + } + } + }, + "overlays": [{ + "thumbnailBottomOverlayViewModel": { + "badges": [{"thumbnailBadgeViewModel": {"text": "1:42:19"}}] + } + }] + } + }, + "metadata": { + "lockupMetadataViewModel": { + "title": {"content": "Single-author lockup recommendation"}, + "metadata": { + "contentMetadataViewModel": { + "metadataRows": [ + { + "metadataParts": [{ + "text": { + "content": "The Diary Of A CEO", + "attachmentRuns": [{ + "element": { + "type": { + "imageType": { + "image": { + "sources": [{ + "clientResource": {"imageName": "CHECK_CIRCLE_FILLED"} + }] + } + } + } + } + }] + } + }] + }, + { + "metadataParts": [ + { + "text": {"content": "4.5M"} + }, + { + "text": {"content": "3mo ago"}, + "accessibilityLabel": "3 months ago" + } + ] + } + ] + } + } + } + } + } + JSON + +MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO = <<-JSON + { + "contentId": "4klivapz4Gw", + "contentType": "LOCKUP_CONTENT_TYPE_VIDEO", + "contentImage": { + "thumbnailViewModel": { + "overlays": [{ + "thumbnailBottomOverlayViewModel": { + "badges": [{"thumbnailBadgeViewModel": {"text": "3:06:19"}}] + } + }] + } + }, + "metadata": { + "lockupMetadataViewModel": { + "title": {"content": "Collaborative lockup recommendation"}, + "image": { + "avatarStackViewModel": { + "rendererContext": { + "commandContext": { + "onTap": { + "innertubeCommand": { + "showDialogCommand": { + "panelLoadingStrategy": { + "inlineContent": { + "dialogViewModel": { + "customContent": { + "listViewModel": { + "listItems": [ + { + "listItemViewModel": { + "title": { + "content": "Chris Williamson", + "attachmentRuns": [{ + "element": { + "type": { + "imageType": { + "image": { + "sources": [{ + "clientResource": {"imageName": "CHECK_CIRCLE_FILLED"} + }] + } + } + } + } + }] + }, + "rendererContext": { + "commandContext": { + "onTap": { + "innertubeCommand": { + "browseEndpoint": {"browseId": "UCIaH-gZIVC432YRjNVvnyCA"} + } + } + } + } + } + }, + { + "listItemViewModel": { + "title": {"content": "Predictive History"}, + "rendererContext": { + "commandContext": { + "onTap": { + "innertubeCommand": { + "browseEndpoint": {"browseId": "UC11aHtNnc5bEPLI4jf6mnYg"} + } + } + } + } + } + }, + { + "listItemViewModel": { + "rendererContext": { + "commandContext": { + "onTap": { + "innertubeCommand": { + "browseEndpoint": {"browseId": "UC-without-name"} + } + } + } + } + } + }, + { + "listItemViewModel": { + "title": {"content": "Unlinked collaborator"} + } + } + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "metadata": { + "contentMetadataViewModel": { + "metadataRows": [ + { + "metadataParts": [{ + "text": {"content": "Chris Williamson and Predictive History"} + }] + }, + { + "metadataParts": [ + { + "text": {"content": "1.2M"} + }, + { + "text": {"content": "2mo ago"}, + "accessibilityLabel": "2 months ago" + } + ] + } + ] + } + } + } + } + } + JSON + +Spectator.describe Invidious::Videos::Parser do + describe ".parse_related_video" do + it "preserves support for legacy single-author renderers" do + related = Invidious::Videos::Parser.parse_related_video( + JSON.parse(LEGACY_SINGLE_AUTHOR_RELATED_VIDEO) + ).not_nil! + + expect(related["author"].as_s).to eq("The Economist") + expect(related["ucid"].as_s).to eq("UC0p5jTq6Xx_DosDFxVXnWaQ") + expect(related["authors"]?).to be_nil + end + + it "parses a single-author lockup renderer" do + related = Invidious::Videos::Parser.parse_related_video( + JSON.parse(SINGLE_AUTHOR_LOCKUP_RELATED_VIDEO) + ).not_nil! + + expect(related["id"].as_s).to eq("_g4l7YkDQwA") + expect(related["title"].as_s).to eq("Single-author lockup recommendation") + expect(related["author"].as_s).to eq("The Diary Of A CEO") + expect(related["ucid"].as_s).to eq("UCGq-a57w-aPwyi3pW7XLiHw") + expect(related["author_verified"].as_s).to eq("true") + expect(related["length_seconds"].as_s).to eq("6139") + expect(related["short_view_count"].as_s).to eq("4.5M") + expect(related["published"].as_s).not_to be_empty + expect(related["authors"]?).to be_nil + end + + it "extracts every named collaborator from a lockup avatar stack" do + related = Invidious::Videos::Parser.parse_related_video( + JSON.parse(MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO) + ).not_nil! + authors = related["authors"].as_a + + expect(related["id"].as_s).to eq("4klivapz4Gw") + expect(related["author"].as_s).to eq("Chris Williamson and Predictive History") + expect(related["ucid"].as_s).to be_empty + expect(related["length_seconds"].as_s).to eq("11179") + expect(related["short_view_count"].as_s).to eq("1.2M") + expect(authors.size).to eq(3) + + expect(authors[0]["author"].as_s).to eq("Chris Williamson") + expect(authors[0]["ucid"].as_s).to eq("UCIaH-gZIVC432YRjNVvnyCA") + expect(authors[0]["verified"].as_bool).to be_true + + expect(authors[1]["author"].as_s).to eq("Predictive History") + expect(authors[1]["ucid"].as_s).to eq("UC11aHtNnc5bEPLI4jf6mnYg") + expect(authors[1]["verified"].as_bool).to be_false + + expect(authors[2]["author"].as_s).to eq("Unlinked collaborator") + expect(authors[2]["ucid"].as_s).to be_empty + end + + it "ignores non-video lockups" do + related = JSON.parse(%({"contentId":"playlist","contentType":"LOCKUP_CONTENT_TYPE_PLAYLIST"})) + expect(Invidious::Videos::Parser.parse_related_video(related)).to be_nil + end + + it "extracts lockups wrapped in an item section" do + lockup = JSON.parse(MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO) + results = JSON.parse({ + "itemSectionRenderer" => { + "contents" => [ + {"lockupViewModel" => lockup}, + {"continuationItemRenderer" => {"trigger" => "CONTINUATION_TRIGGER_ON_ITEM_SHOWN"}}, + ], + }, + }.to_json) + + related = Invidious::Videos::Parser.parse_related_videos(JSON::Any.new([results])) + + expect(related.size).to eq(1) + expect(related[0]["id"].as_s).to eq("4klivapz4Gw") + expect(related[0]["authors"].as_a.map(&.["author"].as_s)).to eq([ + "Chris Williamson", + "Predictive History", + "Unlinked collaborator", + ]) + end + + it "does not classify author-like metadata as views or publication dates" do + lockup = JSON.parse(MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO.sub( + "Chris Williamson and Predictive History", + "Daily Views Channel and Long Ago Podcast" + )) + + related = Invidious::Videos::Parser.parse_related_video(lockup).not_nil! + + expect(related["author"].as_s).to eq("Daily Views Channel and Long Ago Podcast") + expect(related["short_view_count"].as_s).to eq("1.2M") + expect(related["published"].as_s).not_to be_empty + end + end +end + +Spectator.describe Video do + describe "#related_videos" do + it "exposes structured collaborators while retaining legacy fields" do + parsed = Invidious::Videos::Parser.parse_related_video( + JSON.parse(MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO) + ).not_nil! + video = Video.new({ + id: "IZZVijQ0gkA", + info: { + "relatedVideos" => JSON::Any.new([JSON::Any.new(parsed)]), + }, + updated: Time.utc, + }) + + related = video.related_videos.first + + expect(related["id"].as_s).to eq("4klivapz4Gw") + expect(related["author"].as_s).to eq("Chris Williamson and Predictive History") + expect(related["ucid"].as_s).to be_empty + expect(related["length_seconds"].as_s.to_i).to eq(11179) + + authors = related["authors"].as_a + expect(authors.map(&.["author"].as_s)).to eq([ + "Chris Williamson", + "Predictive History", + "Unlinked collaborator", + ]) + expect(authors.map(&.["ucid"].as_s)).to eq([ + "UCIaH-gZIVC432YRjNVvnyCA", + "UC11aHtNnc5bEPLI4jf6mnYg", + "", + ]) + end + end +end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index e02e06173..4193e3e23 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -238,16 +238,16 @@ module Invidious::JSONify::APIv1 video.related_videos.each do |rv| if rv["id"]? json.object do - json.field "videoId", rv["id"] - json.field "title", rv["title"] + json.field "videoId", rv["id"].as_s + json.field "title", rv["title"].as_s json.field "videoThumbnails" do - self.thumbnails(json, rv["id"]) + self.thumbnails(json, rv["id"].as_s) end - json.field "author", rv["author"] - json.field "authorUrl", "/channel/#{rv["ucid"]?}" - json.field "authorId", rv["ucid"]? - json.field "authorVerified", rv["author_verified"] == "true" + json.field "author", rv["author"].as_s + json.field "authorUrl", "/channel/#{rv["ucid"]?.try &.as_s}" + json.field "authorId", rv["ucid"]?.try &.as_s + json.field "authorVerified", rv["author_verified"].as_s == "true" if rv["author_thumbnail"]? json.field "authorThumbnails" do json.array do @@ -255,7 +255,7 @@ module Invidious::JSONify::APIv1 qualities.each do |quality| json.object do - json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") + json.field "url", rv["author_thumbnail"].as_s.gsub(/s\d+-/, "s#{quality}-") json.field "width", quality json.field "height", quality end @@ -264,11 +264,11 @@ module Invidious::JSONify::APIv1 end end - json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i - json.field "viewCountText", rv["short_view_count"]? - json.field "published", rv["published"]? - if rv["published"]?.try &.presence - json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale)) + json.field "lengthSeconds", rv["length_seconds"]?.try &.as_s.to_i + json.field "viewCountText", rv["short_view_count"]?.try &.as_s + json.field "published", rv["published"]?.try &.as_s + if rv["published"]?.try &.as_s.presence + json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].as_s), locale)) else json.field "publishedText", "" end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index ba947b2b7..9add12667 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -15,7 +15,7 @@ struct Video # NOTE: don't forget to bump this number if any change is made to # the `params` structure in videos/parser.cr!!! # - SCHEMA_VERSION = 3 + SCHEMA_VERSION = 4 property id : String @@ -87,8 +87,8 @@ struct Video end end - def related_videos - info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String) + def related_videos : Array(Hash(String, JSON::Any)) + info["relatedVideos"]?.try &.as_a.map(&.as_h) || [] of Hash(String, JSON::Any) end # Methods for parsing streaming data diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 8367fcd78..4e921e8d9 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -3,17 +3,20 @@ require "json" module Invidious::Videos::Parser extend self - # Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". - # The former is preferred as it has more videos in it. The second has - # the same 11 first entries as the compact rendered. + # Used to parse "lockupViewModel", "compactVideoRenderer", and + # "endScreenVideoRenderer" related video entries. # # TODO: "compactRadioRenderer" (Mix) and # TODO: Use a proper struct/class instead of a hacky JSON object def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? + if related["contentType"]? + return parse_lockup_related_video(related) + end + return nil if !related["videoId"]? - # The compact renderer has video length in seconds, where the end - # screen rendered has a full text version ("42:40") + # The compact renderer has video length in seconds, whereas the end + # screen renderer has a full text version ("42:40"). length = related["lengthInSeconds"]?.try &.as_i.to_s length ||= related.dig?("lengthText", "simpleText").try do |box| decode_length_seconds(box.as_s).to_s @@ -28,6 +31,8 @@ module Invidious::Videos::Parser ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } + authors = extract_related_video_authors(channel_info.try &.dig?("navigationEndpoint")) + short_view_count = related.try do |r| HelperExtractors.get_short_view_count(r).to_s end @@ -43,7 +48,7 @@ module Invidious::Videos::Parser # TODO: when refactoring video types, make a struct for related videos # or reuse an existing type, if that fits. - return { + parsed = { "id" => related["videoId"], "title" => related["title"]["simpleText"], "author" => author || JSON::Any.new(""), @@ -53,6 +58,166 @@ module Invidious::Videos::Parser "author_verified" => JSON::Any.new(author_verified), "published" => JSON::Any.new(published || ""), } + + parsed["authors"] = JSON::Any.new(authors) if !authors.empty? + return parsed + end + + def parse_related_videos(results : JSON::Any?) : Array(JSON::Any) + related = [] of JSON::Any + + results.try &.as_a.each do |element| + items = element.dig?("itemSectionRenderer", "contents").try &.as_a + items ||= [element] + + items.each do |item| + renderer = item["compactVideoRenderer"]? || item["lockupViewModel"]? + next if !renderer + + related_video = self.parse_related_video(renderer) + related << JSON::Any.new(related_video) if related_video + end + end + + return related + end + + private def parse_lockup_related_video(related : JSON::Any) : Hash(String, JSON::Any)? + return nil if related["contentType"]?.try &.as_s? != "LOCKUP_CONTENT_TYPE_VIDEO" + + video_id = related["contentId"]?.try &.as_s? + metadata = related.dig?("metadata", "lockupMetadataViewModel") + title = metadata.try &.dig?("title", "content").try &.as_s? + return nil if !video_id || !metadata || !title + + author_info = metadata.dig?( + "metadata", "contentMetadataViewModel", "metadataRows", 0, + "metadataParts", 0, "text" + ) + author = author_info.try &.dig?("content").try &.as_s? || "" + author_verified = text_has_verified_badge?(author_info) + + ucid = related.dig?( + "contentImage", "thumbnailViewModel", "decoratedAvatarViewModel", + "rendererContext", "commandContext", + "onTap", "innertubeCommand", "browseEndpoint", "browseId" + ).try &.as_s? + ucid ||= author_info.try &.dig?( + "commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId" + ).try &.as_s? + + author_command = metadata.dig?( + "image", "avatarStackViewModel", "rendererContext", "commandContext", + "onTap", "innertubeCommand" + ) + authors = extract_related_video_authors(author_command) + ucid = nil if !authors.empty? + + length_text = nil + overlays = related.dig?("contentImage", "thumbnailViewModel", "overlays").try &.as_a? + overlays.try &.each do |overlay| + badges = overlay.dig?("thumbnailBottomOverlayViewModel", "badges").try &.as_a? + badges.try &.each do |badge| + text = badge.dig?("thumbnailBadgeViewModel", "text").try &.as_s? + if text && text.includes?(':') + length_text = text + break + end + end + break if length_text + end + + metadata_parts = [] of JSON::Any + metadata_rows = metadata.dig?( + "metadata", "contentMetadataViewModel", "metadataRows" + ).try &.as_a? + metadata_rows.try &.each do |row| + parts = row["metadataParts"]?.try &.as_a? + metadata_parts.concat(parts) if parts + end + + short_view_count = "0" + published_time_text = nil + metadata_parts.each do |part| + text = part.dig?("text", "content").try &.as_s? + next if !text + + if related_view_count_text?(text) + short_view_count = text.gsub(/\s*views?\z/i, "") + elsif related_published_text?(text) + published_time_text = text + end + end + + published = published_time_text.try do |text| + begin + decode_date(text).to_rfc3339 + rescue + "" + end + end || "" + + parsed = { + "id" => JSON::Any.new(video_id), + "title" => JSON::Any.new(title), + "author" => JSON::Any.new(author), + "ucid" => JSON::Any.new(ucid || ""), + "length_seconds" => JSON::Any.new(decode_length_seconds(length_text || "").to_s), + "short_view_count" => JSON::Any.new(short_view_count), + "author_verified" => JSON::Any.new(author_verified.to_s), + "published" => JSON::Any.new(published), + } + + parsed["authors"] = JSON::Any.new(authors) if !authors.empty? + return parsed + end + + private def extract_related_video_authors(command : JSON::Any?) : Array(JSON::Any) + authors = [] of JSON::Any + dialog_items = command.try &.dig?( + "showDialogCommand", "panelLoadingStrategy", "inlineContent", + "dialogViewModel", "customContent", "listViewModel", "listItems" + ).try &.as_a? + + dialog_items.try &.each do |item| + author_info = item["listItemViewModel"]? + next if !author_info + + name = author_info.dig?("title", "content").try &.as_s? + next if !name || name.empty? + + author_ucid = author_info.dig?( + "rendererContext", "commandContext", "onTap", "innertubeCommand", + "browseEndpoint", "browseId" + ).try &.as_s? || "" + + authors << JSON::Any.new({ + "author" => JSON::Any.new(name), + "ucid" => JSON::Any.new(author_ucid), + "verified" => JSON::Any.new(text_has_verified_badge?(author_info["title"]?)), + }) + end + + return authors + end + + private def text_has_verified_badge?(text : JSON::Any?) : Bool + badge = text.try &.dig?( + "attachmentRuns", 0, "element", "type", "imageType", "image", + "sources", 0, "clientResource", "imageName" + ).try &.as_s? + + return {"CHECK_CIRCLE_FILLED", "AUDIO_BADGE"}.includes?(badge) + end + + private def related_view_count_text?(text : String) : Bool + return text.matches?(/\A\d+(?:[.,]\d+)?[KMBT]?(?:\s+views?)?\z/i) + end + + private def related_published_text?(text : String) : Bool + return text.matches?( + /\A\d+\s*(?:s|sec(?:ond)?s?|min(?:ute)?s?|h|hr|hours?|d|days?|w|wk|weeks?|mo|months?|y|yr|years?)\s+ago\z/i + ) end def extract_video_info(video_id : String) @@ -238,17 +403,10 @@ module Invidious::Videos::Parser LOGGER.debug("extract_video_info: parsing related videos...") - related = [] of JSON::Any - - # Parse "compactVideoRenderer" items (under secondary results) + # Parse legacy and lockup items under secondary results. secondary_results = main_results .dig?("secondaryResults", "secondaryResults", "results") - secondary_results.try &.as_a.each do |element| - if item = element["compactVideoRenderer"]? - related_video = self.parse_related_video(item) - related << JSON::Any.new(related_video) if related_video - end - end + related = self.parse_related_videos(secondary_results) # If nothing was found previously, fall back to end screen renderer if related.empty? diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 3dc19c9e2..faca096ec 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -58,7 +58,7 @@ we're going to need to do it here in order to allow for translations. "plid" => plid, "length_seconds" => video.length_seconds.to_f, "play_next" => !video.related_videos.empty? && !plid && params.continue, - "next_video" => video.related_videos.select { |rv| rv["id"]? }[0]?.try &.["id"], + "next_video" => video.related_videos.select { |rv| rv["id"]? }[0]?.try &.["id"].as_s, "youtube_comments_text" => HTML.escape(I18n.translate(locale, "View YouTube comments")), "comments_youtube_disabled_text" => HTML.escape(I18n.translate(locale, "comments_youtube_disabled_text")), "comments_youtube_disabled_try_reddit" => HTML.escape(I18n.translate(locale, "comments_youtube_disabled_try_reddit")), @@ -369,36 +369,50 @@ we're going to need to do it here in order to allow for translations.
<%= HTML.escape(rv["title"]) %>
+ &listen=<%= params.listen %>"><%= HTML.escape(rv["title"].as_s) %>