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 }
|
||||
|
||||
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)
|
||||
comment_author = comment_mutation.dig("payload", "commentEntityPayload", "author")
|
||||
json.field "authorId", comment_author["channelId"].as_s
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
require "html"
|
||||
require "json"
|
||||
require "uri"
|
||||
|
||||
@ -12,6 +13,20 @@ private def utf16_length(content : String) : Int32
|
||||
u16_size
|
||||
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
|
||||
copied = 0
|
||||
while copied < count
|
||||
@ -40,19 +55,60 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
||||
return copied
|
||||
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?
|
||||
return "" if desc.nil?
|
||||
|
||||
content = desc["content"].as_s
|
||||
return "" if content.empty?
|
||||
|
||||
commands = desc["commandRuns"]?.try &.as_a
|
||||
if commands.nil?
|
||||
# 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|
|
||||
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
||||
copy_string(str, content.each_codepoint, content_size)
|
||||
# Both `commandRuns` (links, timestamps, hashtags) and `attachmentRuns`
|
||||
# (emoji) are indexed in UTF-16 code units over `content`, and the two sets
|
||||
# can be interleaved, so they have to be rendered in a single ordered pass.
|
||||
runs = [] of NamedTuple(start: Int32, length: Int32, node: JSON::Any, kind: Symbol)
|
||||
|
||||
desc["commandRuns"]?.try &.as_a.try do |command_runs|
|
||||
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
|
||||
|
||||
@ -60,36 +116,79 @@ def parse_description(desc, video_id : String) : String?
|
||||
# (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
|
||||
# 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
|
||||
|
||||
index = 0
|
||||
strip_padding = false
|
||||
|
||||
return String.build do |str|
|
||||
commands.each do |command|
|
||||
cmd_start = command["startIndex"].as_i
|
||||
cmd_length = command["length"].as_i
|
||||
runs.each do |run|
|
||||
# Skip runs that overlap an already rendered one: consuming their span
|
||||
# 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.
|
||||
length = cmd_start - index
|
||||
index += copy_string(str, iter, length)
|
||||
|
||||
# We need to copy the command's text using the iterator
|
||||
# and the special function defined above.
|
||||
cmd_content = String.build(cmd_length) do |str2|
|
||||
copy_string(str2, iter, cmd_length)
|
||||
# Copy the text chunk between this run and the previous one.
|
||||
chunk = String.build do |str2|
|
||||
copy_string(str2, iter, run[:start] - index)
|
||||
end
|
||||
|
||||
link = cmd_content
|
||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
||||
# Drop the placeholder padding that follows a substituted channel emoji.
|
||||
if strip_padding
|
||||
chunk = chunk.sub(/\A\x{25A1}+/, "")
|
||||
strip_padding = false
|
||||
end
|
||||
str << link
|
||||
index += cmd_length
|
||||
|
||||
str << chunk
|
||||
index = run[:start]
|
||||
|
||||
case run[:kind]
|
||||
when :command
|
||||
# We need to copy the command's text using the iterator
|
||||
# and the special function defined above.
|
||||
cmd_content = String.build(run[:length]) do |str2|
|
||||
copy_string(str2, iter, run[:length])
|
||||
end
|
||||
|
||||
link = cmd_content
|
||||
if on_tap = run[:node].dig?("onTap", "innertubeCommand")
|
||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
||||
end
|
||||
str << link
|
||||
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
|
||||
|
||||
index += run[:length]
|
||||
end
|
||||
|
||||
# Copy the end of the string (past the last command).
|
||||
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user