mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-07 09:33:04 -05:00
fix(comments): render inline emoji attachments
This commit is contained in:
parent
7d93ccbd04
commit
ef2bf5456b
55
spec/invidious/comments/content_spec.cr
Normal file
55
spec/invidious/comments/content_spec.cr
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
require "../../parsers_helper.cr"
|
||||||
|
|
||||||
|
Spectator.describe "YouTube comment descriptions" do
|
||||||
|
it "renders inline custom emoji attachments" do
|
||||||
|
content = JSON.parse(%({
|
||||||
|
"content":"Before:star:after",
|
||||||
|
"attachmentRuns":[{
|
||||||
|
"startIndex":6,
|
||||||
|
"length":6,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://lh3.googleusercontent.com/custom=s16-w24-h24-c-k-nd",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"star"}}
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(parse_description(content, "video-id")).to eq(
|
||||||
|
%(Before<img class="channel-emoji" alt="star" src="/ggpht/custom=s16-w24-h24-c-k-nd" title="star" width="16" height="16" />after)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "proxies ggpht attachments and preserves unsupported attachments" do
|
||||||
|
content = JSON.parse(%({
|
||||||
|
"content":"one:emoji:two:unknown:three",
|
||||||
|
"attachmentRuns":[
|
||||||
|
{
|
||||||
|
"startIndex":3,
|
||||||
|
"length":7,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://yt3.ggpht.com/emoji/test?s=16",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"emoji"}}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"startIndex":13,
|
||||||
|
"length":9,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://example.com/unknown.png",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"unknown"}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(parse_description(content, "video-id")).to eq(
|
||||||
|
%(one<img class="channel-emoji" alt="emoji" src="/ggpht/emoji/test?s=16" title="emoji" width="16" height="16" />two:unknown:three)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -40,6 +40,46 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
|||||||
return copied
|
return copied
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private def image_source(url : String) : String?
|
||||||
|
uri = URI.parse(url)
|
||||||
|
|
||||||
|
case uri.host
|
||||||
|
when "yt3.ggpht.com"
|
||||||
|
"/ggpht#{uri.request_target}"
|
||||||
|
when "lh3.googleusercontent.com"
|
||||||
|
# Google serves these image paths from the ggpht host as well, keeping
|
||||||
|
# inline emoji behind Invidious' existing image proxy and CSP.
|
||||||
|
"/ggpht#{uri.request_target}"
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private def render_attachment(attachment : JSON::Any) : String?
|
||||||
|
source = attachment.dig?("element", "type", "imageType", "image", "sources", 0)
|
||||||
|
return if source.nil?
|
||||||
|
|
||||||
|
source_url = source["url"]?.try &.as_s
|
||||||
|
return if source_url.nil?
|
||||||
|
|
||||||
|
src = image_source(source_url)
|
||||||
|
return if src.nil?
|
||||||
|
|
||||||
|
alt = attachment.dig?("properties", "accessibilityProperties", "label").try &.as_s || ""
|
||||||
|
width = source["width"]?.try &.as_i || 16_i64
|
||||||
|
height = source["height"]?.try &.as_i || 16_i64
|
||||||
|
|
||||||
|
String.build do |str|
|
||||||
|
str << %(<img class="channel-emoji" alt=")
|
||||||
|
str << HTML.escape(alt)
|
||||||
|
str << %(" src=")
|
||||||
|
str << HTML.escape(src)
|
||||||
|
str << %(" title=")
|
||||||
|
str << HTML.escape(alt)
|
||||||
|
str << %(" width=") << width << %(" height=") << height << %( />)
|
||||||
|
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?
|
||||||
|
|
||||||
@ -47,7 +87,17 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
return "" if content.empty?
|
return "" if content.empty?
|
||||||
|
|
||||||
commands = desc["commandRuns"]?.try &.as_a
|
commands = desc["commandRuns"]?.try &.as_a
|
||||||
if commands.nil?
|
attachments = desc["attachmentRuns"]?.try &.as_a
|
||||||
|
|
||||||
|
events = [] of JSON::Any
|
||||||
|
if commands
|
||||||
|
commands.each { |command| events << command }
|
||||||
|
end
|
||||||
|
if attachments
|
||||||
|
attachments.each { |attachment| events << attachment }
|
||||||
|
end
|
||||||
|
|
||||||
|
if events.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|
|
||||||
@ -56,6 +106,8 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
events.sort_by! { |event| event["startIndex"].as_i }
|
||||||
|
|
||||||
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
|
# 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
|
# (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
|
||||||
@ -65,26 +117,36 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
index = 0
|
index = 0
|
||||||
|
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
commands.each do |command|
|
events.each do |event|
|
||||||
cmd_start = command["startIndex"].as_i
|
event_start = event["startIndex"].as_i
|
||||||
cmd_length = command["length"].as_i
|
event_length = event["length"].as_i
|
||||||
|
next if event_start < index
|
||||||
|
|
||||||
# Copy the text chunk between this command and the previous if needed.
|
# Copy the text chunk between this command and the previous if needed.
|
||||||
length = cmd_start - index
|
length = event_start - index
|
||||||
index += copy_string(str, iter, length)
|
index += copy_string(str, iter, length)
|
||||||
|
|
||||||
|
if event["element"]?
|
||||||
|
# Inline images, including custom channel emojis, are represented as
|
||||||
|
# attachmentRuns by the current YouTube comment API.
|
||||||
|
attachment_content = String.build do |attachment_str|
|
||||||
|
copy_string(attachment_str, iter, event_length)
|
||||||
|
end
|
||||||
|
str << (render_attachment(event) || attachment_content)
|
||||||
|
else
|
||||||
# We need to copy the command's text using the iterator
|
# We need to copy the command's text using the iterator
|
||||||
# and the special function defined above.
|
# and the special function defined above.
|
||||||
cmd_content = String.build(cmd_length) do |str2|
|
cmd_content = String.build(event_length) do |str2|
|
||||||
copy_string(str2, iter, cmd_length)
|
copy_string(str2, iter, event_length)
|
||||||
end
|
end
|
||||||
|
|
||||||
link = cmd_content
|
link = cmd_content
|
||||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
if on_tap = event.dig?("onTap", "innertubeCommand")
|
||||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
||||||
end
|
end
|
||||||
str << link
|
str << link
|
||||||
index += cmd_length
|
end
|
||||||
|
index += event_length
|
||||||
end
|
end
|
||||||
|
|
||||||
# Copy the end of the string (past the last command).
|
# Copy the end of the string (past the last command).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user