From bab6a7b798e18c07f6eea0afd752f546939876c9 Mon Sep 17 00:00:00 2001
From: water <672684719@qq.com>
Date: Sun, 9 Aug 2026 16:04:52 +0800
Subject: [PATCH] feat: render custom channel emoji in descriptions and
comments
Add attachmentRuns handling to parse_description so custom channel
emoji (image shortcodes such as :yt:) render as
tags instead of
falling back to their textual form.
The commentViewModel path (comments/youtube.cr) and the video description
path (parser.cr) both route through parse_description, which previously
only handled commandRuns (links). This change merges commandRuns and
attachmentRuns sorted by startIndex and emits an
for image-type attachments, mirroring the behaviour of the older
content_to_comment_html implementation.
Fixes #5888
---
src/invidious/videos/description.cr | 112 +++++++++++++++++++++++-----
1 file changed, 93 insertions(+), 19 deletions(-)
diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr
index 2c983269..248fd94f 100644
--- a/src/invidious/videos/description.cr
+++ b/src/invidious/videos/description.cr
@@ -40,6 +40,8 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
return copied
end
+private record Run, start_index : Int32, length : Int32, kind : Symbol, data : JSON::Any
+
def parse_description(desc, video_id : String) : String?
return "" if desc.nil?
@@ -47,49 +49,121 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a
- if commands.nil?
- # Slightly faster than HTML.escape, as we're only doing one pass on
- # the string instead of five for the standard library
+ attachments = desc["attachmentRuns"]?.try &.as_a
+
+ # If no runs at all, fall back to simple escape
+ if commands.nil? && attachments.nil?
return String.build do |str|
content_size = content.ascii_only? ? content.size : utf16_length(content)
copy_string(str, content.each_codepoint, content_size)
end
end
- # 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
- # copied byte in a special manner, preventing the use of regular string copy.
- iter = content.each_codepoint
+ # Merge commandRuns and attachmentRuns sorted by startIndex
+ runs = [] of Run
+ if commands
+ commands.each do |cmd|
+ runs << Run.new(
+ start_index: cmd["startIndex"].as_i,
+ length: cmd["length"].as_i,
+ kind: :command,
+ data: cmd
+ )
+ end
+ end
+
+ if attachments
+ attachments.each do |att|
+ runs << Run.new(
+ start_index: att["startIndex"].as_i,
+ length: att["length"].as_i,
+ kind: :attachment,
+ data: att
+ )
+ end
+ end
+
+ runs.sort_by!(&.start_index)
+
+ 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|
+ cmd_start = run.start_index
+ cmd_length = run.length
- # Copy the text chunk between this command and the previous if needed.
+ # Copy the text chunk between this run and the previous if needed.
length = cmd_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)
end
- link = cmd_content
- if on_tap = command.dig?("onTap", "innertubeCommand")
- link = parse_link_endpoint(on_tap, cmd_content, video_id)
+ if run.kind == :command
+ # Handle link commands
+ link = cmd_content
+ if on_tap = run.data.dig?("onTap", "innertubeCommand")
+ link = parse_link_endpoint(on_tap, cmd_content, video_id)
+ end
+ str << link
+ elsif run.kind == :attachment
+ # Handle attachment runs (emoji images)
+ element = run.data["element"]?
+
+ if element.is_a?(JSON::Any) && element["type"]?.try &.as_s == "imageType"
+ image = element["image"]?
+
+ if image.is_a?(JSON::Any)
+ sources = image["sources"]?.try &.as_a
+
+ if sources && sources[0]?
+ source = sources[0]
+ # Get the emoji image URL
+ emoji_url = source["url"]?.try &.as_s
+ # Fallback for clientResource format (badges)
+ if emoji_url.nil? || emoji_url.empty?
+ emoji_url = source.dig?("clientResource", "imageName").try &.as_s
+ end
+
+ if emoji_url && !emoji_url.empty?
+ # Get accessibility label
+ alt_text = image.dig?("accessibility", "accessibilityData", "label").try &.as_s || cmd_content
+
+ # Build the img tag
+ if emoji_url.starts_with?("http://") || emoji_url.starts_with?("https://")
+ str << %(
)
+ else
+ # For internal resource names (badges), skip rendering
+ str << cmd_content
+ end
+ else
+ str << cmd_content
+ end
+ else
+ str << cmd_content
+ end
+ else
+ str << cmd_content
+ end
+ else
+ str << cmd_content
+ end
end
- str << link
+
index += cmd_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
end
-end
+end
\ No newline at end of file