diff --git a/spec/fixtures/related_collaborators.json b/spec/fixtures/related_collaborators.json new file mode 100644 index 000000000..52171518e --- /dev/null +++ b/spec/fixtures/related_collaborators.json @@ -0,0 +1,43 @@ +{ + "videoId": "collaboration", + "title": {"simpleText": "A collaboration"}, + "shortBylineText": { + "runs": [{ + "text": "Alpha & Friends et Beta ", + "navigationEndpoint": { + "showDialogCommand": { + "panelLoadingStrategy": { + "inlineContent": { + "dialogViewModel": { + "header": {"dialogHeaderViewModel": {"headline": {"content": "Collaborateurs"}}}, + "customContent": { + "listViewModel": { + "listItems": [ + {"listItemViewModel": { + "title": { + "content": "Alpha & Friends", + "attachmentRuns": [{"element": {"type": {"imageType": {"image": {"sources": [ + {"clientResource": {"imageName": "CHECK_CIRCLE_FILLED"}} + ]}}}}}] + }, + "rendererContext": {"commandContext": {"onTap": {"innertubeCommand": { + "browseEndpoint": {"browseId": "UC-alpha"} + }}}} + }}, + {"listItemViewModel": { + "title": {"content": "Beta "}, + "rendererContext": {"commandContext": {"onTap": {"innertubeCommand": { + "browseEndpoint": {"browseId": "UC-beta"} + }}}} + }} + ] + } + } + } + } + } + } + } + }] + } +} diff --git a/spec/invidious/videos/related_video_authors_spec.cr b/spec/invidious/videos/related_video_authors_spec.cr new file mode 100644 index 000000000..4ea74f9be --- /dev/null +++ b/spec/invidious/videos/related_video_authors_spec.cr @@ -0,0 +1,57 @@ +require "../../parsers_helper.cr" +require "ecr" +require "xml" + +def render_related_video_authors(rv : Hash(String, String)) + ECR.render "src/invidious/views/components/related_video_authors.ecr" +end + +Spectator.describe "related video author links" do + it "renders cached single-author records without collaborator metadata" do + html = render_related_video_authors({"author" => "Legacy & Author", "ucid" => "UC-legacy", "author_verified" => "true"}) + document = XML.parse_html(html) + + expect(document.xpath_nodes("//a").size).to eq(1) + expect(document.xpath_node("//a").not_nil!["href"]).to eq("/channel/UC-legacy") + expect(document.xpath_node("//a").not_nil!.content).to contain("Legacy & Author") + expect(document.xpath_nodes("//i").size).to eq(1) + end + + it "renders every collaborator and escapes names and destinations" do + authors = [ + {"name" => "Alpha & Friends", "ucid" => "UC-alpha", "verified" => "true"}, + {"name" => "", "ucid" => "UC-\"quoted", "verified" => "false"}, + {"name" => "Unlinked ", "ucid" => "", "verified" => "false"}, + ] + html = render_related_video_authors({"author" => "Combined", "ucid" => "", "author_channels" => authors.to_json}) + document = XML.parse_html(html) + links = document.xpath_nodes("//a") + + expect(links.size).to eq(2) + expect(links[0]["href"]).to eq("/channel/UC-alpha") + expect(links[1]["href"]).to eq("/channel/UC-\"quoted") + expect(links[1].content.strip).to eq("") + expect(document.xpath_nodes("//script").size).to eq(0) + expect(document.xpath_nodes("//i").size).to eq(1) + expect(document.content).to contain("Unlinked ") + end + + it "renders links from a parsed localized dialog through the string-valued cache" do + related = JSON.parse(File.read(File.join(__DIR__, "../../fixtures/related_collaborators.json"))) + video = Invidious::Videos::Parser.parse_related_video(related).not_nil! + cached = JSON.parse(video.to_json).as_h.transform_values &.as_s + document = XML.parse_html(render_related_video_authors(cached)) + links = document.xpath_nodes("//a") + + expect(links.map { |link| link["href"] }).to eq(["/channel/UC-alpha", "/channel/UC-beta"]) + expect(links[1].content.strip).to eq("Beta ") + end + + it "falls back to an escaped unlinked label when no channels were extracted" do + html = render_related_video_authors({"author" => "", "ucid" => "", "author_channels" => "[]"}) + document = XML.parse_html(html) + + expect(document.xpath_nodes("//a").size).to eq(0) + expect(document.content).to contain("") + end +end diff --git a/spec/invidious/videos/related_videos_extract_spec.cr b/spec/invidious/videos/related_videos_extract_spec.cr new file mode 100644 index 000000000..46b8a21b6 --- /dev/null +++ b/spec/invidious/videos/related_videos_extract_spec.cr @@ -0,0 +1,64 @@ +require "../../parsers_helper.cr" + +def related_collaborators_fixture + JSON.parse(File.read(File.join(__DIR__, "../../fixtures/related_collaborators.json"))) +end + +Spectator.describe "parse_related_video authors" do + it "preserves the legacy single-author fields and string-valued cache" do + related = JSON.parse(%({"videoId":"single","title":{"simpleText":"Single"}, + "shortBylineText":{"runs":[{"text":"Author","navigationEndpoint":{"browseEndpoint":{"browseId":"UC-single"}}}]}})) + video = Invidious::Videos::Parser.parse_related_video(related).not_nil! + + expect(video["author"]).to eq("Author") + expect(video["ucid"]).to eq("UC-single") + expect(video.values.all?(&.as_s?)).to be_true + end + + it "extracts each collaborator without relying on the localized dialog title" do + video = Invidious::Videos::Parser.parse_related_video(related_collaborators_fixture).not_nil! + authors = JSON.parse(video["author_channels"].as_s).as_a + + expect(video["author"]).to eq("Alpha & Friends et Beta ") + expect(video["ucid"]).to eq("") + expect(authors.map(&.["name"].as_s)).to eq(["Alpha & Friends", "Beta "]) + expect(authors.map(&.["ucid"].as_s)).to eq(["UC-alpha", "UC-beta"]) + expect(authors.map(&.["verified"].as_s)).to eq(["true", "false"]) + expect(video.values.all?(&.as_s?)).to be_true + end + + it "finds all directly linked runs including a link after an unlinked label" do + related = JSON.parse(%({"videoId":"flat","title":{"simpleText":"Flat"}, + "longBylineText":{"runs":[{"text":"Featuring "}, + {"text":"First","navigationEndpoint":{"browseEndpoint":{"browseId":"UC-first"}}}, + {"text":" and "}, + {"text":"Second","navigationEndpoint":{"browseEndpoint":{"browseId":"UC-second"}}}]}})) + video = Invidious::Videos::Parser.parse_related_video(related).not_nil! + authors = JSON.parse(video["author_channels"].as_s).as_a + + expect(authors.map(&.["ucid"].as_s)).to eq(["UC-first", "UC-second"]) + expect(video["author"]).to eq("Featuring ") + expect(video["ucid"]).to eq("") + end + + it "retains a collaborator name without inventing a missing channel destination" do + related = related_collaborators_fixture + item = related.dig("shortBylineText", "runs", 0, "navigationEndpoint", "showDialogCommand", + "panelLoadingStrategy", "inlineContent", "dialogViewModel", "customContent", "listViewModel", "listItems", 1) + item["listItemViewModel"].as_h.delete("rendererContext") + video = Invidious::Videos::Parser.parse_related_video(related).not_nil! + authors = JSON.parse(video["author_channels"].as_s).as_a + + expect(authors[1]["name"]).to eq("Beta ") + expect(authors[1]["ucid"]).to eq("") + end + + it "keeps an unlinked author when no channel data is available" do + related = JSON.parse(%({"videoId":"unlinked","title":{"simpleText":"Unlinked"}, + "shortBylineText":{"runs":[{"text":"Unlinked author"}]}})) + video = Invidious::Videos::Parser.parse_related_video(related).not_nil! + + expect(video["author"]).to eq("Unlinked author") + expect(video["ucid"]).to eq("") + end +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 8367fcd78..255b07428 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -51,10 +51,45 @@ module Invidious::Videos::Parser "length_seconds" => JSON::Any.new(length || "0"), "short_view_count" => JSON::Any.new(short_view_count || "0"), "author_verified" => JSON::Any.new(author_verified), + "author_channels" => JSON::Any.new(parse_related_authors(related).to_json), "published" => JSON::Any.new(published || ""), } end + def parse_related_authors(related : JSON::Any) : Array(Hash(String, String)) + authors = [] of Hash(String, String) + runs = (related["shortBylineText"]? || related["longBylineText"]?).try &.dig?("runs").try &.as_a + return authors unless runs + + runs.each do |run| + items = run.dig?("navigationEndpoint", "showDialogCommand", "panelLoadingStrategy", + "inlineContent", "dialogViewModel", "customContent", "listViewModel", "listItems") + if items + items.as_a.each do |item| + next unless creator = item["listItemViewModel"]? + next unless name = creator.dig?("title", "content").try &.as_s + + ucid = creator.dig?("rendererContext", "commandContext", "onTap", "innertubeCommand", + "browseEndpoint", "browseId").try &.as_s + verified = creator.dig?("title", "attachmentRuns").try &.as_a.any? do |attachment| + sources = attachment.dig?("element", "type", "imageType", "image", "sources") + sources.try &.as_a.any? { |source| source.dig?("clientResource", "imageName") == "CHECK_CIRCLE_FILLED" } + end + authors << {"name" => name, "ucid" => ucid || "", "verified" => (!!verified).to_s} + end + else + ucid = HelperExtractors.get_browse_id(run) + next if ucid.empty? + next unless name = run["text"]?.try &.as_s + + verified = runs.size == 1 && has_verified_badge?(related["ownerBadges"]?) + authors << {"name" => name, "ucid" => ucid, "verified" => verified.to_s} + end + end + + return authors + end + def extract_video_info(video_id : String) # Fetch data from the player endpoint player_response = YoutubeAPI.player(video_id: video_id) diff --git a/src/invidious/views/components/related_video_authors.ecr b/src/invidious/views/components/related_video_authors.ecr new file mode 100644 index 000000000..1e4a8b4f3 --- /dev/null +++ b/src/invidious/views/components/related_video_authors.ecr @@ -0,0 +1,14 @@ +<%- + authors = rv["author_channels"]?.try { |value| Array(Hash(String, String)).from_json(value) } || [] of Hash(String, String) + if authors.empty? + authors << {"name" => rv["author"]? || "", "ucid" => rv["ucid"], "verified" => rv["author_verified"]? || "false"} + end +-%> + + <%- authors.each_with_index do |author, index| -%> + <%- if index > 0 -%>, <% end -%> + <%- unless author["ucid"].empty? -%>"><% end -%> + <%= HTML.escape(author["name"]) %><% if author["verified"] == "true" %> <% end -%> + <%- unless author["ucid"].empty? -%><% end -%> + <%- end -%> + diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 3dc19c9e2..5a1c96be7 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -389,11 +389,7 @@ we're going to need to do it here in order to allow for translations.
- <% if !rv["ucid"].empty? %> - "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> - <% else %> - <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> - <% end %> + <%= rendered "components/related_video_authors" %>