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