mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 00:52:45 -05:00
Merge 99c48181e51e215007b8d31a9f2e2d9c12176dc7 into 049d591d294e2a43cb32b97a0bb018c2093e5beb
This commit is contained in:
commit
0d20286e5c
442
spec/invidious/videos/description_spec.cr
Normal file
442
spec/invidious/videos/description_spec.cr
Normal file
@ -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 <world> & \\"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(
|
||||||
|
%(<a href="/watch?v=dQw4w9WgXcQ">Watch</a> 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 <img alt="ToonThumbsUp" src="/ggpht/abcdef=w24-h24-c-k-nd" ) +
|
||||||
|
%(title="ToonThumbsUp" width="24" height="24" class="channel-emoji" />)
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
%(<img alt="Hi" src="/ggpht/xyz=w24-h24-c-k-nd" title="Hi" width="24" height="24" class="channel-emoji" />) +
|
||||||
|
%( <a href="/watch?v=dQw4w9WgXcQ">watch</a>)
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
%(<img alt="face-red-heart-shape" src="/ggpht/I0Mem9dU=s16-w24-h24-c-k-nd" ) +
|
||||||
|
%(title="face-red-heart-shape" width="16" height="16" class="channel-emoji" />)
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
%(<img alt="a&b" src="/ggpht/x=w24-h24" title="a&b" ) +
|
||||||
|
%(width="24" height="24" class="channel-emoji" />)
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
%(<img alt="e" src="/ggpht/x"onerror=alert(1) a="" title="e" ) +
|
||||||
|
%(width="24" height="24" class="channel-emoji" />)
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
%(<img alt="e" src="/ggpht/z=w24-h24" title="e" width="24" height="24" class="channel-emoji" />) +
|
||||||
|
%(<a href="/watch?v=dQw4w9WgXcQ">watch</a>)
|
||||||
|
)
|
||||||
|
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} <img alt="Test" src="/ggpht/test=w24-h24-c-k-nd" title="Test" width="24" height="24" class="channel-emoji" />)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
require "html"
|
||||||
require "json"
|
require "json"
|
||||||
require "uri"
|
require "uri"
|
||||||
|
|
||||||
@ -40,6 +41,69 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
|||||||
return copied
|
return copied
|
||||||
end
|
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.
|
||||||
|
private 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
|
||||||
|
# <img> 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 << %(<img alt=") << label << "\" "
|
||||||
|
str << %(src="/ggpht) << HTML.escape(uri.request_target) << "\" "
|
||||||
|
str << %(title=") << label << "\" "
|
||||||
|
str << %(width=") << (width || 24) << "\" "
|
||||||
|
str << %(height=") << (height || 24) << "\" "
|
||||||
|
str << %(class="channel-emoji" />)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def parse_description(desc, video_id : String) : String?
|
def parse_description(desc, video_id : String) : String?
|
||||||
return "" if desc.nil?
|
return "" if desc.nil?
|
||||||
|
|
||||||
@ -47,7 +111,9 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
return "" if content.empty?
|
return "" if content.empty?
|
||||||
|
|
||||||
commands = desc["commandRuns"]?.try &.as_a
|
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
|
# Slightly faster than HTML.escape, as we're only doing one pass on
|
||||||
# the string instead of five for the standard library
|
# the string instead of five for the standard library
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
@ -56,6 +122,18 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
end
|
end
|
||||||
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
|
# 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
|
# (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
|
# 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
|
index = 0
|
||||||
|
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
commands.each do |command|
|
runs.each do |(run_start, run_length, is_attachment, node)|
|
||||||
cmd_start = command["startIndex"].as_i
|
# Overlapping runs would desynchronize the iterator from the index, so
|
||||||
cmd_length = command["length"].as_i
|
# 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.
|
# Copy the text chunk between this run and the previous if needed.
|
||||||
length = cmd_start - index
|
length = run_start - index
|
||||||
index += copy_string(str, iter, length)
|
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.
|
# and the special function defined above.
|
||||||
cmd_content = String.build(cmd_length) do |str2|
|
# Attachments may have a length of 0, when youtube inserts an image
|
||||||
copy_string(str2, iter, cmd_length)
|
# 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
|
end
|
||||||
|
|
||||||
link = cmd_content
|
index += run_length
|
||||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
|
||||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
|
||||||
end
|
|
||||||
str << link
|
|
||||||
index += cmd_length
|
|
||||||
end
|
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)
|
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
||||||
remaining_length = content_size - index
|
remaining_length = content_size - index
|
||||||
copy_string(str, iter, remaining_length) if remaining_length > 0
|
copy_string(str, iter, remaining_length) if remaining_length > 0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user