From c5aacd177213f76b462e3ecc758edb5f454a82bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8chico10117=C2=A8?= Date: Thu, 6 Aug 2026 22:05:14 -0600 Subject: [PATCH] fix(comments): render YouTube attachment emojis --- spec/invidious/videos/description_spec.cr | 75 ++++++++++++++++ src/invidious/videos/description.cr | 100 ++++++++++++++++++++++ 2 files changed, 175 insertions(+) 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..3fca140d2 --- /dev/null +++ b/spec/invidious/videos/description_spec.cr @@ -0,0 +1,75 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_description" do + it "renders YouTube attachment runs as images" do + description = JSON.parse(<<-JSON) + { + "content": ":face-red-heart-shape::face-orange-biting-nails:", + "attachmentRuns": [ + { + "startIndex": 0, + "length": 22, + "element": { + "type": { + "imageType": { + "image": { + "sources": [{ + "url": "https://lh3.googleusercontent.com/emoji-red=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/emoji-orange=s16-w24-h24-c-k-nd", + "width": 16, + "height": 16 + }] + } + } + } + }, + "properties": { + "accessibilityProperties": {"label": "face-orange-biting-nails"} + } + } + ] + } + JSON + + html = parse_description(description, "Gy3bmuzmWMM").not_nil! + + expect(html).to eq( + %(face-red-heart-shape) + + %(face-orange-biting-nails) + ) + end + + it "preserves attachment text when image data is unavailable" do + description = JSON.parse(<<-JSON) + { + "content": "hello:custom-emoji:", + "attachmentRuns": [{ + "startIndex": 5, + "length": 14, + "element": {"type": {"imageType": {"image": {}}}} + }] + } + JSON + + expect(parse_description(description, "video-id")).to eq("hello:custom-emoji:") + end +end diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 2c9832690..eb8638530 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -40,6 +40,102 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I return copied 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) + + skipped += cp > 0xFFFF ? 2 : 1 + end + + skipped +end + +private def parse_attachment_run(attachment) : String? + image_source = attachment.dig?("element", "type", "imageType", "image", "sources", 0) + return if image_source.nil? + + url = image_source["url"]?.try &.as_s? + return if url.nil? + + label = attachment.dig?("properties", "accessibilityProperties", "label").try &.as_s? || "" + escaped_label = HTML.escape(label) + + String.build do |str| + str << %() << escaped_label << %() + end +end + +private def parse_description_with_attachments(content : String, commands, attachments, video_id : String) : String + runs = [] of Tuple(Int32, Int32, String, JSON::Any) + + commands.try &.each do |command| + runs << {command["startIndex"].as_i, command["length"].as_i, "command", command} + end + + attachments.each do |attachment| + runs << {attachment["startIndex"].as_i, attachment["length"].as_i, "attachment", attachment} + end + + runs.sort_by! { |run| run[0] } + + iter = content.each_codepoint + index = 0_i32 + + String.build do |str| + runs.each do |run| + start_index = run[0] + length = run[1] + next if start_index < index + + if start_index > index + index += copy_string(str, iter, start_index - index) + end + + case run[2] + when "command" + command = run[3] + command_content = String.build(length) do |command_str| + copy_string(command_str, iter, length) + end + + link = command_content + if on_tap = command.dig?("onTap", "innertubeCommand") + link = parse_link_endpoint(on_tap, command_content, video_id) + end + str << link + when "attachment" + attachment = run[3] + if attachment_html = parse_attachment_run(attachment) + str << attachment_html + skip_string(iter, length) + else + copy_string(str, iter, length) + end + end + + index += length + end + + 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 + def parse_description(desc, video_id : String) : String? return "" if desc.nil? @@ -47,6 +143,10 @@ def parse_description(desc, video_id : String) : String? return "" if content.empty? commands = desc["commandRuns"]?.try &.as_a + if attachments = desc["attachmentRuns"]?.try &.as_a + return parse_description_with_attachments(content, commands, attachments, video_id) + end + 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