Fix collaborator channel links in related videos

This commit is contained in:
privacyguy123 2026-09-19 19:23:25 +01:00
parent c88230067b
commit 8aa761940c
6 changed files with 214 additions and 5 deletions

View File

@ -0,0 +1,43 @@
{
"videoId": "collaboration",
"title": {"simpleText": "A collaboration"},
"shortBylineText": {
"runs": [{
"text": "Alpha & Friends et Beta <Live>",
"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 <Live>"},
"rendererContext": {"commandContext": {"onTap": {"innertubeCommand": {
"browseEndpoint": {"browseId": "UC-beta"}
}}}}
}}
]
}
}
}
}
}
}
}
}]
}
}

View File

@ -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" => "<script>Beta</script>", "ucid" => "UC-\"quoted", "verified" => "false"},
{"name" => "Unlinked <Author>", "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("<script>Beta</script>")
expect(document.xpath_nodes("//script").size).to eq(0)
expect(document.xpath_nodes("//i").size).to eq(1)
expect(document.content).to contain("Unlinked <Author>")
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 <Live>")
end
it "falls back to an escaped unlinked label when no channels were extracted" do
html = render_related_video_authors({"author" => "<Legacy>", "ucid" => "", "author_channels" => "[]"})
document = XML.parse_html(html)
expect(document.xpath_nodes("//a").size).to eq(0)
expect(document.content).to contain("<Legacy>")
end
end

View File

@ -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 <Live>")
expect(video["ucid"]).to eq("")
expect(authors.map(&.["name"].as_s)).to eq(["Alpha & Friends", "Beta <Live>"])
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 <Live>")
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

View File

@ -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)

View File

@ -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
-%>
<b style="width:100%">
<%- authors.each_with_index do |author, index| -%>
<%- if index > 0 -%>, <% end -%>
<%- unless author["ucid"].empty? -%><a href="/channel/<%= HTML.escape(author["ucid"]) %>"><% end -%>
<%= HTML.escape(author["name"]) %><% if author["verified"] == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
<%- unless author["ucid"].empty? -%></a><% end -%>
<%- end -%>
</b>

View File

@ -389,11 +389,7 @@ we're going to need to do it here in order to allow for translations.
<h5 class="pure-g">
<div class="pure-u-14-24">
<% if !rv["ucid"].empty? %>
<b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
<% else %>
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
<% end %>
<%= rendered "components/related_video_authors" %>
</div>
<div class="pure-u-10-24" style="text-align:right">