mirror of
https://github.com/iv-org/invidious.git
synced 2026-10-24 22:09:13 -05:00
Merge 08d5420ad58dda71d004405f86a564f298f0644a into 3b21f78e99c1416f65dd3e64f1aa43b2f4dcad7a
This commit is contained in:
commit
31a9f2db19
130
spec/invidious/videos/description_spec.cr
Normal file
130
spec/invidious/videos/description_spec.cr
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
require "../../parsers_helper.cr"
|
||||||
|
|
||||||
|
# Fixtures below are taken verbatim (structurally) from the InnerTube payloads
|
||||||
|
# that Invidious receives for `commentEntityPayload.properties.content` and for
|
||||||
|
# video `attributedDescription`s. Custom channel emoji are shipped as an image
|
||||||
|
# in `attachmentRuns`, with only a `:_EmojiName:` placeholder (followed by
|
||||||
|
# U+25A1 padding) left behind in `content`. Plain unicode emoji are the opposite:
|
||||||
|
# they are already in `content` and their attachment run points at YouTube's own
|
||||||
|
# emoji sprite on `www.youtube.com`, which cannot be served through /ggpht.
|
||||||
|
private def attachment_run(label : String, start_index : Int32, length : Int32, url : String) : String
|
||||||
|
<<-JSON
|
||||||
|
{
|
||||||
|
"startIndex": #{start_index},
|
||||||
|
"length": #{length},
|
||||||
|
"element": {
|
||||||
|
"type": {
|
||||||
|
"imageType": {
|
||||||
|
"image": {
|
||||||
|
"sources": [{"url": "#{url}", "width": 16, "height": 16}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"accessibilityProperties": {"label": "#{label}"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"alignment": "ALIGNMENT_VERTICAL_CENTER"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
end
|
||||||
|
|
||||||
|
private def command_run(start_index : Int32, length : Int32, url : String) : String
|
||||||
|
<<-JSON
|
||||||
|
{
|
||||||
|
"startIndex": #{start_index},
|
||||||
|
"length": #{length},
|
||||||
|
"onTap": {"innertubeCommand": {"urlEndpoint": {"url": "#{url}"}}}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
end
|
||||||
|
|
||||||
|
CUSTOM_EMOJI_URL = "https://yt3.googleusercontent.com/6-Hzg3v1TFZI9DifzoYrJZYYTsu4quApCRT461xkMjDhaR4xUP8FbsDZe4xIdcTQi_oGPOg4=s16-w24-h24-c-k-nd"
|
||||||
|
# YouTube's own emoji sprite, served from www.youtube.com (not /ggpht-able).
|
||||||
|
UNICODE_EMOJI_URL = "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f600.png"
|
||||||
|
|
||||||
|
Spectator.describe "parse_description" do
|
||||||
|
it "returns an empty string for nil content" do
|
||||||
|
expect(parse_description(nil, "vid")).to eq("")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns an empty string for empty content" do
|
||||||
|
expect(parse_description(JSON.parse(%({"content": ""})), "vid")).to eq("")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "escapes plain content when there are no runs" do
|
||||||
|
desc = JSON.parse(%({"content": "a & b <c>"}))
|
||||||
|
expect(parse_description(desc, "vid")).to eq("a & b <c>")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders custom channel emoji as proxied images" do
|
||||||
|
content = "hi :_EmojiOne:\u25A1\u25A1 and :_EmojiTwo: end"
|
||||||
|
desc = JSON.parse(
|
||||||
|
%({"content": "#{content}", "attachmentRuns": [) +
|
||||||
|
attachment_run("EmojiOne", 3, 11, CUSTOM_EMOJI_URL) + "," +
|
||||||
|
attachment_run("EmojiTwo", 21, 11, CUSTOM_EMOJI_URL) + "]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
html = parse_description(desc, "vid").to_s
|
||||||
|
|
||||||
|
expect(html).to eq(
|
||||||
|
%(hi ) +
|
||||||
|
%(<img alt="EmojiOne" src="/ggpht/6-Hzg3v1TFZI9DifzoYrJZYYTsu4quApCRT461xkMjDhaR4xUP8FbsDZe4xIdcTQi_oGPOg4=s16-w24-h24-c-k-nd" title="EmojiOne" class="channel-emoji" />) +
|
||||||
|
%( and ) +
|
||||||
|
%(<img alt="EmojiTwo" src="/ggpht/6-Hzg3v1TFZI9DifzoYrJZYYTsu4quApCRT461xkMjDhaR4xUP8FbsDZe4xIdcTQi_oGPOg4=s16-w24-h24-c-k-nd" title="EmojiTwo" class="channel-emoji" />) +
|
||||||
|
%( end)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not leave placeholders or padding behind for custom emoji" do
|
||||||
|
content = "hi :_EmojiOne:\u25A1\u25A1"
|
||||||
|
desc = JSON.parse(
|
||||||
|
%({"content": "#{content}", "attachmentRuns": [) +
|
||||||
|
attachment_run("EmojiOne", 3, 11, CUSTOM_EMOJI_URL) + "]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
html = parse_description(desc, "vid").to_s
|
||||||
|
|
||||||
|
expect(html).to_not contain(":_EmojiOne:")
|
||||||
|
expect(html).to_not contain("\u25A1")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "leaves plain unicode emoji alone instead of substituting an image" do
|
||||||
|
content = "Smiley: \u{1F600} ok"
|
||||||
|
desc = JSON.parse(
|
||||||
|
%({"content": "#{content}", "attachmentRuns": [) +
|
||||||
|
attachment_run("\u{1F600}", 8, 2, UNICODE_EMOJI_URL) + "]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
html = parse_description(desc, "vid").to_s
|
||||||
|
|
||||||
|
expect(html).to eq("Smiley: \u{1F600} ok")
|
||||||
|
expect(html).to_not contain("<img")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "still renders commandRuns (links and timestamps)" do
|
||||||
|
desc = JSON.parse(
|
||||||
|
%({"content": "see here", "commandRuns": [) +
|
||||||
|
command_run(4, 4, "https://example.com/x") + "]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
html = parse_description(desc, "vid").to_s
|
||||||
|
|
||||||
|
expect(html).to contain(%(<a href="https://example.com/x">here</a>))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders commandRuns and attachmentRuns interleaved in index order" do
|
||||||
|
desc = JSON.parse(
|
||||||
|
%({"content": "0:48 :_E1: tail", "commandRuns": [) +
|
||||||
|
command_run(0, 4, "https://example.com/t") +
|
||||||
|
%(], "attachmentRuns": [) +
|
||||||
|
attachment_run("E1", 5, 5, CUSTOM_EMOJI_URL) + "]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
html = parse_description(desc, "vid").to_s
|
||||||
|
|
||||||
|
expect(html).to contain(%(<a href="https://example.com/t">0:48</a>))
|
||||||
|
expect(html).to contain(%(class="channel-emoji" />))
|
||||||
|
expect(html).to contain(" tail")
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -150,7 +150,7 @@ module Invidious::Comments
|
|||||||
toolbar_mutation = mutations.find { |i| i.dig?("entityKey") == toolbar_key }
|
toolbar_mutation = mutations.find { |i| i.dig?("entityKey") == toolbar_key }
|
||||||
|
|
||||||
if !comment_mutation.nil? && !toolbar_mutation.nil?
|
if !comment_mutation.nil? && !toolbar_mutation.nil?
|
||||||
# todo parse styleRuns, commandRuns and attachmentRuns for comments
|
# todo parse styleRuns for comments
|
||||||
html_content = parse_description(comment_mutation.dig("payload", "commentEntityPayload", "properties", "content"), id)
|
html_content = parse_description(comment_mutation.dig("payload", "commentEntityPayload", "properties", "content"), id)
|
||||||
comment_author = comment_mutation.dig("payload", "commentEntityPayload", "author")
|
comment_author = comment_mutation.dig("payload", "commentEntityPayload", "author")
|
||||||
json.field "authorId", comment_author["channelId"].as_s
|
json.field "authorId", comment_author["channelId"].as_s
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
require "html"
|
||||||
require "json"
|
require "json"
|
||||||
require "uri"
|
require "uri"
|
||||||
|
|
||||||
@ -12,6 +13,20 @@ private def utf16_length(content : String) : Int32
|
|||||||
u16_size
|
u16_size
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private def skip_string(iter : Iterator, count : Int) : Int
|
||||||
|
skipped = 0
|
||||||
|
while skipped < count
|
||||||
|
cp = iter.next
|
||||||
|
break if cp.is_a?(Iterator::Stop)
|
||||||
|
|
||||||
|
# A codepoint from the SMP counts twice
|
||||||
|
skipped += 1 if cp > 0xFFFF
|
||||||
|
skipped += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
return skipped
|
||||||
|
end
|
||||||
|
|
||||||
private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int
|
private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int
|
||||||
copied = 0
|
copied = 0
|
||||||
while copied < count
|
while copied < count
|
||||||
@ -40,19 +55,60 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
|||||||
return copied
|
return copied
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# YouTube ships custom channel emoji as an image inside an `attachmentRuns`
|
||||||
|
# entry, while `content` only holds a `:_EmojiName:` placeholder followed by
|
||||||
|
# U+25A1 padding characters. Plain unicode emoji are the opposite: they are
|
||||||
|
# already present verbatim in `content`, and their attachment run points at
|
||||||
|
# YouTube's own emoji sprite (`www.youtube.com/s/gaming/emoji/...`), which the
|
||||||
|
# `/ggpht` proxy cannot serve. Therefore only the former are substituted with
|
||||||
|
# an image tag; the latter are left as the text characters they already are.
|
||||||
|
private def attachment_run_to_html(run : JSON::Any) : String?
|
||||||
|
image = run.dig?("element", "type", "imageType", "image")
|
||||||
|
return if image.nil?
|
||||||
|
|
||||||
|
sources = image["sources"].as_a?
|
||||||
|
return if sources.nil? || sources.empty?
|
||||||
|
|
||||||
|
image_url = sources[0]["url"].as_s?
|
||||||
|
return if image_url.nil?
|
||||||
|
|
||||||
|
uri = URI.parse(image_url)
|
||||||
|
return unless uri.host.try &.ends_with?("googleusercontent.com")
|
||||||
|
|
||||||
|
alt = run.dig?("element", "properties", "accessibilityProperties", "label")
|
||||||
|
.try &.as_s || ""
|
||||||
|
alt = HTML.escape(alt)
|
||||||
|
|
||||||
|
return String.build do |str|
|
||||||
|
str << %(<img alt=") << alt << "\" "
|
||||||
|
str << %(src="/ggpht) << uri.request_target << "\" "
|
||||||
|
str << %(title=") << alt << "\" "
|
||||||
|
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?
|
||||||
|
|
||||||
content = desc["content"].as_s
|
content = desc["content"].as_s
|
||||||
return "" if content.empty?
|
return "" if content.empty?
|
||||||
|
|
||||||
commands = desc["commandRuns"]?.try &.as_a
|
# Both `commandRuns` (links, timestamps, hashtags) and `attachmentRuns`
|
||||||
if commands.nil?
|
# (emoji) are indexed in UTF-16 code units over `content`, and the two sets
|
||||||
# Slightly faster than HTML.escape, as we're only doing one pass on
|
# can be interleaved, so they have to be rendered in a single ordered pass.
|
||||||
# the string instead of five for the standard library
|
runs = [] of NamedTuple(start: Int32, length: Int32, node: JSON::Any, kind: Symbol)
|
||||||
return String.build do |str|
|
|
||||||
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
desc["commandRuns"]?.try &.as_a.try do |command_runs|
|
||||||
copy_string(str, content.each_codepoint, content_size)
|
command_runs.each do |run|
|
||||||
|
runs << {start: run["startIndex"].as_i, length: run["length"].as_i,
|
||||||
|
node: run, kind: :command}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
desc["attachmentRuns"]?.try &.as_a.try do |attachment_runs|
|
||||||
|
attachment_runs.each do |run|
|
||||||
|
runs << {start: run["startIndex"].as_i, length: run["length"].as_i,
|
||||||
|
node: run, kind: :attachment}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -60,36 +116,79 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
# (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
|
||||||
# copied byte in a special manner, preventing the use of regular string copy.
|
# copied byte in a special manner, preventing the use of regular string copy.
|
||||||
|
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
||||||
|
|
||||||
|
if runs.empty?
|
||||||
|
# Slightly faster than HTML.escape, as we're only doing one pass on
|
||||||
|
# the string instead of five for the standard library
|
||||||
|
return String.build do |str|
|
||||||
|
copy_string(str, content.each_codepoint, content_size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
runs.sort_by! { |run| run[:start] }
|
||||||
|
|
||||||
iter = content.each_codepoint
|
iter = content.each_codepoint
|
||||||
|
|
||||||
index = 0
|
index = 0
|
||||||
|
strip_padding = false
|
||||||
|
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
commands.each do |command|
|
runs.each do |run|
|
||||||
cmd_start = command["startIndex"].as_i
|
# Skip runs that overlap an already rendered one: consuming their span
|
||||||
cmd_length = command["length"].as_i
|
# again would advance the iterator past the text they cover.
|
||||||
|
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 one.
|
||||||
length = cmd_start - index
|
chunk = String.build do |str2|
|
||||||
index += copy_string(str, iter, length)
|
copy_string(str2, iter, run[:start] - index)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Drop the placeholder padding that follows a substituted channel emoji.
|
||||||
|
if strip_padding
|
||||||
|
chunk = chunk.sub(/\A\x{25A1}+/, "")
|
||||||
|
strip_padding = false
|
||||||
|
end
|
||||||
|
|
||||||
|
str << chunk
|
||||||
|
index = run[:start]
|
||||||
|
|
||||||
|
case run[:kind]
|
||||||
|
when :command
|
||||||
# We need to copy the command's text using the iterator
|
# We need to copy the command'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|
|
cmd_content = String.build(run[:length]) do |str2|
|
||||||
copy_string(str2, iter, cmd_length)
|
copy_string(str2, iter, run[:length])
|
||||||
end
|
end
|
||||||
|
|
||||||
link = cmd_content
|
link = cmd_content
|
||||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
if on_tap = run[:node].dig?("onTap", "innertubeCommand")
|
||||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
||||||
end
|
end
|
||||||
str << link
|
str << link
|
||||||
index += cmd_length
|
when :attachment
|
||||||
|
if emoji = attachment_run_to_html(run[:node])
|
||||||
|
skip_string(iter, run[:length])
|
||||||
|
str << emoji
|
||||||
|
strip_padding = true
|
||||||
|
else
|
||||||
|
# Not a channel emoji: the span is already the emoji itself.
|
||||||
|
copy_string(str, iter, run[:length])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Copy the end of the string (past the last command).
|
index += run[:length]
|
||||||
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
end
|
||||||
remaining_length = content_size - index
|
|
||||||
copy_string(str, iter, remaining_length) if remaining_length > 0
|
# Copy the end of the string (past the last run).
|
||||||
|
chunk = String.build do |str2|
|
||||||
|
copy_string(str2, iter, content_size - index)
|
||||||
|
end
|
||||||
|
|
||||||
|
if strip_padding
|
||||||
|
chunk = chunk.sub(/\A\x{25A1}+/, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
str << chunk
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user