diff --git a/spec/invidious/videos/description_attachment_runs_spec.cr b/spec/invidious/videos/description_attachment_runs_spec.cr new file mode 100644 index 000000000..9f7bee5a8 --- /dev/null +++ b/spec/invidious/videos/description_attachment_runs_spec.cr @@ -0,0 +1,50 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_description" do + # Real InnerTube data captured from YouTube comments (2026-08). + # Standard emojis are attached as images hosted on www.youtube.com, while + # custom channel emojis are hosted on yt3.ggpht.com. + + it "keeps standard emoji characters when their image is not proxyable" do + desc = JSON.parse(%q({"content":"Not going to lie. Your intro got me to sub! Watching you, everyone deserves to grow❤️","attachmentRuns":[{"startIndex":84,"length":1,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u2764.png","width":16,"height":16}]},"playbackState":"IMAGE_PLAYBACK_STATE_STOPPED"}},"properties":{"layoutProperties":{"height":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"width":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"margin":{"left":{"value":2,"unit":"DIMENSION_UNIT_POINT"},"right":{"value":2,"unit":"DIMENSION_UNIT_POINT"}}},"accessibilityProperties":{"label":"❤"}}},"alignment":"ALIGNMENT_VERTICAL_CENTER"}]})) + + html = parse_description(desc, "Gy3bmuzmWMM") + + expect(html).to eq(%(Not going to lie. Your intro got me to sub! Watching you, everyone deserves to grow\u{2764}\u{fe0f})) + expect(html.not_nil!.includes?("img")).to be_false + end + + it "handles SMP emojis (2 UTF-16 units) right after multibyte-free text" do + desc = JSON.parse(%q({"content":"Feyrer is on 🔥! Thank you again, Master Jedi.","attachmentRuns":[{"startIndex":13,"length":2,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f525.png","width":16,"height":16}]},"playbackState":"IMAGE_PLAYBACK_STATE_STOPPED"}},"properties":{"layoutProperties":{"height":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"width":{"value":16,"unit":"DIMENSION_UNIT_POINT"},"margin":{"left":{"value":2,"unit":"DIMENSION_UNIT_POINT"},"right":{"value":2,"unit":"DIMENSION_UNIT_POINT"}}},"accessibilityProperties":{"label":"🔥"}}},"alignment":"ALIGNMENT_VERTICAL_CENTER"}]})) + + html = parse_description(desc, "Gy3bmuzmWMM") + + expect(html).to eq("Feyrer is on \u{1F525}! Thank you again, Master Jedi.") + end + + it "renders proxied channel emojis as images" do + # Fixture derived from a real capture: only the image URL was changed to + # a yt3.ggpht.com one (custom channel emoji) and its label set accordingly. + desc = JSON.parse(%q({"content":"Great video :_ToonThumbsUp: love it","attachmentRuns":[{"startIndex":12,"length":15,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://yt3.ggpht.com/UCxyz/aKgIYbrtFqmL8gT4576oCA=w24-h24-c-k-nd","width":24,"height":24}]}}},"properties":{"accessibilityProperties":{"label":"ToonThumbsUp"}}}}]})) + + html = parse_description(desc, "Gy3bmuzmWMM") + + expect(html).to eq(%(Great video ToonThumbsUp love it)) + end + + it "renders links and channel emojis in order when both are present" do + # Minimal synthetic fixture: a command run without onTap stays as plain + # text, followed by a proxyable emoji attachment. + desc = JSON.parse(%q({"content":"see docs now :ToonFire:","attachmentRuns":[{"startIndex":13,"length":10,"element":{"type":{"imageType":{"image":{"sources":[{"url":"https://yt3.ggpht.com/x/fire.png"}]}}},"properties":{"accessibilityProperties":{"label":"ToonFire"}}}}],"commandRuns":[{"startIndex":4,"length":4}]})) + + html = parse_description(desc, "Gy3bmuzmWMM") + + expect(html).to eq(%(see docs now ToonFire)) + end + + it "escapes HTML entities in the fast path" do + desc = JSON.parse(%q({"content":"a 0xFFFF + copied += 1 + end +end + +# Builds an tag for a given attachment run. Only images hosted on +# domains proxied through /ggpht are supported; attachments pointing at +# www.youtube.com (standard unicode emojis rendered as images by YouTube) +# return nil, as the underlying text characters already render fine. +private def attachment_to_img(attachment : JSON::Any) : String? + source = attachment.dig?("element", "type", "imageType", "image", "sources", 0) + url = source.try &.dig?("url").try &.as_s + return unless url + + uri = URI.parse(url) + host = uri.host.try &.downcase + return unless host.try(&.ends_with?("ggpht.com")) || + host.try(&.ends_with?("googleusercontent.com")) + + alt = attachment.dig?("element", "properties", "accessibilityProperties", "label").try &.as_s || "" + width = source.try &.dig?("width").try &.as_i || 16 + height = source.try &.dig?("height").try &.as_i || 16 + + String.build do |str| + str << %() << HTML.escape(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? + commands = (desc["commandRuns"]?.try &.as_a) || Array(JSON::Any).new + attachments = (desc["attachmentRuns"]?.try &.as_a) || Array(JSON::Any).new + # Only image attachments are supported for now + attachments = attachments.select do |attachment| + attachment.dig?("element", "type", "imageType", "image", "sources", 0, "url") + end + + if commands.empty? && attachments.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| @@ -60,34 +106,53 @@ 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. + # + # Links (commandRuns) and image attachments (attachmentRuns, used for custom + # channel emojis) share the same startIndex/length coordinate space and are + # merged into one ordered list of runs below. + runs = [] of {Int32, Int32, JSON::Any, Bool} + commands.each do |command| + runs << {command["startIndex"].as_i, command["length"].as_i, command, false} + end + attachments.each do |attachment| + runs << {attachment["startIndex"].as_i, attachment["length"].as_i, attachment, true} + end + runs.sort_by!(&.[0]) + 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 |(start, length, node, is_attachment)| + # Defensive check against overlapping runs + next if start < index - # Copy the text chunk between this command and the previous if needed. - length = cmd_start - index - index += copy_string(str, iter, length) + # Copy the text chunk between this run and the previous one if needed. + gap = start - index + index += copy_string(str, iter, gap) - # 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) + if img = is_attachment ? attachment_to_img(node) : nil + # Skip the attached characters: the image replaces them entirely. + skip_string(iter, length) + str << img + else + # We need to copy the command's text using the iterator + # and the special function defined above. + cmd_content = String.build(length) do |str2| + copy_string(str2, iter, length) + end + + link = cmd_content + if !is_attachment && (on_tap = node.dig?("onTap", "innertubeCommand")) + link = parse_link_endpoint(on_tap, cmd_content, video_id) + end + str << link end - - link = cmd_content - if on_tap = command.dig?("onTap", "innertubeCommand") - link = parse_link_endpoint(on_tap, cmd_content, video_id) - end - str << link - index += cmd_length + index += 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