From ed68874dc637bcd20ba1a78337297325a0e2863c Mon Sep 17 00:00:00 2001 From: waroxa <5514643+waroxa@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:57:40 -0400 Subject: [PATCH] fix: use UTF-16 offsets for description links --- spec/invidious/videos/description_spec.cr | 56 +++++++++++++++++++++++ src/invidious/videos/description.cr | 13 +++--- 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 spec/invidious/videos/description_spec.cr diff --git a/spec/invidious/videos/description_spec.cr b/spec/invidious/videos/description_spec.cr new file mode 100644 index 000000000..8766e2720 --- /dev/null +++ b/spec/invidious/videos/description_spec.cr @@ -0,0 +1,56 @@ +require "../../spec_helper" +require "../../../src/invidious/videos/description" + +Spectator.describe "Video description parser" do + describe "#parse_description" do + it "uses UTF-16 offsets when an emoji precedes a command" do + description = JSON.parse(<<-JSON) + { + "content": "🚀 Visit example now", + "commandRuns": [{ + "startIndex": 3, + "length": 5, + "onTap": { + "innertubeCommand": { + "urlEndpoint": {"url": "https://example.com"} + } + } + }] + } + JSON + + expect(parse_description(description, "video-id")).to eq( + %(🚀 Visit example now) + ) + end + + it "uses UTF-16 lengths when an emoji is inside a command" do + description = JSON.parse(<<-JSON) + { + "content": "Open 😀 docs", + "commandRuns": [{ + "startIndex": 0, + "length": 7, + "onTap": { + "innertubeCommand": { + "urlEndpoint": {"url": "https://example.com"} + } + } + }] + } + JSON + + expect(parse_description(description, "video-id")).to eq( + %(Open 😀 docs) + ) + end + + it "escapes complete descriptions without command runs" do + description = JSON.parse(%({"content":"🚀 & complete"})) + + expect(parse_description(description, "video-id")).to eq( + %(🚀 <safe> & complete) + ) + end + end +end diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 18b4122eb..e0864f11a 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -1,9 +1,9 @@ require "json" require "uri" -private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int +private def copy_string(str : String::Builder, iter : Iterator, count : Int? = nil) : Int copied = 0 - while copied < count + while count.nil? || copied < count cp = iter.next break if cp.is_a?(Iterator::Stop) @@ -21,7 +21,9 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I str << cp.chr end - copied += 1 + # YouTube expresses command offsets in UTF-16 code units. Astral + # codepoints therefore count as two units instead of one. + copied += cp > 0xFFFF ? 2 : 1 end return copied @@ -38,7 +40,7 @@ def parse_description(desc, video_id : String) : String? # 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| - copy_string(str, content.each_codepoint, content.size) + copy_string(str, content.each_codepoint) end end @@ -70,7 +72,6 @@ def parse_description(desc, video_id : String) : String? end # Copy the end of the string (past the last command). - remaining_length = content.size - index - copy_string(str, iter, remaining_length) if remaining_length > 0 + copy_string(str, iter) end end