mirror of
https://github.com/iv-org/invidious.git
synced 2026-08-17 05:20:51 -05:00
Merge f18762bcde3c4e38e7e5d5379fa80ed6f982b45e into d10f2a48021f1768f2d6ff3bd1b9f3de4e0b2d22
This commit is contained in:
commit
3d69caffc2
103
spec/invidious/videos/description_spec.cr
Normal file
103
spec/invidious/videos/description_spec.cr
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
require "../../parsers_helper.cr"
|
||||||
|
|
||||||
|
Spectator.describe "parse_description" do
|
||||||
|
it "renders custom channel emoji as an image" do
|
||||||
|
# Custom emoji only exist in the attachment run; the text content carries
|
||||||
|
# the ":shortcode:" placeholder, which must not be shown as-is.
|
||||||
|
raw = {
|
||||||
|
"content" => ":face-red-heart-shape: hello",
|
||||||
|
"attachmentRuns" => [
|
||||||
|
{
|
||||||
|
"startIndex" => 0,
|
||||||
|
"length" => 22,
|
||||||
|
"element" => {
|
||||||
|
"type" => {
|
||||||
|
"imageType" => {
|
||||||
|
"image" => {
|
||||||
|
"sources" => [
|
||||||
|
{"url" => "https://lh3.googleusercontent.com/abc=s16-w24-h24-c-k-nd", "width" => 16, "height" => 16},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"properties" => {
|
||||||
|
"accessibilityProperties" => {"label" => "face-red-heart-shape"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parse_description(JSON.parse(raw.to_json), "")
|
||||||
|
|
||||||
|
expect(result).to eq(
|
||||||
|
%(<img alt="face-red-heart-shape" src="/ggpht/abc=s16-w24-h24-c-k-nd" ) +
|
||||||
|
%(title="face-red-heart-shape" width="16" height="16" class="channel-emoji" /> hello)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "leaves standard unicode emoji untouched" do
|
||||||
|
# Standard emoji also come with an attachment run, but the text content is
|
||||||
|
# already the emoji character, so it must be kept as text.
|
||||||
|
raw = {
|
||||||
|
"content" => "Nice sharing 🎉",
|
||||||
|
"attachmentRuns" => [
|
||||||
|
{
|
||||||
|
"startIndex" => 13,
|
||||||
|
"length" => 2,
|
||||||
|
"element" => {
|
||||||
|
"type" => {
|
||||||
|
"imageType" => {
|
||||||
|
"image" => {
|
||||||
|
"sources" => [
|
||||||
|
{"url" => "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f389.png", "width" => 16, "height" => 16},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"properties" => {
|
||||||
|
"accessibilityProperties" => {"label" => "🎉"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parse_description(JSON.parse(raw.to_json), "")
|
||||||
|
|
||||||
|
expect(result).to eq("Nice sharing 🎉")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "keeps text offsets correct when an emoji precedes other content" do
|
||||||
|
# The emoji run is consumed from the same iterator as the surrounding
|
||||||
|
# text, so a wrong length here would shift everything after it.
|
||||||
|
raw = {
|
||||||
|
"content" => "a:face-red-heart-shape:b",
|
||||||
|
"attachmentRuns" => [
|
||||||
|
{
|
||||||
|
"startIndex" => 1,
|
||||||
|
"length" => 22,
|
||||||
|
"element" => {
|
||||||
|
"type" => {
|
||||||
|
"imageType" => {
|
||||||
|
"image" => {
|
||||||
|
"sources" => [
|
||||||
|
{"url" => "https://lh3.googleusercontent.com/abc", "width" => 16, "height" => 16},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"properties" => {
|
||||||
|
"accessibilityProperties" => {"label" => "face-red-heart-shape"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parse_description(JSON.parse(raw.to_json), "")
|
||||||
|
|
||||||
|
expect(result).to start_with("a<img ")
|
||||||
|
expect(result).to end_with("/>b")
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -40,6 +40,37 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
|||||||
return copied
|
return copied
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Custom channel emoji are sent as an attachment run holding the image, while
|
||||||
|
# the text content only carries a ":shortcode:" placeholder. If the image is
|
||||||
|
# not substituted in, that raw placeholder is what gets displayed.
|
||||||
|
#
|
||||||
|
# Standard Unicode emoji are sent as attachment runs too, but their text
|
||||||
|
# content is the emoji character itself, which already renders correctly. Those
|
||||||
|
# are left as-is, so we don't proxy an image for every single emoji.
|
||||||
|
private def attachment_run_to_html(run : JSON::Any, text : String) : String
|
||||||
|
return text if !(text.starts_with?(':') && text.ends_with?(':') && text.size > 2)
|
||||||
|
|
||||||
|
source = run.dig?("element", "type", "imageType", "image", "sources", 0)
|
||||||
|
return text if source.nil?
|
||||||
|
|
||||||
|
url = source["url"]?.try &.as_s
|
||||||
|
return text if url.nil?
|
||||||
|
|
||||||
|
label = run.dig?("element", "properties", "accessibilityProperties", "label")
|
||||||
|
.try &.as_s || text
|
||||||
|
width = source["width"]?.try &.as_i? || 16
|
||||||
|
height = source["height"]?.try &.as_i? || 16
|
||||||
|
|
||||||
|
return String.build do |str|
|
||||||
|
str << %(<img alt=") << HTML.escape(label) << %(" )
|
||||||
|
str << %(src="/ggpht) << URI.parse(url).request_target << %(" )
|
||||||
|
str << %(title=") << HTML.escape(label) << %(" )
|
||||||
|
str << %(width=") << width << %(" )
|
||||||
|
str << %(height=") << height << %(" )
|
||||||
|
str << %(class="channel-emoji" />)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def parse_description(desc, video_id : String) : String?
|
def parse_description(desc, video_id : String) : String?
|
||||||
return "" if desc.nil?
|
return "" if desc.nil?
|
||||||
|
|
||||||
@ -47,7 +78,9 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
return "" if content.empty?
|
return "" if content.empty?
|
||||||
|
|
||||||
commands = desc["commandRuns"]?.try &.as_a
|
commands = desc["commandRuns"]?.try &.as_a
|
||||||
if commands.nil?
|
attachments = desc["attachmentRuns"]?.try &.as_a
|
||||||
|
|
||||||
|
if commands.nil? && attachments.nil?
|
||||||
# Slightly faster than HTML.escape, as we're only doing one pass on
|
# Slightly faster than HTML.escape, as we're only doing one pass on
|
||||||
# the string instead of five for the standard library
|
# the string instead of five for the standard library
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
@ -56,6 +89,17 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Both kinds of run index into the same string, so they have to be walked
|
||||||
|
# together in positional order.
|
||||||
|
runs = [] of {Int32, Int32, Bool, JSON::Any}
|
||||||
|
commands.try &.each do |command|
|
||||||
|
runs << {command["startIndex"].as_i, command["length"].as_i, false, command}
|
||||||
|
end
|
||||||
|
attachments.try &.each do |attachment|
|
||||||
|
runs << {attachment["startIndex"].as_i, attachment["length"].as_i, true, attachment}
|
||||||
|
end
|
||||||
|
runs.sort_by! { |run| run[0] }
|
||||||
|
|
||||||
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
|
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
|
||||||
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
|
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
|
||||||
# automatically decoded by the JSON parser. It means that we need to count
|
# automatically decoded by the JSON parser. It means that we need to count
|
||||||
@ -65,26 +109,32 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
index = 0
|
index = 0
|
||||||
|
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
commands.each do |command|
|
runs.each do |(run_start, run_length, is_attachment, run)|
|
||||||
cmd_start = command["startIndex"].as_i
|
# A command and an attachment can cover the same characters. The
|
||||||
cmd_length = command["length"].as_i
|
# iterator can only move forward, so skip anything already consumed.
|
||||||
|
next if run_start < index
|
||||||
|
|
||||||
# Copy the text chunk between this command and the previous if needed.
|
# Copy the text chunk between this run and the previous if needed.
|
||||||
length = cmd_start - index
|
length = run_start - index
|
||||||
index += copy_string(str, iter, length)
|
index += copy_string(str, iter, length)
|
||||||
|
|
||||||
# We need to copy the command's text using the iterator
|
# We need to copy the run's text using the iterator
|
||||||
# and the special function defined above.
|
# and the special function defined above.
|
||||||
cmd_content = String.build(cmd_length) do |str2|
|
run_content = String.build(run_length) do |str2|
|
||||||
copy_string(str2, iter, cmd_length)
|
copy_string(str2, iter, run_length)
|
||||||
end
|
end
|
||||||
|
|
||||||
link = cmd_content
|
if is_attachment
|
||||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
str << attachment_run_to_html(run, run_content)
|
||||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
else
|
||||||
|
link = run_content
|
||||||
|
if on_tap = run.dig?("onTap", "innertubeCommand")
|
||||||
|
link = parse_link_endpoint(on_tap, run_content, video_id)
|
||||||
|
end
|
||||||
|
str << link
|
||||||
end
|
end
|
||||||
str << link
|
|
||||||
index += cmd_length
|
index += run_length
|
||||||
end
|
end
|
||||||
|
|
||||||
# Copy the end of the string (past the last command).
|
# Copy the end of the string (past the last command).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user