mirror of
https://github.com/iv-org/invidious.git
synced 2026-08-16 21:10:53 -05:00
feat: render custom channel emoji in descriptions and comments
Add attachmentRuns handling to parse_description so custom channel emoji (image shortcodes such as :yt:) render as <img> tags instead of falling back to their textual form. The commentViewModel path (comments/youtube.cr) and the video description path (parser.cr) both route through parse_description, which previously only handled commandRuns (links). This change merges commandRuns and attachmentRuns sorted by startIndex and emits an <img class=channel-emoji> for image-type attachments, mirroring the behaviour of the older content_to_comment_html implementation. Fixes #5888
This commit is contained in:
parent
eb407578a2
commit
bab6a7b798
@ -40,6 +40,8 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
||||
return copied
|
||||
end
|
||||
|
||||
private record Run, start_index : Int32, length : Int32, kind : Symbol, data : JSON::Any
|
||||
|
||||
def parse_description(desc, video_id : String) : String?
|
||||
return "" if desc.nil?
|
||||
|
||||
@ -47,49 +49,121 @@ def parse_description(desc, video_id : String) : String?
|
||||
return "" if content.empty?
|
||||
|
||||
commands = desc["commandRuns"]?.try &.as_a
|
||||
if commands.nil?
|
||||
# Slightly faster than HTML.escape, as we're only doing one pass on
|
||||
# the string instead of five for the standard library
|
||||
attachments = desc["attachmentRuns"]?.try &.as_a
|
||||
|
||||
# If no runs at all, fall back to simple escape
|
||||
if commands.nil? && attachments.nil?
|
||||
return String.build do |str|
|
||||
content_size = content.ascii_only? ? content.size : utf16_length(content)
|
||||
copy_string(str, content.each_codepoint, content_size)
|
||||
end
|
||||
end
|
||||
|
||||
# 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
|
||||
# copied byte in a special manner, preventing the use of regular string copy.
|
||||
iter = content.each_codepoint
|
||||
# Merge commandRuns and attachmentRuns sorted by startIndex
|
||||
runs = [] of Run
|
||||
|
||||
if commands
|
||||
commands.each do |cmd|
|
||||
runs << Run.new(
|
||||
start_index: cmd["startIndex"].as_i,
|
||||
length: cmd["length"].as_i,
|
||||
kind: :command,
|
||||
data: cmd
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if attachments
|
||||
attachments.each do |att|
|
||||
runs << Run.new(
|
||||
start_index: att["startIndex"].as_i,
|
||||
length: att["length"].as_i,
|
||||
kind: :attachment,
|
||||
data: att
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
runs.sort_by!(&.start_index)
|
||||
|
||||
iter = content.each_codepoint
|
||||
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|
|
||||
cmd_start = run.start_index
|
||||
cmd_length = run.length
|
||||
|
||||
# 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
|
||||
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)
|
||||
end
|
||||
|
||||
link = cmd_content
|
||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
||||
if run.kind == :command
|
||||
# Handle link commands
|
||||
link = cmd_content
|
||||
if on_tap = run.data.dig?("onTap", "innertubeCommand")
|
||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
||||
end
|
||||
str << link
|
||||
elsif run.kind == :attachment
|
||||
# Handle attachment runs (emoji images)
|
||||
element = run.data["element"]?
|
||||
|
||||
if element.is_a?(JSON::Any) && element["type"]?.try &.as_s == "imageType"
|
||||
image = element["image"]?
|
||||
|
||||
if image.is_a?(JSON::Any)
|
||||
sources = image["sources"]?.try &.as_a
|
||||
|
||||
if sources && sources[0]?
|
||||
source = sources[0]
|
||||
# Get the emoji image URL
|
||||
emoji_url = source["url"]?.try &.as_s
|
||||
# Fallback for clientResource format (badges)
|
||||
if emoji_url.nil? || emoji_url.empty?
|
||||
emoji_url = source.dig?("clientResource", "imageName").try &.as_s
|
||||
end
|
||||
|
||||
if emoji_url && !emoji_url.empty?
|
||||
# Get accessibility label
|
||||
alt_text = image.dig?("accessibility", "accessibilityData", "label").try &.as_s || cmd_content
|
||||
|
||||
# Build the img tag
|
||||
if emoji_url.starts_with?("http://") || emoji_url.starts_with?("https://")
|
||||
str << %(<img alt=") << alt_text << "\" "
|
||||
str << %(src=") << emoji_url << "\" "
|
||||
str << %(title=") << alt_text << "\" "
|
||||
str << %(class="channel-emoji" />)
|
||||
else
|
||||
# For internal resource names (badges), skip rendering
|
||||
str << cmd_content
|
||||
end
|
||||
else
|
||||
str << cmd_content
|
||||
end
|
||||
else
|
||||
str << cmd_content
|
||||
end
|
||||
else
|
||||
str << cmd_content
|
||||
end
|
||||
else
|
||||
str << cmd_content
|
||||
end
|
||||
end
|
||||
str << link
|
||||
|
||||
index += cmd_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
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
x
Reference in New Issue
Block a user