From 08d5420ad58dda71d004405f86a564f298f0644a Mon Sep 17 00:00:00 2001 From: Shaurya Swarup Date: Fri, 23 Oct 2026 00:03:28 +0530 Subject: [PATCH] Comments: Fix custom channel emoji rendering YouTube now ships comment bodies (and video descriptions) as an `attributedDescription`: a plain `content` string plus interleaved `commandRuns`/`attachmentRuns`/`styleRuns` indexed in UTF-16 code units. `parse_description` only understood `commandRuns`, so it silently dropped every `attachmentRuns` entry. For custom channel emoji that is the only place the image lives: `content` merely contains a `:_EmojiName:` placeholder followed by U+25A1 padding, so comments rendered the raw placeholder instead of the emoji. Plain unicode emoji kept working because they are already present verbatim in `content`. Render `commandRuns` and `attachmentRuns` in a single ordered pass, and substitute a proxied `` for custom channel emoji. Plain unicode emoji attachment runs are deliberately left alone: their image lives on `www.youtube.com/s/gaming/emoji/...`, which the `/ggpht` proxy cannot serve, and the characters are already correct. Overlapping runs are skipped so the codepoint iterator cannot be advanced past the text a run covers. Fixture data in the new spec was captured from live InnerTube `commentEntityPayload.properties.content` payloads. --- spec/invidious/videos/description_spec.cr | 130 ++++++++++++++++++ src/invidious/comments/youtube.cr | 2 +- src/invidious/videos/description.cr | 153 ++++++++++++++++++---- 3 files changed, 257 insertions(+), 28 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..ee08a883a --- /dev/null +++ b/spec/invidious/videos/description_spec.cr @@ -0,0 +1,130 @@ +require "../../parsers_helper.cr" + +# Fixtures below are taken verbatim (structurally) from the InnerTube payloads +# that Invidious receives for `commentEntityPayload.properties.content` and for +# video `attributedDescription`s. Custom channel emoji are shipped as an image +# in `attachmentRuns`, with only a `:_EmojiName:` placeholder (followed by +# U+25A1 padding) left behind in `content`. Plain unicode emoji are the opposite: +# they are already in `content` and their attachment run points at YouTube's own +# emoji sprite on `www.youtube.com`, which cannot be served through /ggpht. +private def attachment_run(label : String, start_index : Int32, length : Int32, url : String) : String + <<-JSON + { + "startIndex": #{start_index}, + "length": #{length}, + "element": { + "type": { + "imageType": { + "image": { + "sources": [{"url": "#{url}", "width": 16, "height": 16}] + } + } + }, + "properties": { + "accessibilityProperties": {"label": "#{label}"} + } + }, + "alignment": "ALIGNMENT_VERTICAL_CENTER" + } + JSON +end + +private def command_run(start_index : Int32, length : Int32, url : String) : String + <<-JSON + { + "startIndex": #{start_index}, + "length": #{length}, + "onTap": {"innertubeCommand": {"urlEndpoint": {"url": "#{url}"}}} + } + JSON +end + +CUSTOM_EMOJI_URL = "https://yt3.googleusercontent.com/6-Hzg3v1TFZI9DifzoYrJZYYTsu4quApCRT461xkMjDhaR4xUP8FbsDZe4xIdcTQi_oGPOg4=s16-w24-h24-c-k-nd" +# YouTube's own emoji sprite, served from www.youtube.com (not /ggpht-able). +UNICODE_EMOJI_URL = "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f600.png" + +Spectator.describe "parse_description" do + it "returns an empty string for nil content" do + expect(parse_description(nil, "vid")).to eq("") + end + + it "returns an empty string for empty content" do + expect(parse_description(JSON.parse(%({"content": ""})), "vid")).to eq("") + end + + it "escapes plain content when there are no runs" do + desc = JSON.parse(%({"content": "a & b "})) + expect(parse_description(desc, "vid")).to eq("a & b <c>") + end + + it "renders custom channel emoji as proxied images" do + content = "hi :_EmojiOne:\u25A1\u25A1 and :_EmojiTwo: end" + desc = JSON.parse( + %({"content": "#{content}", "attachmentRuns": [) + + attachment_run("EmojiOne", 3, 11, CUSTOM_EMOJI_URL) + "," + + attachment_run("EmojiTwo", 21, 11, CUSTOM_EMOJI_URL) + "]}" + ) + + html = parse_description(desc, "vid").to_s + + expect(html).to eq( + %(hi ) + + %(EmojiOne) + + %( and ) + + %(EmojiTwo) + + %( end) + ) + end + + it "does not leave placeholders or padding behind for custom emoji" do + content = "hi :_EmojiOne:\u25A1\u25A1" + desc = JSON.parse( + %({"content": "#{content}", "attachmentRuns": [) + + attachment_run("EmojiOne", 3, 11, CUSTOM_EMOJI_URL) + "]}" + ) + + html = parse_description(desc, "vid").to_s + + expect(html).to_not contain(":_EmojiOne:") + expect(html).to_not contain("\u25A1") + end + + it "leaves plain unicode emoji alone instead of substituting an image" do + content = "Smiley: \u{1F600} ok" + desc = JSON.parse( + %({"content": "#{content}", "attachmentRuns": [) + + attachment_run("\u{1F600}", 8, 2, UNICODE_EMOJI_URL) + "]}" + ) + + html = parse_description(desc, "vid").to_s + + expect(html).to eq("Smiley: \u{1F600} ok") + expect(html).to_not contain("here)) + end + + it "renders commandRuns and attachmentRuns interleaved in index order" do + desc = JSON.parse( + %({"content": "0:48 :_E1: tail", "commandRuns": [) + + command_run(0, 4, "https://example.com/t") + + %(], "attachmentRuns": [) + + attachment_run("E1", 5, 5, CUSTOM_EMOJI_URL) + "]}" + ) + + html = parse_description(desc, "vid").to_s + + expect(html).to contain(%(0:48)) + expect(html).to contain(%(class="channel-emoji" />)) + expect(html).to contain(" tail") + end +end diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr index 134a3d712..8b1be1baf 100644 --- a/src/invidious/comments/youtube.cr +++ b/src/invidious/comments/youtube.cr @@ -150,7 +150,7 @@ module Invidious::Comments toolbar_mutation = mutations.find { |i| i.dig?("entityKey") == toolbar_key } if !comment_mutation.nil? && !toolbar_mutation.nil? - # todo parse styleRuns, commandRuns and attachmentRuns for comments + # todo parse styleRuns for comments html_content = parse_description(comment_mutation.dig("payload", "commentEntityPayload", "properties", "content"), id) comment_author = comment_mutation.dig("payload", "commentEntityPayload", "author") json.field "authorId", comment_author["channelId"].as_s diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 2c9832690..0fe7f86ce 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -1,3 +1,4 @@ +require "html" require "json" require "uri" @@ -12,6 +13,20 @@ private def utf16_length(content : String) : Int32 u16_size end +private def skip_string(iter : Iterator, count : Int) : Int + skipped = 0 + while skipped < count + cp = iter.next + break if cp.is_a?(Iterator::Stop) + + # A codepoint from the SMP counts twice + skipped += 1 if cp > 0xFFFF + skipped += 1 + end + + return skipped +end + private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int copied = 0 while copied < count @@ -40,19 +55,60 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I return copied end +# YouTube ships custom channel emoji as an image inside an `attachmentRuns` +# entry, while `content` only holds a `:_EmojiName:` placeholder followed by +# U+25A1 padding characters. Plain unicode emoji are the opposite: they are +# already present verbatim in `content`, and their attachment run points at +# YouTube's own emoji sprite (`www.youtube.com/s/gaming/emoji/...`), which the +# `/ggpht` proxy cannot serve. Therefore only the former are substituted with +# an image tag; the latter are left as the text characters they already are. +private def attachment_run_to_html(run : JSON::Any) : String? + image = run.dig?("element", "type", "imageType", "image") + return if image.nil? + + sources = image["sources"].as_a? + return if sources.nil? || sources.empty? + + image_url = sources[0]["url"].as_s? + return if image_url.nil? + + uri = URI.parse(image_url) + return unless uri.host.try &.ends_with?("googleusercontent.com") + + alt = run.dig?("element", "properties", "accessibilityProperties", "label") + .try &.as_s || "" + alt = HTML.escape(alt) + + return String.build do |str| + str << %() << alt << ) + end +end + def parse_description(desc, video_id : String) : String? return "" if desc.nil? content = desc["content"].as_s 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 - return String.build do |str| - content_size = content.ascii_only? ? content.size : utf16_length(content) - copy_string(str, content.each_codepoint, content_size) + # Both `commandRuns` (links, timestamps, hashtags) and `attachmentRuns` + # (emoji) are indexed in UTF-16 code units over `content`, and the two sets + # can be interleaved, so they have to be rendered in a single ordered pass. + runs = [] of NamedTuple(start: Int32, length: Int32, node: JSON::Any, kind: Symbol) + + desc["commandRuns"]?.try &.as_a.try do |command_runs| + command_runs.each do |run| + runs << {start: run["startIndex"].as_i, length: run["length"].as_i, + node: run, kind: :command} + end + end + + desc["attachmentRuns"]?.try &.as_a.try do |attachment_runs| + attachment_runs.each do |run| + runs << {start: run["startIndex"].as_i, length: run["length"].as_i, + node: run, kind: :attachment} end end @@ -60,36 +116,79 @@ 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. + content_size = content.ascii_only? ? content.size : utf16_length(content) + + if runs.empty? + # 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| + copy_string(str, content.each_codepoint, content_size) + end + end + + runs.sort_by! { |run| run[:start] } + iter = content.each_codepoint index = 0 + strip_padding = false 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| + # Skip runs that overlap an already rendered one: consuming their span + # again would advance the iterator past the text they cover. + next if run[:start] < index - # Copy the text chunk between this command 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 - # and the special function defined above. - cmd_content = String.build(cmd_length) do |str2| - copy_string(str2, iter, cmd_length) + # Copy the text chunk between this run and the previous one. + chunk = String.build do |str2| + copy_string(str2, iter, run[:start] - index) end - link = cmd_content - if on_tap = command.dig?("onTap", "innertubeCommand") - link = parse_link_endpoint(on_tap, cmd_content, video_id) + # Drop the placeholder padding that follows a substituted channel emoji. + if strip_padding + chunk = chunk.sub(/\A\x{25A1}+/, "") + strip_padding = false end - str << link - index += cmd_length + + str << chunk + index = run[:start] + + case run[:kind] + when :command + # We need to copy the command's text using the iterator + # and the special function defined above. + cmd_content = String.build(run[:length]) do |str2| + copy_string(str2, iter, run[:length]) + end + + link = cmd_content + if on_tap = run[:node].dig?("onTap", "innertubeCommand") + link = parse_link_endpoint(on_tap, cmd_content, video_id) + end + str << link + when :attachment + if emoji = attachment_run_to_html(run[:node]) + skip_string(iter, run[:length]) + str << emoji + strip_padding = true + else + # Not a channel emoji: the span is already the emoji itself. + copy_string(str, iter, run[:length]) + end + end + + index += run[:length] end - # Copy the end of the string (past the last command). - 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 + # Copy the end of the string (past the last run). + chunk = String.build do |str2| + copy_string(str2, iter, content_size - index) + end + + if strip_padding + chunk = chunk.sub(/\A\x{25A1}+/, "") + end + + str << chunk end end