diff --git a/spec/invidious/videos/description_spec.cr b/spec/invidious/videos/description_spec.cr new file mode 100644 index 000000000..90957945d --- /dev/null +++ b/spec/invidious/videos/description_spec.cr @@ -0,0 +1,442 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_description" do + it "parses a description without any run" do + desc = JSON.parse(%({"content": "Hello & \\"friends\\""})) + + expect(parse_description(desc, "")).to eq( + "Hello <world> & "friends"" + ) + end + + it "parses a description with a link (commandRuns)" do + desc = JSON.parse(<<-JSON) + { + "content": "Watch this", + "commandRuns": [ + { + "startIndex": 0, + "length": 5, + "onTap": { + "innertubeCommand": { + "watchEndpoint": { "videoId": "dQw4w9WgXcQ" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "someOtherId")).to eq( + %(Watch this) + ) + end + + # Standard emojis are sent as an attachment, but the content string already + # contains the real unicode character, so the text must be left untouched. + # Captured from https://www.youtube.com/watch?v=Gy3bmuzmWMM + it "keeps standard emojis as unicode" do + desc = JSON.parse(<<-JSON) + { + "content": "everyone deserves to grow❤️", + "attachmentRuns": [ + { + "startIndex": 25, + "length": 1, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { + "url": "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u2764.png", + "width": 16, + "height": 16 + } + ] + } + } + }, + "properties": { + "accessibilityProperties": { "label": "❤" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq("everyone deserves to grow❤️") + end + + # Youtube inserts social media icons in descriptions with a length of 0, + # meaning no text is covered by the attachment. + # Captured from https://www.youtube.com/watch?v=Gy3bmuzmWMM + it "does not consume text for a zero length attachment" do + desc = JSON.parse(<<-JSON) + { + "content": "Follow me on twitter", + "attachmentRuns": [ + { + "startIndex": 14, + "length": 0, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { "url": "https://www.gstatic.com/youtube/img/watch/social_media/twitter_1x_v2.png" } + ] + } + } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq("Follow me on twitter") + end + + # Custom channel emojis have no unicode equivalent and are hosted on ggpht, + # so they must be rendered as a proxied image. + it "renders custom channel emojis as an image" do + desc = JSON.parse(<<-JSON) + { + "content": "nice video :_ToonThumbsUp:", + "attachmentRuns": [ + { + "startIndex": 11, + "length": 15, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { + "url": "https://yt3.ggpht.com/abcdef=w24-h24-c-k-nd", + "width": 24, + "height": 24 + } + ] + } + } + }, + "properties": { + "layoutProperties": { + "width": { "value": 24 }, + "height": { "value": 24 } + }, + "accessibilityProperties": { "label": "ToonThumbsUp" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq( + %(nice video ToonThumbsUp) + ) + end + + # Both kinds of run index into the same string and share a single iterator, + # so they have to be walked in order of appearance. + it "parses a description mixing a link and a custom emoji" do + desc = JSON.parse(<<-JSON) + { + "content": ":_Hi: watch", + "commandRuns": [ + { + "startIndex": 6, + "length": 5, + "onTap": { + "innertubeCommand": { + "watchEndpoint": { "videoId": "dQw4w9WgXcQ" } + } + } + } + ], + "attachmentRuns": [ + { + "startIndex": 0, + "length": 5, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { "url": "https://yt3.ggpht.com/xyz=w24-h24-c-k-nd", "width": 24, "height": 24 } + ] + } + } + }, + "properties": { + "accessibilityProperties": { "label": "Hi" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "someOtherId")).to eq( + %(Hi) + + %( watch) + ) + end + + # Real capture of the comment linked in #5888, whose emojis are hosted on + # lh3.googleusercontent.com rather than ggpht: + # https://www.youtube.com/watch?v=Gy3bmuzmWMM&lc=UgxVdYO3R6wrr9sLhT54AaABAg + it "renders the emojis of the comment reported in #5888" do + desc = JSON.parse(<<-JSON) + { + "content": ":face-red-heart-shape:", + "attachmentRuns": [ + { + "startIndex": 0, + "length": 22, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { + "url": "https://lh3.googleusercontent.com/I0Mem9dU=s16-w24-h24-c-k-nd", + "width": 16, + "height": 16 + } + ] + } + } + }, + "properties": { + "layoutProperties": { + "height": { "value": 16 }, + "width": { "value": 16 } + }, + "accessibilityProperties": { "label": "face-red-heart-shape" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq( + %(face-red-heart-shape) + ) + end + + it "does not treat a gstatic image as a custom emoji" do + desc = JSON.parse(<<-JSON) + { + "content": "hi", + "attachmentRuns": [ + { + "startIndex": 0, + "length": 2, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { "url": "https://www.gstatic.com/youtube/img/watch/x.png", "width": 16, "height": 16 } + ] + } + } + }, + "properties": { + "accessibilityProperties": { "label": "x" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq("hi") + end + + # copy_string escapes the text as it copies it, so the fallback used when + # youtube sends no label must not be escaped a second time. + it "does not escape the fallback label twice" do + desc = JSON.parse(<<-JSON) + { + "content": "a&b", + "attachmentRuns": [ + { + "startIndex": 0, + "length": 3, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { "url": "https://yt3.ggpht.com/x=w24-h24", "width": 24, "height": 24 } + ] + } + } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq( + %(a&b) + ) + end + + # URI#request_target returns the path untouched, quotes included, so it has + # to be escaped before being placed inside the src attribute. + it "escapes a quote in the image url" do + desc = JSON.parse(<<-JSON) + { + "content": "hi", + "attachmentRuns": [ + { + "startIndex": 0, + "length": 2, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { "url": "https://yt3.ggpht.com/x\\"onerror=alert(1) a=\\"", "width": 24, "height": 24 } + ] + } + } + }, + "properties": { + "accessibilityProperties": { "label": "e" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq( + %(e) + ) + end + + # Matching the bare domain suffix would let a lookalike host through. + it "rejects a lookalike emoji host" do + desc = JSON.parse(<<-JSON) + { + "content": "hi", + "attachmentRuns": [ + { + "startIndex": 0, + "length": 2, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { "url": "https://evilgoogleusercontent.com/x=w24-h24", "width": 24, "height": 24 } + ] + } + } + }, + "properties": { + "accessibilityProperties": { "label": "e" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq("hi") + end + + # A zero length attachment consumes no text, so a command starting at the + # same index must not advance the cursor past it and drop the image. + it "keeps a zero-length attachment sharing an index with a command" do + desc = JSON.parse(<<-JSON) + { + "content": "watch", + "commandRuns": [ + { + "startIndex": 0, + "length": 5, + "onTap": { + "innertubeCommand": { + "watchEndpoint": { "videoId": "dQw4w9WgXcQ" } + } + } + } + ], + "attachmentRuns": [ + { + "startIndex": 0, + "length": 0, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { "url": "https://yt3.ggpht.com/z=w24-h24", "width": 24, "height": 24 } + ] + } + } + }, + "properties": { + "accessibilityProperties": { "label": "e" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "someOtherId")).to eq( + %(e) + + %(watch) + ) + end + + # Youtube counts indexes in UTF-16 code units, so a single SMP codepoint + # placed before a run shifts every following index by two. + it "handles offsets after an SMP codepoint" do + desc = JSON.parse(<<-JSON) + { + "content": "\u{1F600} :_Test:", + "attachmentRuns": [ + { + "startIndex": 3, + "length": 7, + "element": { + "type": { + "imageType": { + "image": { + "sources": [ + { "url": "https://yt3.ggpht.com/test=w24-h24-c-k-nd", "width": 24, "height": 24 } + ] + } + } + }, + "properties": { + "accessibilityProperties": { "label": "Test" } + } + } + } + ] + } + JSON + + expect(parse_description(desc, "")).to eq( + %(\u{1F600} Test) + ) + end +end diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 2c9832690..62469c00f 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -1,3 +1,4 @@ +require "html" require "json" require "uri" @@ -40,6 +41,69 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I return copied end +# Google image CDNs that the /ggpht route can serve. They share a path +# namespace, so an emoji hosted on googleusercontent is also reachable through +# the ggpht pool that route proxies to. +EMOJI_IMAGE_DOMAINS = {"ggpht.com", "googleusercontent.com"} + +# Custom emojis (channel membership ones, and the ":face-red-heart-shape:" +# set) have no unicode equivalent, so youtube sends them as an image +# attachment covering the ":shortcut:" text, which has to be rendered as an +# tag, otherwise the raw shortcut is shown to the user. +# +# Standard emojis are sent as attachments too, but the content string already +# holds the real unicode character, so those are left untouched: rendering +# them as images would only add pointless requests to youtube's servers. +# +# Custom emojis are hosted on google's image CDNs, which are all reachable +# through the /ggpht route, so anything hosted elsewhere (youtube.com and +# gstatic.com serve the standard emojis) is deliberately left as text. +private def parse_emoji_attachment(attachment : JSON::Any, text : String) : String + image = attachment.dig?("element", "type", "imageType", "image") + return text if image.nil? + + source = image.dig?("sources", 0) + return text if source.nil? + + url = source["url"]?.try &.as_s + return text if url.nil? + + uri = URI.parse(url) + host = uri.host + return text if host.nil? + + # Matching on the bare suffix would also accept "notggpht.com", so the host + # has to be the domain itself or one of its subdomains. + accepted = EMOJI_IMAGE_DOMAINS.any? do |domain| + host == domain || host.ends_with?(".#{domain}") + end + return text if !accepted + + properties = attachment.dig?("element", "properties") + + # The label is the emoji name, e.g "ToonThumbsUp" for ":_ToonThumbsUp:". + # `text` was produced by copy_string, which escapes as it copies, so it is + # only the raw label coming from youtube that still needs to be escaped. + raw_label = properties.try(&.dig?("accessibilityProperties", "label")).try(&.as_s) + label = raw_label.nil? ? text : HTML.escape(raw_label) + + # Youtube gives the display size in "points" here, and the intrinsic size + # of the image in the source. The former is what the official client uses. + width = properties.try(&.dig?("layoutProperties", "width", "value")).try(&.as_i) + height = properties.try(&.dig?("layoutProperties", "height", "value")).try(&.as_i) + width ||= source["width"]?.try &.as_i + height ||= source["height"]?.try &.as_i + + return String.build do |str| + str << %() << label << ) + end +end + def parse_description(desc, video_id : String) : String? return "" if desc.nil? @@ -47,7 +111,9 @@ def parse_description(desc, video_id : String) : String? return "" if content.empty? commands = desc["commandRuns"]?.try &.as_a - if commands.nil? + attachments = desc["attachmentRuns"]?.try &.as_a + + if commands.nil? && attachments.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| @@ -56,6 +122,18 @@ def parse_description(desc, video_id : String) : String? end end + # Commands (links) and attachments (emojis) both index into the same string + # and are consumed by the same iterator below, so they have to be walked in + # a single pass, ordered by their position in the text. + # Runs of length 0 consume nothing and come first, otherwise a command + # sharing their index would advance past them and they'd be dropped by the + # overlap check. Commands then come before attachments, as Crystal's sort is + # not guaranteed to be stable. + runs = [] of Tuple(Int32, Int32, Bool, JSON::Any) + commands.try &.each { |cmd| runs << {cmd["startIndex"].as_i, cmd["length"].as_i, false, cmd} } + attachments.try &.each { |att| runs << {att["startIndex"].as_i, att["length"].as_i, true, att} } + runs.sort_by! { |run| {run[0], run[1] == 0 ? 0 : 1, run[2] ? 1 : 0} } + # 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 @@ -65,29 +143,39 @@ def parse_description(desc, video_id : String) : String? 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_start, run_length, is_attachment, node)| + # Overlapping runs would desynchronize the iterator from the index, so + # anything starting inside an already consumed run is skipped. + next if run_start < index - # Copy the text chunk between this command and the previous if needed. - length = cmd_start - index + # Copy the text chunk between this run and the previous if needed. + length = run_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) + # Attachments may have a length of 0, when youtube inserts an image + # without any text behind it, in which case nothing is consumed. + run_content = if run_length > 0 + String.build(run_length) { |str2| copy_string(str2, iter, run_length) } + else + "" + end + + if is_attachment + str << parse_emoji_attachment(node, run_content) + else + link = run_content + if on_tap = node.dig?("onTap", "innertubeCommand") + link = parse_link_endpoint(on_tap, run_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 += run_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