mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 00:52:45 -05:00
custom youtube emojis dont render
custom yt emojis don't render anymore on invidious, youtube changed the comment format to commentEntityPayload which goes to parse_description but it never reads attachmentRuns, the emoji images live there. the old emoji code sits in content_to_comment_html, which gets no data now, the descriptions have the same flaw. the fix was reading attachmentRuns in the same pass as commandRuns Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
821365cf71
commit
cba508ba61
442
spec/invidious/videos/description_spec.cr
Normal file
442
spec/invidious/videos/description_spec.cr
Normal file
@ -0,0 +1,442 @@
|
||||
require "../../parsers_helper.cr"
|
||||
|
||||
Spectator.describe "parse_description" do
|
||||
it "parses a description without any run" do
|
||||
desc = JSON.parse(%({"content": "Hello <world> & \\"friends\\""}))
|
||||
|
||||
expect(parse_description(desc, "")).to eq(
|
||||
"Hello <world> & "friends""
|
||||
)
|
||||
end
|
||||
|
||||
it "parses a description with a link (commandRuns)" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "Watch this",
|
||||
"commandRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 5,
|
||||
"onTap": {
|
||||
"innertubeCommand": {
|
||||
"watchEndpoint": { "videoId": "dQw4w9WgXcQ" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "someOtherId")).to eq(
|
||||
%(<a href="/watch?v=dQw4w9WgXcQ">Watch</a> this)
|
||||
)
|
||||
end
|
||||
|
||||
# Standard emojis are sent as an attachment, but the content string already
|
||||
# contains the real unicode character, so the text must be left untouched.
|
||||
# Captured from https://www.youtube.com/watch?v=Gy3bmuzmWMM
|
||||
it "keeps standard emojis as unicode" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "everyone deserves to grow❤️",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 25,
|
||||
"length": 1,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u2764.png",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"accessibilityProperties": { "label": "❤" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq("everyone deserves to grow❤️")
|
||||
end
|
||||
|
||||
# Youtube inserts social media icons in descriptions with a length of 0,
|
||||
# meaning no text is covered by the attachment.
|
||||
# Captured from https://www.youtube.com/watch?v=Gy3bmuzmWMM
|
||||
it "does not consume text for a zero length attachment" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "Follow me on twitter",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 14,
|
||||
"length": 0,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{ "url": "https://www.gstatic.com/youtube/img/watch/social_media/twitter_1x_v2.png" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq("Follow me on twitter")
|
||||
end
|
||||
|
||||
# Custom channel emojis have no unicode equivalent and are hosted on ggpht,
|
||||
# so they must be rendered as a proxied image.
|
||||
it "renders custom channel emojis as an image" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "nice video :_ToonThumbsUp:",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 11,
|
||||
"length": 15,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://yt3.ggpht.com/abcdef=w24-h24-c-k-nd",
|
||||
"width": 24,
|
||||
"height": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"layoutProperties": {
|
||||
"width": { "value": 24 },
|
||||
"height": { "value": 24 }
|
||||
},
|
||||
"accessibilityProperties": { "label": "ToonThumbsUp" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq(
|
||||
%(nice video <img alt="ToonThumbsUp" src="/ggpht/abcdef=w24-h24-c-k-nd" ) +
|
||||
%(title="ToonThumbsUp" width="24" height="24" class="channel-emoji" />)
|
||||
)
|
||||
end
|
||||
|
||||
# Both kinds of run index into the same string and share a single iterator,
|
||||
# so they have to be walked in order of appearance.
|
||||
it "parses a description mixing a link and a custom emoji" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": ":_Hi: watch",
|
||||
"commandRuns": [
|
||||
{
|
||||
"startIndex": 6,
|
||||
"length": 5,
|
||||
"onTap": {
|
||||
"innertubeCommand": {
|
||||
"watchEndpoint": { "videoId": "dQw4w9WgXcQ" }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 5,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{ "url": "https://yt3.ggpht.com/xyz=w24-h24-c-k-nd", "width": 24, "height": 24 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"accessibilityProperties": { "label": "Hi" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "someOtherId")).to eq(
|
||||
%(<img alt="Hi" src="/ggpht/xyz=w24-h24-c-k-nd" title="Hi" width="24" height="24" class="channel-emoji" />) +
|
||||
%( <a href="/watch?v=dQw4w9WgXcQ">watch</a>)
|
||||
)
|
||||
end
|
||||
|
||||
# Real capture of the comment linked in #5888, whose emojis are hosted on
|
||||
# lh3.googleusercontent.com rather than ggpht:
|
||||
# https://www.youtube.com/watch?v=Gy3bmuzmWMM&lc=UgxVdYO3R6wrr9sLhT54AaABAg
|
||||
it "renders the emojis of the comment reported in #5888" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": ":face-red-heart-shape:",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 22,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://lh3.googleusercontent.com/I0Mem9dU=s16-w24-h24-c-k-nd",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"layoutProperties": {
|
||||
"height": { "value": 16 },
|
||||
"width": { "value": 16 }
|
||||
},
|
||||
"accessibilityProperties": { "label": "face-red-heart-shape" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq(
|
||||
%(<img alt="face-red-heart-shape" src="/ggpht/I0Mem9dU=s16-w24-h24-c-k-nd" ) +
|
||||
%(title="face-red-heart-shape" width="16" height="16" class="channel-emoji" />)
|
||||
)
|
||||
end
|
||||
|
||||
it "does not treat a gstatic image as a custom emoji" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "hi",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 2,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{ "url": "https://www.gstatic.com/youtube/img/watch/x.png", "width": 16, "height": 16 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"accessibilityProperties": { "label": "x" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq("hi")
|
||||
end
|
||||
|
||||
# copy_string escapes the text as it copies it, so the fallback used when
|
||||
# youtube sends no label must not be escaped a second time.
|
||||
it "does not escape the fallback label twice" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "a&b",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 3,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{ "url": "https://yt3.ggpht.com/x=w24-h24", "width": 24, "height": 24 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq(
|
||||
%(<img alt="a&b" src="/ggpht/x=w24-h24" title="a&b" ) +
|
||||
%(width="24" height="24" class="channel-emoji" />)
|
||||
)
|
||||
end
|
||||
|
||||
# URI#request_target returns the path untouched, quotes included, so it has
|
||||
# to be escaped before being placed inside the src attribute.
|
||||
it "escapes a quote in the image url" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "hi",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 2,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{ "url": "https://yt3.ggpht.com/x\\"onerror=alert(1) a=\\"", "width": 24, "height": 24 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"accessibilityProperties": { "label": "e" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq(
|
||||
%(<img alt="e" src="/ggpht/x"onerror=alert(1) a="" title="e" ) +
|
||||
%(width="24" height="24" class="channel-emoji" />)
|
||||
)
|
||||
end
|
||||
|
||||
# Matching the bare domain suffix would let a lookalike host through.
|
||||
it "rejects a lookalike emoji host" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "hi",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 2,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{ "url": "https://evilgoogleusercontent.com/x=w24-h24", "width": 24, "height": 24 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"accessibilityProperties": { "label": "e" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq("hi")
|
||||
end
|
||||
|
||||
# A zero length attachment consumes no text, so a command starting at the
|
||||
# same index must not advance the cursor past it and drop the image.
|
||||
it "keeps a zero-length attachment sharing an index with a command" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "watch",
|
||||
"commandRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 5,
|
||||
"onTap": {
|
||||
"innertubeCommand": {
|
||||
"watchEndpoint": { "videoId": "dQw4w9WgXcQ" }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 0,
|
||||
"length": 0,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{ "url": "https://yt3.ggpht.com/z=w24-h24", "width": 24, "height": 24 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"accessibilityProperties": { "label": "e" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "someOtherId")).to eq(
|
||||
%(<img alt="e" src="/ggpht/z=w24-h24" title="e" width="24" height="24" class="channel-emoji" />) +
|
||||
%(<a href="/watch?v=dQw4w9WgXcQ">watch</a>)
|
||||
)
|
||||
end
|
||||
|
||||
# Youtube counts indexes in UTF-16 code units, so a single SMP codepoint
|
||||
# placed before a run shifts every following index by two.
|
||||
it "handles offsets after an SMP codepoint" do
|
||||
desc = JSON.parse(<<-JSON)
|
||||
{
|
||||
"content": "\u{1F600} :_Test:",
|
||||
"attachmentRuns": [
|
||||
{
|
||||
"startIndex": 3,
|
||||
"length": 7,
|
||||
"element": {
|
||||
"type": {
|
||||
"imageType": {
|
||||
"image": {
|
||||
"sources": [
|
||||
{ "url": "https://yt3.ggpht.com/test=w24-h24-c-k-nd", "width": 24, "height": 24 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"accessibilityProperties": { "label": "Test" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
expect(parse_description(desc, "")).to eq(
|
||||
%(\u{1F600} <img alt="Test" src="/ggpht/test=w24-h24-c-k-nd" title="Test" width="24" height="24" class="channel-emoji" />)
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -1,3 +1,4 @@
|
||||
require "html"
|
||||
require "json"
|
||||
require "uri"
|
||||
|
||||
@ -40,6 +41,69 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
||||
return copied
|
||||
end
|
||||
|
||||
# Google image CDNs that the /ggpht route can serve. They share a path
|
||||
# namespace, so an emoji hosted on googleusercontent is also reachable through
|
||||
# the ggpht pool that route proxies to.
|
||||
EMOJI_IMAGE_DOMAINS = {"ggpht.com", "googleusercontent.com"}
|
||||
|
||||
# Custom emojis (channel membership ones, and the ":face-red-heart-shape:"
|
||||
# set) have no unicode equivalent, so youtube sends them as an image
|
||||
# attachment covering the ":shortcut:" text, which has to be rendered as an
|
||||
# <img> tag, otherwise the raw shortcut is shown to the user.
|
||||
#
|
||||
# Standard emojis are sent as attachments too, but the content string already
|
||||
# holds the real unicode character, so those are left untouched: rendering
|
||||
# them as images would only add pointless requests to youtube's servers.
|
||||
#
|
||||
# Custom emojis are hosted on google's image CDNs, which are all reachable
|
||||
# through the /ggpht route, so anything hosted elsewhere (youtube.com and
|
||||
# gstatic.com serve the standard emojis) is deliberately left as text.
|
||||
private def parse_emoji_attachment(attachment : JSON::Any, text : String) : String
|
||||
image = attachment.dig?("element", "type", "imageType", "image")
|
||||
return text if image.nil?
|
||||
|
||||
source = image.dig?("sources", 0)
|
||||
return text if source.nil?
|
||||
|
||||
url = source["url"]?.try &.as_s
|
||||
return text if url.nil?
|
||||
|
||||
uri = URI.parse(url)
|
||||
host = uri.host
|
||||
return text if host.nil?
|
||||
|
||||
# Matching on the bare suffix would also accept "notggpht.com", so the host
|
||||
# has to be the domain itself or one of its subdomains.
|
||||
accepted = EMOJI_IMAGE_DOMAINS.any? do |domain|
|
||||
host == domain || host.ends_with?(".#{domain}")
|
||||
end
|
||||
return text if !accepted
|
||||
|
||||
properties = attachment.dig?("element", "properties")
|
||||
|
||||
# The label is the emoji name, e.g "ToonThumbsUp" for ":_ToonThumbsUp:".
|
||||
# `text` was produced by copy_string, which escapes as it copies, so it is
|
||||
# only the raw label coming from youtube that still needs to be escaped.
|
||||
raw_label = properties.try(&.dig?("accessibilityProperties", "label")).try(&.as_s)
|
||||
label = raw_label.nil? ? text : HTML.escape(raw_label)
|
||||
|
||||
# Youtube gives the display size in "points" here, and the intrinsic size
|
||||
# of the image in the source. The former is what the official client uses.
|
||||
width = properties.try(&.dig?("layoutProperties", "width", "value")).try(&.as_i)
|
||||
height = properties.try(&.dig?("layoutProperties", "height", "value")).try(&.as_i)
|
||||
width ||= source["width"]?.try &.as_i
|
||||
height ||= source["height"]?.try &.as_i
|
||||
|
||||
return String.build do |str|
|
||||
str << %(<img alt=") << label << "\" "
|
||||
str << %(src="/ggpht) << HTML.escape(uri.request_target) << "\" "
|
||||
str << %(title=") << label << "\" "
|
||||
str << %(width=") << (width || 24) << "\" "
|
||||
str << %(height=") << (height || 24) << "\" "
|
||||
str << %(class="channel-emoji" />)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_description(desc, video_id : String) : String?
|
||||
return "" if desc.nil?
|
||||
|
||||
@ -47,7 +111,9 @@ def parse_description(desc, video_id : String) : String?
|
||||
return "" if content.empty?
|
||||
|
||||
commands = desc["commandRuns"]?.try &.as_a
|
||||
if commands.nil?
|
||||
attachments = desc["attachmentRuns"]?.try &.as_a
|
||||
|
||||
if commands.nil? && attachments.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|
|
||||
@ -56,6 +122,18 @@ def parse_description(desc, video_id : String) : String?
|
||||
end
|
||||
end
|
||||
|
||||
# Commands (links) and attachments (emojis) both index into the same string
|
||||
# and are consumed by the same iterator below, so they have to be walked in
|
||||
# a single pass, ordered by their position in the text.
|
||||
# Runs of length 0 consume nothing and come first, otherwise a command
|
||||
# sharing their index would advance past them and they'd be dropped by the
|
||||
# overlap check. Commands then come before attachments, as Crystal's sort is
|
||||
# not guaranteed to be stable.
|
||||
runs = [] of Tuple(Int32, Int32, Bool, JSON::Any)
|
||||
commands.try &.each { |cmd| runs << {cmd["startIndex"].as_i, cmd["length"].as_i, false, cmd} }
|
||||
attachments.try &.each { |att| runs << {att["startIndex"].as_i, att["length"].as_i, true, att} }
|
||||
runs.sort_by! { |run| {run[0], run[1] == 0 ? 0 : 1, run[2] ? 1 : 0} }
|
||||
|
||||
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
|
||||
# (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
|
||||
@ -65,29 +143,39 @@ def parse_description(desc, video_id : String) : String?
|
||||
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 |(run_start, run_length, is_attachment, node)|
|
||||
# Overlapping runs would desynchronize the iterator from the index, so
|
||||
# anything starting inside an already consumed run is skipped.
|
||||
next if run_start < index
|
||||
|
||||
# Copy the text chunk between this command and the previous if needed.
|
||||
length = cmd_start - index
|
||||
# Copy the text chunk between this run and the previous if needed.
|
||||
length = run_start - index
|
||||
index += copy_string(str, iter, length)
|
||||
|
||||
# We need to copy the command's text using the iterator
|
||||
# We need to copy the run'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)
|
||||
# Attachments may have a length of 0, when youtube inserts an image
|
||||
# without any text behind it, in which case nothing is consumed.
|
||||
run_content = if run_length > 0
|
||||
String.build(run_length) { |str2| copy_string(str2, iter, run_length) }
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
if is_attachment
|
||||
str << parse_emoji_attachment(node, run_content)
|
||||
else
|
||||
link = run_content
|
||||
if on_tap = node.dig?("onTap", "innertubeCommand")
|
||||
link = parse_link_endpoint(on_tap, run_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 += run_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