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 << %() << alt_text << ) + 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