mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 00:52: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
|
||||
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?
|
||||
return "" if desc.nil?
|
||||
|
||||
content = desc["content"].as_s
|
||||
return "" if content.empty?
|
||||
|
||||
commands = desc["commandRuns"]?.try &.as_a
|
||||
if commands.nil?
|
||||
commands = (desc["commandRuns"]?.try &.as_a) || Array(JSON::Any).new
|
||||
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
|
||||
# the string instead of five for the standard library
|
||||
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
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
|
||||
index = 0
|
||||
|
||||
return String.build do |str|
|
||||
commands.each do |command|
|
||||
cmd_start = command["startIndex"].as_i
|
||||
cmd_length = command["length"].as_i
|
||||
runs.each do |(start, length, node, is_attachment)|
|
||||
# Defensive check against overlapping runs
|
||||
next if 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 if needed.
|
||||
gap = start - index
|
||||
index += copy_string(str, iter, gap)
|
||||
|
||||
# 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)
|
||||
if img = is_attachment ? attachment_to_img(node) : nil
|
||||
# Skip the attached characters: the image replaces them entirely.
|
||||
skip_string(iter, 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
|
||||
|
||||
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
|
||||
index += length
|
||||
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)
|
||||
remaining_length = content_size - index
|
||||
copy_string(str, iter, remaining_length) if remaining_length > 0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user