diff --git a/spec/invidious/videos/description_spec.cr b/spec/invidious/videos/description_spec.cr new file mode 100644 index 00000000..ee4cf253 --- /dev/null +++ b/spec/invidious/videos/description_spec.cr @@ -0,0 +1,103 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_description" do + it "renders custom channel emoji as an image" do + # Custom emoji only exist in the attachment run; the text content carries + # the ":shortcode:" placeholder, which must not be shown as-is. + raw = { + "content" => ":face-red-heart-shape: hello", + "attachmentRuns" => [ + { + "startIndex" => 0, + "length" => 22, + "element" => { + "type" => { + "imageType" => { + "image" => { + "sources" => [ + {"url" => "https://lh3.googleusercontent.com/abc=s16-w24-h24-c-k-nd", "width" => 16, "height" => 16}, + ], + }, + }, + }, + "properties" => { + "accessibilityProperties" => {"label" => "face-red-heart-shape"}, + }, + }, + }, + ], + } + + result = parse_description(JSON.parse(raw.to_json), "") + + expect(result).to eq( + %(face-red-heart-shape hello) + ) + end + + it "leaves standard unicode emoji untouched" do + # Standard emoji also come with an attachment run, but the text content is + # already the emoji character, so it must be kept as text. + raw = { + "content" => "Nice sharing 🎉", + "attachmentRuns" => [ + { + "startIndex" => 13, + "length" => 2, + "element" => { + "type" => { + "imageType" => { + "image" => { + "sources" => [ + {"url" => "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f389.png", "width" => 16, "height" => 16}, + ], + }, + }, + }, + "properties" => { + "accessibilityProperties" => {"label" => "🎉"}, + }, + }, + }, + ], + } + + result = parse_description(JSON.parse(raw.to_json), "") + + expect(result).to eq("Nice sharing 🎉") + end + + it "keeps text offsets correct when an emoji precedes other content" do + # The emoji run is consumed from the same iterator as the surrounding + # text, so a wrong length here would shift everything after it. + raw = { + "content" => "a:face-red-heart-shape:b", + "attachmentRuns" => [ + { + "startIndex" => 1, + "length" => 22, + "element" => { + "type" => { + "imageType" => { + "image" => { + "sources" => [ + {"url" => "https://lh3.googleusercontent.com/abc", "width" => 16, "height" => 16}, + ], + }, + }, + }, + "properties" => { + "accessibilityProperties" => {"label" => "face-red-heart-shape"}, + }, + }, + }, + ], + } + + result = parse_description(JSON.parse(raw.to_json), "") + + expect(result).to start_with("ab") + end +end diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 2c983269..6a011fe3 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -40,6 +40,37 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I return copied end +# Custom channel emoji are sent as an attachment run holding the image, while +# the text content only carries a ":shortcode:" placeholder. If the image is +# not substituted in, that raw placeholder is what gets displayed. +# +# Standard Unicode emoji are sent as attachment runs too, but their text +# content is the emoji character itself, which already renders correctly. Those +# are left as-is, so we don't proxy an image for every single emoji. +private def attachment_run_to_html(run : JSON::Any, text : String) : String + return text if !(text.starts_with?(':') && text.ends_with?(':') && text.size > 2) + + source = run.dig?("element", "type", "imageType", "image", "sources", 0) + return text if source.nil? + + url = source["url"]?.try &.as_s + return text if url.nil? + + label = run.dig?("element", "properties", "accessibilityProperties", "label") + .try &.as_s || text + width = source["width"]?.try &.as_i? || 16 + height = source["height"]?.try &.as_i? || 16 + + return String.build do |str| + str << %() << HTML.escape(label) << %() + end +end + def parse_description(desc, video_id : String) : String? return "" if desc.nil? @@ -47,7 +78,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 +89,17 @@ def parse_description(desc, video_id : String) : String? end end + # Both kinds of run index into the same string, so they have to be walked + # together in positional order. + runs = [] of {Int32, Int32, Bool, JSON::Any} + commands.try &.each do |command| + runs << {command["startIndex"].as_i, command["length"].as_i, false, command} + end + attachments.try &.each do |attachment| + runs << {attachment["startIndex"].as_i, attachment["length"].as_i, true, attachment} + end + runs.sort_by! { |run| run[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,26 +109,32 @@ 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, run)| + # A command and an attachment can cover the same characters. The + # iterator can only move forward, so skip anything already consumed. + 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) + run_content = String.build(run_length) do |str2| + copy_string(str2, iter, run_length) end - link = cmd_content - if on_tap = command.dig?("onTap", "innertubeCommand") - link = parse_link_endpoint(on_tap, cmd_content, video_id) + if is_attachment + str << attachment_run_to_html(run, run_content) + else + link = run_content + if on_tap = run.dig?("onTap", "innertubeCommand") + link = parse_link_endpoint(on_tap, run_content, video_id) + end + str << link end - str << link - index += cmd_length + + index += run_length end # Copy the end of the string (past the last command).