mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-26 11:35:27 -05:00
Fix collaborator channel links in related videos
This commit is contained in:
parent
c88230067b
commit
8aa761940c
43
spec/fixtures/related_collaborators.json
vendored
Normal file
43
spec/fixtures/related_collaborators.json
vendored
Normal 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"}
|
||||||
|
}}}}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
57
spec/invidious/videos/related_video_authors_spec.cr
Normal file
57
spec/invidious/videos/related_video_authors_spec.cr
Normal 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
|
||||||
64
spec/invidious/videos/related_videos_extract_spec.cr
Normal file
64
spec/invidious/videos/related_videos_extract_spec.cr
Normal 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
|
||||||
@ -51,10 +51,45 @@ module Invidious::Videos::Parser
|
|||||||
"length_seconds" => JSON::Any.new(length || "0"),
|
"length_seconds" => JSON::Any.new(length || "0"),
|
||||||
"short_view_count" => JSON::Any.new(short_view_count || "0"),
|
"short_view_count" => JSON::Any.new(short_view_count || "0"),
|
||||||
"author_verified" => JSON::Any.new(author_verified),
|
"author_verified" => JSON::Any.new(author_verified),
|
||||||
|
"author_channels" => JSON::Any.new(parse_related_authors(related).to_json),
|
||||||
"published" => JSON::Any.new(published || ""),
|
"published" => JSON::Any.new(published || ""),
|
||||||
}
|
}
|
||||||
end
|
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)
|
def extract_video_info(video_id : String)
|
||||||
# Fetch data from the player endpoint
|
# Fetch data from the player endpoint
|
||||||
player_response = YoutubeAPI.player(video_id: video_id)
|
player_response = YoutubeAPI.player(video_id: video_id)
|
||||||
|
|||||||
14
src/invidious/views/components/related_video_authors.ecr
Normal file
14
src/invidious/views/components/related_video_authors.ecr
Normal 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" %> <i class="icon ion ion-md-checkmark-circle"></i><% end -%>
|
||||||
|
<%- unless author["ucid"].empty? -%></a><% end -%>
|
||||||
|
<%- end -%>
|
||||||
|
</b>
|
||||||
@ -389,11 +389,7 @@ we're going to need to do it here in order to allow for translations.
|
|||||||
|
|
||||||
<h5 class="pure-g">
|
<h5 class="pure-g">
|
||||||
<div class="pure-u-14-24">
|
<div class="pure-u-14-24">
|
||||||
<% if !rv["ucid"].empty? %>
|
<%= rendered "components/related_video_authors" %>
|
||||||
<b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
|
|
||||||
<% else %>
|
|
||||||
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pure-u-10-24" style="text-align:right">
|
<div class="pure-u-10-24" style="text-align:right">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user