mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 09:02:45 -05:00
fix(description): render custom emoji attachments in comments and descriptions
YouTube's new commentEntityPayload format carries emojis as attachmentRuns (startIndex/length into the plain text plus an image reference), which parse_description() ignored so far - making every custom channel emoji disappear from comments and video descriptions. Merge attachmentRuns with the existing commandRuns handling (they share the same UTF-16 coordinate space): - Images hosted on /ggpht-proxiable domains (yt3.ggpht.com custom channel emojis) are rendered as <img class="channel-emoji"> tags, reusing the proxy and CSS class from the legacy comment renderer. - Attachments pointing at www.youtube.com (standard unicode emojis rendered as images by YouTube) are skipped, keeping their original characters - they render fine natively without contacting YouTube's servers. Fixes #5888
This commit is contained in:
parent
163380d339
commit
080f87f0f5
50
spec/invidious/videos/description_attachment_runs_spec.cr
Normal file
50
spec/invidious/videos/description_attachment_runs_spec.cr
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
require "../../parsers_helper.cr"
|
||||||
|
|
||||||
|
Spectator.describe "parse_description" do
|
||||||
|
# Real InnerTube data captured from YouTube comments (2026-08).
|
||||||
|
# Standard emojis are attached as images hosted on www.youtube.com, while
|
||||||
|
# custom channel emojis are hosted on yt3.ggpht.com.
|
||||||
|
|
||||||
|
it "keeps standard emoji characters when their image is not proxyable" do
|
||||||
|
desc = JSON.parse(%q({"content":"Not going to lie. Your intro got me to sub! Watching you, everyone deserves to grow❤️","attachmentRuns":[{"startIndex":84,"length":1,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u2764.png","width":16,"height":16}]},"playbackState":"IMAGE_PLAYBACK_STATE_STOPPED"}},"properties":{"layoutProperties":{"height":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"width":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"margin":{"left":{"value":2,"unit":"DIMENSION_UNIT_POINT"},"right":{"value":2,"unit":"DIMENSION_UNIT_POINT"}}},"accessibilityProperties":{"label":"❤"}}},"alignment":"ALIGNMENT_VERTICAL_CENTER"}]}))
|
||||||
|
|
||||||
|
html = parse_description(desc, "Gy3bmuzmWMM")
|
||||||
|
|
||||||
|
expect(html).to eq(%(Not going to lie. Your intro got me to sub! Watching you, everyone deserves to grow\u{2764}\u{fe0f}))
|
||||||
|
expect(html.not_nil!.includes?("img")).to be_false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles SMP emojis (2 UTF-16 units) right after multibyte-free text" do
|
||||||
|
desc = JSON.parse(%q({"content":"Feyrer is on 🔥! Thank you again, Master Jedi.","attachmentRuns":[{"startIndex":13,"length":2,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f525.png","width":16,"height":16}]},"playbackState":"IMAGE_PLAYBACK_STATE_STOPPED"}},"properties":{"layoutProperties":{"height":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"width":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"margin":{"left":{"value":2,"unit":"DIMENSION_UNIT_POINT"},"right":{"value":2,"unit":"DIMENSION_UNIT_POINT"}}},"accessibilityProperties":{"label":"🔥"}}},"alignment":"ALIGNMENT_VERTICAL_CENTER"}]}))
|
||||||
|
|
||||||
|
html = parse_description(desc, "Gy3bmuzmWMM")
|
||||||
|
|
||||||
|
expect(html).to eq("Feyrer is on \u{1F525}! Thank you again, Master Jedi.")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders proxied channel emojis as images" do
|
||||||
|
# Fixture derived from a real capture: only the image URL was changed to
|
||||||
|
# a yt3.ggpht.com one (custom channel emoji) and its label set accordingly.
|
||||||
|
desc = JSON.parse(%q({"content":"Great video :_ToonThumbsUp: love it","attachmentRuns":[{"startIndex":12,"length":15,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://yt3.ggpht.com/UCxyz/aKgIYbrtFqmL8gT4576oCA=w24-h24-c-k-nd","width":24,"height":24}]}}},"properties":{"accessibilityProperties":{"label":"ToonThumbsUp"}}}}]}))
|
||||||
|
|
||||||
|
html = parse_description(desc, "Gy3bmuzmWMM")
|
||||||
|
|
||||||
|
expect(html).to eq(%(Great video <img alt="ToonThumbsUp" title="ToonThumbsUp" src="/ggpht/UCxyz/aKgIYbrtFqmL8gT4576oCA=w24-h24-c-k-nd" width="24" height="24" class="channel-emoji" /> love it))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders links and channel emojis in order when both are present" do
|
||||||
|
# Minimal synthetic fixture: a command run without onTap stays as plain
|
||||||
|
# text, followed by a proxyable emoji attachment.
|
||||||
|
desc = JSON.parse(%q({"content":"see docs now :ToonFire:","attachmentRuns":[{"startIndex":13,"length":10,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://yt3.ggpht.com/x/fire.png"}]}}},"properties":{"accessibilityProperties":{"label":"ToonFire"}}}}],"commandRuns":[{"startIndex":4,"length":4}]}))
|
||||||
|
|
||||||
|
html = parse_description(desc, "Gy3bmuzmWMM")
|
||||||
|
|
||||||
|
expect(html).to eq(%(see docs now <img alt="ToonFire" title="ToonFire" src="/ggpht/x/fire.png" width="16" height="16" class="channel-emoji" />))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "escapes HTML entities in the fast path" do
|
||||||
|
desc = JSON.parse(%q({"content":"a<b & c"}))
|
||||||
|
|
||||||
|
expect(parse_description(desc, "x")).to eq("a<b & c")
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -40,14 +40,60 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
|||||||
return copied
|
return copied
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private def skip_string(iter : Iterator, count : Int) : Nil
|
||||||
|
copied = 0
|
||||||
|
while copied < count
|
||||||
|
cp = iter.next
|
||||||
|
break if cp.is_a?(Iterator::Stop)
|
||||||
|
|
||||||
|
# A codepoint from the SMP counts twice
|
||||||
|
copied += 1 if cp > 0xFFFF
|
||||||
|
copied += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Builds an <img> tag for a given attachment run. Only images hosted on
|
||||||
|
# domains proxied through /ggpht are supported; attachments pointing at
|
||||||
|
# www.youtube.com (standard unicode emojis rendered as images by YouTube)
|
||||||
|
# return nil, as the underlying text characters already render fine.
|
||||||
|
private def attachment_to_img(attachment : JSON::Any) : String?
|
||||||
|
source = attachment.dig?("element", "type", "imageType", "image", "sources", 0)
|
||||||
|
url = source.try &.dig?("url").try &.as_s
|
||||||
|
return unless url
|
||||||
|
|
||||||
|
uri = URI.parse(url)
|
||||||
|
host = uri.host.try &.downcase
|
||||||
|
return unless host.try(&.ends_with?("ggpht.com")) ||
|
||||||
|
host.try(&.ends_with?("googleusercontent.com"))
|
||||||
|
|
||||||
|
alt = attachment.dig?("element", "properties", "accessibilityProperties", "label").try &.as_s || ""
|
||||||
|
width = source.try &.dig?("width").try &.as_i || 16
|
||||||
|
height = source.try &.dig?("height").try &.as_i || 16
|
||||||
|
|
||||||
|
String.build do |str|
|
||||||
|
str << %(<img alt=") << HTML.escape(alt) << "\" "
|
||||||
|
str << %(title=") << HTML.escape(alt) << "\" "
|
||||||
|
str << %(src="/ggpht) << uri.request_target << "\" "
|
||||||
|
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?
|
||||||
|
|
||||||
content = desc["content"].as_s
|
content = desc["content"].as_s
|
||||||
return "" if content.empty?
|
return "" if content.empty?
|
||||||
|
|
||||||
commands = desc["commandRuns"]?.try &.as_a
|
commands = (desc["commandRuns"]?.try &.as_a) || Array(JSON::Any).new
|
||||||
if commands.nil?
|
attachments = (desc["attachmentRuns"]?.try &.as_a) || Array(JSON::Any).new
|
||||||
|
# Only image attachments are supported for now
|
||||||
|
attachments = attachments.select do |attachment|
|
||||||
|
attachment.dig?("element", "type", "imageType", "image", "sources", 0, "url")
|
||||||
|
end
|
||||||
|
|
||||||
|
if commands.empty? && attachments.empty?
|
||||||
# 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|
|
||||||
@ -60,34 +106,53 @@ 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.
|
||||||
|
#
|
||||||
|
# Links (commandRuns) and image attachments (attachmentRuns, used for custom
|
||||||
|
# channel emojis) share the same startIndex/length coordinate space and are
|
||||||
|
# merged into one ordered list of runs below.
|
||||||
|
runs = [] of {Int32, Int32, JSON::Any, Bool}
|
||||||
|
commands.each do |command|
|
||||||
|
runs << {command["startIndex"].as_i, command["length"].as_i, command, false}
|
||||||
|
end
|
||||||
|
attachments.each do |attachment|
|
||||||
|
runs << {attachment["startIndex"].as_i, attachment["length"].as_i, attachment, true}
|
||||||
|
end
|
||||||
|
runs.sort_by!(&.[0])
|
||||||
|
|
||||||
iter = content.each_codepoint
|
iter = content.each_codepoint
|
||||||
|
|
||||||
index = 0
|
index = 0
|
||||||
|
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
commands.each do |command|
|
runs.each do |(start, length, node, is_attachment)|
|
||||||
cmd_start = command["startIndex"].as_i
|
# Defensive check against overlapping runs
|
||||||
cmd_length = command["length"].as_i
|
next if 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 if needed.
|
||||||
length = cmd_start - index
|
gap = start - index
|
||||||
index += copy_string(str, iter, length)
|
index += copy_string(str, iter, gap)
|
||||||
|
|
||||||
# We need to copy the command's text using the iterator
|
if img = is_attachment ? attachment_to_img(node) : nil
|
||||||
# and the special function defined above.
|
# Skip the attached characters: the image replaces them entirely.
|
||||||
cmd_content = String.build(cmd_length) do |str2|
|
skip_string(iter, length)
|
||||||
copy_string(str2, iter, cmd_length)
|
str << img
|
||||||
|
else
|
||||||
|
# We need to copy the command's text using the iterator
|
||||||
|
# and the special function defined above.
|
||||||
|
cmd_content = String.build(length) do |str2|
|
||||||
|
copy_string(str2, iter, length)
|
||||||
|
end
|
||||||
|
|
||||||
|
link = cmd_content
|
||||||
|
if !is_attachment && (on_tap = node.dig?("onTap", "innertubeCommand"))
|
||||||
|
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
||||||
|
end
|
||||||
|
str << link
|
||||||
end
|
end
|
||||||
|
index += length
|
||||||
link = cmd_content
|
|
||||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
|
||||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
|
||||||
end
|
|
||||||
str << link
|
|
||||||
index += cmd_length
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Copy the end of the string (past the last command).
|
# Copy the end of the string (past the last run).
|
||||||
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
||||||
remaining_length = content_size - index
|
remaining_length = content_size - index
|
||||||
copy_string(str, iter, remaining_length) if remaining_length > 0
|
copy_string(str, iter, remaining_length) if remaining_length > 0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user