Comments: Add support for new format (#4576)

The new comment format is similar to the description's commandRuns.

This should fix the issues with most comments but there are still
some more changes that would need to be made like adding support for
formatting (bold, italic, underline) and channel emojis.

Fixes issue 4566
This commit is contained in:
Samantaz Fox 2024-04-26 23:45:44 +02:00
commit 7c1d2714e0
No known key found for this signature in database
GPG key ID: F42821059186176E
5 changed files with 148 additions and 70 deletions

View file

@ -64,15 +64,15 @@ def content_to_comment_html(content, video_id : String? = "")
# check for custom emojis # check for custom emojis
if run["emoji"]? if run["emoji"]?
if run["emoji"]["isCustomEmoji"]?.try &.as_bool if run["emoji"]["isCustomEmoji"]?.try &.as_bool
if emojiImage = run.dig?("emoji", "image") if emoji_image = run.dig?("emoji", "image")
emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text emoji_alt = emoji_image.dig?("accessibility", "accessibilityData", "label").try &.as_s || text
emojiThumb = emojiImage["thumbnails"][0] emoji_thumb = emoji_image["thumbnails"][0]
text = String.build do |str| text = String.build do |str|
str << %(<img alt=") << emojiAlt << "\" " str << %(<img alt=") << emoji_alt << "\" "
str << %(src="/ggpht) << URI.parse(emojiThumb["url"].as_s).request_target << "\" " str << %(src="/ggpht) << URI.parse(emoji_thumb["url"].as_s).request_target << "\" "
str << %(title=") << emojiAlt << "\" " str << %(title=") << emoji_alt << "\" "
str << %(width=") << emojiThumb["width"] << "\" " str << %(width=") << emoji_thumb["width"] << "\" "
str << %(height=") << emojiThumb["height"] << "\" " str << %(height=") << emoji_thumb["height"] << "\" "
str << %(class="channel-emoji" />) str << %(class="channel-emoji" />)
end end
else else

View file

@ -57,7 +57,7 @@ module Invidious::Comments
return initial_data return initial_data
end end
def parse_youtube(id, response, format, locale, thin_mode, sort_by = "top", isPost = false) def parse_youtube(id, response, format, locale, thin_mode, sort_by = "top", is_post = false)
contents = nil contents = nil
if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? if on_response_received_endpoints = response["onResponseReceivedEndpoints"]?
@ -104,6 +104,8 @@ module Invidious::Comments
end end
end end
mutations = response.dig?("frameworkUpdates", "entityBatchUpdate", "mutations").try &.as_a || [] of JSON::Any
response = JSON.build do |json| response = JSON.build do |json|
json.object do json.object do
if header if header
@ -113,7 +115,7 @@ module Invidious::Comments
json.field "commentCount", comment_count json.field "commentCount", comment_count
end end
if isPost if is_post
json.field "postId", id json.field "postId", id
else else
json.field "videoId", id json.field "videoId", id
@ -131,18 +133,76 @@ module Invidious::Comments
node_replies = node["replies"]["commentRepliesRenderer"] node_replies = node["replies"]["commentRepliesRenderer"]
end end
if cvm = node["commentViewModel"]?
# two commentViewModels for inital request
# one commentViewModel when getting a replies to a comment
cvm = cvm["commentViewModel"] if cvm["commentViewModel"]?
comment_key = cvm["commentKey"]
toolbar_key = cvm["toolbarStateKey"]
comment_mutation = mutations.find { |i| i.dig?("payload", "commentEntityPayload", "key") == comment_key }
toolbar_mutation = mutations.find { |i| i.dig?("entityKey") == toolbar_key }
if !comment_mutation.nil? && !toolbar_mutation.nil?
# todo parse styleRuns, commandRuns and attachmentRuns for comments
html_content = parse_description(comment_mutation.dig("payload", "commentEntityPayload", "properties", "content"), id)
comment_author = comment_mutation.dig("payload", "commentEntityPayload", "author")
json.field "authorId", comment_author["channelId"].as_s
json.field "authorUrl", "/channel/#{comment_author["channelId"].as_s}"
json.field "author", comment_author["displayName"].as_s
json.field "verified", comment_author["isVerified"].as_bool
json.field "authorThumbnails" do
json.array do
comment_mutation.dig?("payload", "commentEntityPayload", "avatar", "image", "sources").try &.as_a.each do |thumbnail|
json.object do
json.field "url", thumbnail["url"]
json.field "width", thumbnail["width"]
json.field "height", thumbnail["height"]
end
end
end
end
json.field "authorIsChannelOwner", comment_author["isCreator"].as_bool
json.field "isSponsor", (comment_author["sponsorBadgeUrl"]? != nil)
if sponsor_badge_url = comment_author["sponsorBadgeUrl"]?
# Sponsor icon thumbnails always have one object and there's only ever the url property in it
json.field "sponsorIconUrl", sponsor_badge_url
end
comment_toolbar = comment_mutation.dig("payload", "commentEntityPayload", "toolbar")
json.field "likeCount", short_text_to_number(comment_toolbar["likeCountNotliked"].as_s)
reply_count = short_text_to_number(comment_toolbar["replyCount"]?.try &.as_s || "0")
if heart_state = toolbar_mutation.dig?("payload", "engagementToolbarStateEntityPayload", "heartState")
if heart_state.as_s == "TOOLBAR_HEART_STATE_HEARTED"
json.field "creatorHeart" do
json.object do
json.field "creatorThumbnail", comment_toolbar["creatorThumbnailUrl"].as_s
json.field "creatorName", comment_toolbar["heartActiveTooltip"].as_s.sub("❤ by ", "")
end
end
end
end
published_text = comment_mutation.dig?("payload", "commentEntityPayload", "properties", "publishedTime").try &.as_s
end
json.field "isPinned", (cvm.dig?("pinnedText") != nil)
json.field "commentId", cvm["commentId"]
else
if node["comment"]? if node["comment"]?
node_comment = node["comment"]["commentRenderer"] node_comment = node["comment"]["commentRenderer"]
else else
node_comment = node["commentRenderer"] node_comment = node["commentRenderer"]
end end
json.field "commentId", node_comment["commentId"]
content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || "" html_content = node_comment["contentText"]?.try { |t| parse_content(t, id) }
author = node_comment["authorText"]?.try &.["simpleText"]? || ""
json.field "verified", (node_comment["authorCommentBadge"]? != nil) json.field "verified", (node_comment["authorCommentBadge"]? != nil)
json.field "author", author json.field "author", node_comment["authorText"]?.try &.["simpleText"]? || ""
json.field "authorThumbnails" do json.field "authorThumbnails" do
json.array do json.array do
node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail|
@ -155,6 +215,19 @@ module Invidious::Comments
end end
end end
if comment_action_buttons_renderer = node_comment.dig?("actionButtons", "commentActionButtonsRenderer")
json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i
if comment_action_buttons_renderer["creatorHeart"]?
heart_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"]
json.field "creatorHeart" do
json.object do
json.field "creatorThumbnail", heart_data["thumbnails"][-1]["url"]
json.field "creatorName", heart_data["accessibility"]["accessibilityData"]["label"]
end
end
end
end
if node_comment["authorEndpoint"]? if node_comment["authorEndpoint"]?
json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"]
json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"]
@ -163,41 +236,35 @@ module Invidious::Comments
json.field "authorUrl", "" json.field "authorUrl", ""
end end
published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
published = decode_date(published_text.rchop(" (edited)"))
if published_text.includes?(" (edited)")
json.field "isEdited", true
else
json.field "isEdited", false
end
json.field "content", html_to_content(content_html)
json.field "contentHtml", content_html
json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil)
published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s
json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil) json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil)
if node_comment["sponsorCommentBadge"]? if node_comment["sponsorCommentBadge"]?
# Sponsor icon thumbnails always have one object and there's only ever the url property in it # Sponsor icon thumbnails always have one object and there's only ever the url property in it
json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s
end end
reply_count = node_comment["replyCount"]?
end
content_html = html_content || ""
json.field "content", html_to_content(content_html)
json.field "contentHtml", content_html
if published_text != nil
published_text = published_text.to_s
if published_text.includes?(" (edited)")
json.field "isEdited", true
published = decode_date(published_text.rchop(" (edited)"))
else
json.field "isEdited", false
published = decode_date(published_text)
end
json.field "published", published.to_unix json.field "published", published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"]
json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i
json.field "commentId", node_comment["commentId"]
json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"]
if comment_action_buttons_renderer["creatorHeart"]?
hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"]
json.field "creatorHeart" do
json.object do
json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"]
json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"]
end
end
end end
if node_replies && !response["commentRepliesContinuation"]? if node_replies && !response["commentRepliesContinuation"]?
@ -210,7 +277,7 @@ module Invidious::Comments
json.field "replies" do json.field "replies" do
json.object do json.object do
json.field "replyCount", node_comment["replyCount"]? || 1 json.field "replyCount", reply_count || 1
json.field "continuation", continuation json.field "continuation", continuation
end end
end end
@ -236,7 +303,6 @@ module Invidious::Comments
if format == "html" if format == "html"
response = JSON.parse(response) response = JSON.parse(response)
content_html = Frontend::Comments.template_youtube(response, locale, thin_mode) content_html = Frontend::Comments.template_youtube(response, locale, thin_mode)
response = JSON.build do |json| response = JSON.build do |json|
json.object do json.object do
json.field "contentHtml", content_html json.field "contentHtml", content_html

View file

@ -394,7 +394,7 @@ module Invidious::Routes::API::V1::Channels
else else
comments = YoutubeAPI.browse(continuation: continuation) comments = YoutubeAPI.browse(continuation: continuation)
end end
return Comments.parse_youtube(id, comments, format, locale, thin_mode, isPost: true) return Comments.parse_youtube(id, comments, format, locale, thin_mode, is_post: true)
end end
def self.channels(env) def self.channels(env)

View file

@ -231,7 +231,7 @@ module Invidious::Routes::Channels
if nojs if nojs
comments = Comments.fetch_community_post_comments(ucid, id) comments = Comments.fetch_community_post_comments(ucid, id)
comment_html = JSON.parse(Comments.parse_youtube(id, comments, "html", locale, thin_mode, isPost: true))["contentHtml"] comment_html = JSON.parse(Comments.parse_youtube(id, comments, "html", locale, thin_mode, is_post: true))["contentHtml"]
end end
templated "post" templated "post"
end end

View file

@ -7,7 +7,19 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
cp = iter.next cp = iter.next
break if cp.is_a?(Iterator::Stop) break if cp.is_a?(Iterator::Stop)
if cp == 0x26 # Ampersand (&)
str << "&amp;"
elsif cp == 0x27 # Single quote (')
str << "&#39;"
elsif cp == 0x22 # Double quote (")
str << "&quot;"
elsif cp == 0x3C # Less-than (<)
str << "&lt;"
elsif cp == 0x3E # Greater than (>)
str << "&gt;"
else
str << cp.chr str << cp.chr
end
# A codepoint from the SMP counts twice # A codepoint from the SMP counts twice
copied += 1 if cp > 0xFFFF copied += 1 if cp > 0xFFFF