Merge 08d5420ad58dda71d004405f86a564f298f0644a into 3b21f78e99c1416f65dd3e64f1aa43b2f4dcad7a

This commit is contained in:
Shaurya Swarup 2026-10-24 04:03:16 +07:00 committed by GitHub
commit 31a9f2db19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 257 additions and 28 deletions

View 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 &amp; b &lt;c&gt;")
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

View File

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

View File

@ -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)
# Copy the text chunk between this run and the previous one.
chunk = String.build do |str2|
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
# and the special function defined above.
cmd_content = String.build(cmd_length) do |str2|
copy_string(str2, iter, cmd_length)
cmd_content = String.build(run[:length]) do |str2|
copy_string(str2, iter, run[:length])
end
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)
end
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
# 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
index += run[:length]
end
# 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