From 1588ac6d17ac885b9d4cb50ab8c6c1c61ea2eb9e Mon Sep 17 00:00:00 2001
From: andrzejber <149780348+andrzejber@users.noreply.github.com>
Date: Fri, 7 Aug 2026 20:37:31 +0200
Subject: [PATCH] Fix custom emojis in video descriptions
---
spec/invidious/videos/description_spec.cr | 123 ++++++++++++++++++++++
src/invidious/videos/description.cr | 77 +++++++++++---
2 files changed, 185 insertions(+), 15 deletions(-)
create mode 100644 spec/invidious/videos/description_spec.cr
diff --git a/spec/invidious/videos/description_spec.cr b/spec/invidious/videos/description_spec.cr
new file mode 100644
index 000000000..054e5ca66
--- /dev/null
+++ b/spec/invidious/videos/description_spec.cr
@@ -0,0 +1,123 @@
+require "json"
+require "uri"
+
+require "spectator"
+
+require "../../../src/invidious/helpers/utils"
+require "../../../src/invidious/videos/description"
+
+Spectator.describe "parse_description" do
+ it "renders custom emojis using the current YouTube attachment-run schema" do
+ description = JSON.parse(%({
+ "content":":face-red-heart-shape::face-orange-biting-nails:",
+ "attachmentRuns":[
+ {
+ "startIndex":0,
+ "length":22,
+ "element":{"type":{"imageType":{"image":{"sources":[{
+ "url":"https://lh3.googleusercontent.com/red-heart=s16-w24-h24-c-k-nd",
+ "width":16,
+ "height":16
+ }]}}}},
+ "properties":{"accessibilityProperties":{"label":"face-red-heart-shape"}}
+ },
+ {
+ "startIndex":22,
+ "length":26,
+ "element":{"type":{"imageType":{"image":{"sources":[{
+ "url":"https://lh3.googleusercontent.com/biting-nails=s16-w24-h24-c-k-nd",
+ "width":16,
+ "height":16
+ }]}}}},
+ "properties":{"accessibilityProperties":{"label":"face-orange-biting-nails"}}
+ }
+ ]
+ }))
+
+ expect(parse_description(description, "video-id")).to eq(
+ %(
) +
+ %(
)
+ )
+ end
+
+ it "keeps command runs and UTF-16 attachment indexes aligned" do
+ description = JSON.parse(%({
+ "content":"๐visit :custom:",
+ "commandRuns":[{
+ "startIndex":2,
+ "length":5,
+ "onTap":{"innertubeCommand":{"urlEndpoint":{"url":"https://example.com"}}}
+ }],
+ "attachmentRuns":[{
+ "startIndex":8,
+ "length":8,
+ "element":{"type":{"imageType":{"image":{"sources":[{
+ "url":"https://yt3.ggpht.com/custom=s16",
+ "width":16,
+ "height":16
+ }]}}}},
+ "properties":{"accessibilityProperties":{"label":"custom"}}
+ }]
+ }))
+
+ expect(parse_description(description, "video-id")).to eq(
+ %(๐visit
)
+ )
+ end
+
+ it "preserves the escaped shortcode when an attachment source is unsupported" do
+ description = JSON.parse(%({
+ "content":"before <:custom:> after",
+ "attachmentRuns":[{
+ "startIndex":8,
+ "length":8,
+ "element":{"type":{"imageType":{"image":{"sources":[{
+ "url":"https://example.com/custom.png",
+ "width":16,
+ "height":16
+ }]}}}},
+ "properties":{"accessibilityProperties":{"label":"custom"}}
+ }]
+ }))
+
+ expect(parse_description(description, "video-id")).to eq("before <:custom:> after")
+ end
+
+ it "escapes attachment labels before inserting them into HTML" do
+ description = JSON.parse(%({
+ "content":":custom:",
+ "attachmentRuns":[{
+ "startIndex":0,
+ "length":8,
+ "element":{"type":{"imageType":{"image":{"sources":[{
+ "url":"https://lh3.googleusercontent.com/custom=s16",
+ "width":16,
+ "height":16
+ }]}}}},
+ "properties":{"accessibilityProperties":{"label":"custom\\u0022 onerror=\\u0022alert(1)"}}
+ }]
+ }))
+
+ expect(parse_description(description, "video-id")).to eq(
+ %(
)
+ )
+ end
+
+ it "does not render zero-length graphical attachments" do
+ description = JSON.parse(%({
+ "content":"plain text",
+ "attachmentRuns":[{
+ "startIndex":0,
+ "length":0,
+ "element":{"type":{"imageType":{"image":{"sources":[{
+ "url":"https://lh3.googleusercontent.com/badge=s16",
+ "width":16,
+ "height":16
+ }]}}}},
+ "properties":{"accessibilityProperties":{"label":"badge"}}
+ }]
+ }))
+
+ expect(parse_description(description, "video-id")).to eq("plain text")
+ end
+end
diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr
index 2c9832690..d31c2c73d 100644
--- a/src/invidious/videos/description.cr
+++ b/src/invidious/videos/description.cr
@@ -1,3 +1,4 @@
+require "html"
require "json"
require "uri"
@@ -40,6 +41,34 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
return copied
end
+private def render_description_attachment(attachment : JSON::Any, fallback : String) : 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?
+
+ uri = URI.parse(source_url)
+ return if uri.scheme != "https"
+
+ # Custom emoji URLs use either host; the existing /ggpht proxy serves the
+ # same request path through yt3.ggpht.com.
+ return unless {"lh3.googleusercontent.com", "yt3.ggpht.com"}.includes?(uri.host)
+
+ label = attachment.dig?("properties", "accessibilityProperties", "label").try &.as_s? || fallback
+ width = source["width"]?.try &.as_i? || 16_i64
+ height = source["height"]?.try &.as_i? || 16_i64
+
+ String.build do |str|
+ str << %(
)
+ end
+end
+
def parse_description(desc, video_id : String) : String?
return "" if desc.nil?
@@ -47,7 +76,8 @@ 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|
@@ -60,31 +90,48 @@ 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.
+ runs = [] of NamedTuple(start: Int32, length: Int32, attachment: Bool, data: JSON::Any)
+ commands.try &.each do |command|
+ runs << {start: command["startIndex"].as_i, length: command["length"].as_i, attachment: false, data: command}
+ end
+ attachments.try &.each do |attachment|
+ runs << {start: attachment["startIndex"].as_i, length: attachment["length"].as_i, attachment: true, data: attachment}
+ end
+ runs.sort_by! { |run| {run[:start], run[:attachment] ? 0 : 1} }
+
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 |run|
+ run_start = run[:start]
+ run_length = run[:length]
- # Copy the text chunk between this command and the previous if needed.
- length = cmd_start - index
+ # If command and attachment runs overlap, ignore a lower-priority run
+ # once that section has already been consumed.
+ next if run_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
- # and the special function defined above.
- cmd_content = String.build(cmd_length) do |str2|
- copy_string(str2, iter, cmd_length)
+ # We need to copy the run's text using the iterator and the special
+ # function defined above.
+ run_content = String.build(run_length) do |str2|
+ copy_string(str2, iter, run_length)
end
- link = cmd_content
- if on_tap = command.dig?("onTap", "innertubeCommand")
- link = parse_link_endpoint(on_tap, cmd_content, video_id)
+ if run[:attachment] && run_length > 0
+ str << (render_description_attachment(run[:data], run_content) || run_content)
+ else
+ link = run_content
+ if on_tap = run[:data].dig?("onTap", "innertubeCommand")
+ link = parse_link_endpoint(on_tap, run_content, video_id)
+ end
+ str << link
end
- str << link
- index += cmd_length
+ index += run_length
end
# Copy the end of the string (past the last command).