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(
+ %(
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("a
b")
+ 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 << %(
)
+ 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).