修复推荐视频有多个创作者时,频道名称无法点击的问题

This commit is contained in:
awer123456 2026-09-02 18:34:58 +08:00
parent b3a3f3a6df
commit 92db7b0e22
5 changed files with 561 additions and 40 deletions

View File

@ -0,0 +1,349 @@
require "../../parsers_helper.cr"
LEGACY_SINGLE_AUTHOR_RELATED_VIDEO = <<-JSON
{
"videoId": "XuoqKYxDHVc",
"title": {"simpleText": "Single-author recommendation"},
"lengthInSeconds": 5107,
"shortViewCountText": {"simpleText": "3.1M views"},
"publishedTimeText": {"simpleText": "4 days ago"},
"shortBylineText": {
"runs": [{
"text": "The Economist",
"navigationEndpoint": {
"browseEndpoint": {"browseId": "UC0p5jTq6Xx_DosDFxVXnWaQ"}
}
}]
}
}
JSON
SINGLE_AUTHOR_LOCKUP_RELATED_VIDEO = <<-JSON
{
"contentId": "_g4l7YkDQwA",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO",
"contentImage": {
"thumbnailViewModel": {
"decoratedAvatarViewModel": {
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {"browseId": "UCGq-a57w-aPwyi3pW7XLiHw"}
}
}
}
}
},
"overlays": [{
"thumbnailBottomOverlayViewModel": {
"badges": [{"thumbnailBadgeViewModel": {"text": "1:42:19"}}]
}
}]
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {"content": "Single-author lockup recommendation"},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [{
"text": {
"content": "The Diary Of A CEO",
"attachmentRuns": [{
"element": {
"type": {
"imageType": {
"image": {
"sources": [{
"clientResource": {"imageName": "CHECK_CIRCLE_FILLED"}
}]
}
}
}
}
}]
}
}]
},
{
"metadataParts": [
{
"text": {"content": "4.5M"}
},
{
"text": {"content": "3mo ago"},
"accessibilityLabel": "3 months ago"
}
]
}
]
}
}
}
}
}
JSON
MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO = <<-JSON
{
"contentId": "4klivapz4Gw",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO",
"contentImage": {
"thumbnailViewModel": {
"overlays": [{
"thumbnailBottomOverlayViewModel": {
"badges": [{"thumbnailBadgeViewModel": {"text": "3:06:19"}}]
}
}]
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {"content": "Collaborative lockup recommendation"},
"image": {
"avatarStackViewModel": {
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"showDialogCommand": {
"panelLoadingStrategy": {
"inlineContent": {
"dialogViewModel": {
"customContent": {
"listViewModel": {
"listItems": [
{
"listItemViewModel": {
"title": {
"content": "Chris Williamson",
"attachmentRuns": [{
"element": {
"type": {
"imageType": {
"image": {
"sources": [{
"clientResource": {"imageName": "CHECK_CIRCLE_FILLED"}
}]
}
}
}
}
}]
},
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {"browseId": "UCIaH-gZIVC432YRjNVvnyCA"}
}
}
}
}
}
},
{
"listItemViewModel": {
"title": {"content": "Predictive History"},
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {"browseId": "UC11aHtNnc5bEPLI4jf6mnYg"}
}
}
}
}
}
},
{
"listItemViewModel": {
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {"browseId": "UC-without-name"}
}
}
}
}
}
},
{
"listItemViewModel": {
"title": {"content": "Unlinked collaborator"}
}
}
]
}
}
}
}
}
}
}
}
}
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [{
"text": {"content": "Chris Williamson and Predictive History"}
}]
},
{
"metadataParts": [
{
"text": {"content": "1.2M"}
},
{
"text": {"content": "2mo ago"},
"accessibilityLabel": "2 months ago"
}
]
}
]
}
}
}
}
}
JSON
Spectator.describe Invidious::Videos::Parser do
describe ".parse_related_video" do
it "preserves support for legacy single-author renderers" do
related = Invidious::Videos::Parser.parse_related_video(
JSON.parse(LEGACY_SINGLE_AUTHOR_RELATED_VIDEO)
).not_nil!
expect(related["author"].as_s).to eq("The Economist")
expect(related["ucid"].as_s).to eq("UC0p5jTq6Xx_DosDFxVXnWaQ")
expect(related["authors"]?).to be_nil
end
it "parses a single-author lockup renderer" do
related = Invidious::Videos::Parser.parse_related_video(
JSON.parse(SINGLE_AUTHOR_LOCKUP_RELATED_VIDEO)
).not_nil!
expect(related["id"].as_s).to eq("_g4l7YkDQwA")
expect(related["title"].as_s).to eq("Single-author lockup recommendation")
expect(related["author"].as_s).to eq("The Diary Of A CEO")
expect(related["ucid"].as_s).to eq("UCGq-a57w-aPwyi3pW7XLiHw")
expect(related["author_verified"].as_s).to eq("true")
expect(related["length_seconds"].as_s).to eq("6139")
expect(related["short_view_count"].as_s).to eq("4.5M")
expect(related["published"].as_s).not_to be_empty
expect(related["authors"]?).to be_nil
end
it "extracts every named collaborator from a lockup avatar stack" do
related = Invidious::Videos::Parser.parse_related_video(
JSON.parse(MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO)
).not_nil!
authors = related["authors"].as_a
expect(related["id"].as_s).to eq("4klivapz4Gw")
expect(related["author"].as_s).to eq("Chris Williamson and Predictive History")
expect(related["ucid"].as_s).to be_empty
expect(related["length_seconds"].as_s).to eq("11179")
expect(related["short_view_count"].as_s).to eq("1.2M")
expect(authors.size).to eq(3)
expect(authors[0]["author"].as_s).to eq("Chris Williamson")
expect(authors[0]["ucid"].as_s).to eq("UCIaH-gZIVC432YRjNVvnyCA")
expect(authors[0]["verified"].as_bool).to be_true
expect(authors[1]["author"].as_s).to eq("Predictive History")
expect(authors[1]["ucid"].as_s).to eq("UC11aHtNnc5bEPLI4jf6mnYg")
expect(authors[1]["verified"].as_bool).to be_false
expect(authors[2]["author"].as_s).to eq("Unlinked collaborator")
expect(authors[2]["ucid"].as_s).to be_empty
end
it "ignores non-video lockups" do
related = JSON.parse(%({"contentId":"playlist","contentType":"LOCKUP_CONTENT_TYPE_PLAYLIST"}))
expect(Invidious::Videos::Parser.parse_related_video(related)).to be_nil
end
it "extracts lockups wrapped in an item section" do
lockup = JSON.parse(MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO)
results = JSON.parse({
"itemSectionRenderer" => {
"contents" => [
{"lockupViewModel" => lockup},
{"continuationItemRenderer" => {"trigger" => "CONTINUATION_TRIGGER_ON_ITEM_SHOWN"}},
],
},
}.to_json)
related = Invidious::Videos::Parser.parse_related_videos(JSON::Any.new([results]))
expect(related.size).to eq(1)
expect(related[0]["id"].as_s).to eq("4klivapz4Gw")
expect(related[0]["authors"].as_a.map(&.["author"].as_s)).to eq([
"Chris Williamson",
"Predictive History",
"Unlinked collaborator",
])
end
it "does not classify author-like metadata as views or publication dates" do
lockup = JSON.parse(MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO.sub(
"Chris Williamson and Predictive History",
"Daily Views Channel and Long Ago Podcast"
))
related = Invidious::Videos::Parser.parse_related_video(lockup).not_nil!
expect(related["author"].as_s).to eq("Daily Views Channel and Long Ago Podcast")
expect(related["short_view_count"].as_s).to eq("1.2M")
expect(related["published"].as_s).not_to be_empty
end
end
end
Spectator.describe Video do
describe "#related_videos" do
it "exposes structured collaborators while retaining legacy fields" do
parsed = Invidious::Videos::Parser.parse_related_video(
JSON.parse(MULTIPLE_AUTHORS_LOCKUP_RELATED_VIDEO)
).not_nil!
video = Video.new({
id: "IZZVijQ0gkA",
info: {
"relatedVideos" => JSON::Any.new([JSON::Any.new(parsed)]),
},
updated: Time.utc,
})
related = video.related_videos.first
expect(related["id"].as_s).to eq("4klivapz4Gw")
expect(related["author"].as_s).to eq("Chris Williamson and Predictive History")
expect(related["ucid"].as_s).to be_empty
expect(related["length_seconds"].as_s.to_i).to eq(11179)
authors = related["authors"].as_a
expect(authors.map(&.["author"].as_s)).to eq([
"Chris Williamson",
"Predictive History",
"Unlinked collaborator",
])
expect(authors.map(&.["ucid"].as_s)).to eq([
"UCIaH-gZIVC432YRjNVvnyCA",
"UC11aHtNnc5bEPLI4jf6mnYg",
"",
])
end
end
end

View File

@ -238,16 +238,16 @@ module Invidious::JSONify::APIv1
video.related_videos.each do |rv|
if rv["id"]?
json.object do
json.field "videoId", rv["id"]
json.field "title", rv["title"]
json.field "videoId", rv["id"].as_s
json.field "title", rv["title"].as_s
json.field "videoThumbnails" do
self.thumbnails(json, rv["id"])
self.thumbnails(json, rv["id"].as_s)
end
json.field "author", rv["author"]
json.field "authorUrl", "/channel/#{rv["ucid"]?}"
json.field "authorId", rv["ucid"]?
json.field "authorVerified", rv["author_verified"] == "true"
json.field "author", rv["author"].as_s
json.field "authorUrl", "/channel/#{rv["ucid"]?.try &.as_s}"
json.field "authorId", rv["ucid"]?.try &.as_s
json.field "authorVerified", rv["author_verified"].as_s == "true"
if rv["author_thumbnail"]?
json.field "authorThumbnails" do
json.array do
@ -255,7 +255,7 @@ module Invidious::JSONify::APIv1
qualities.each do |quality|
json.object do
json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-")
json.field "url", rv["author_thumbnail"].as_s.gsub(/s\d+-/, "s#{quality}-")
json.field "width", quality
json.field "height", quality
end
@ -264,11 +264,11 @@ module Invidious::JSONify::APIv1
end
end
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
json.field "viewCountText", rv["short_view_count"]?
json.field "published", rv["published"]?
if rv["published"]?.try &.presence
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale))
json.field "lengthSeconds", rv["length_seconds"]?.try &.as_s.to_i
json.field "viewCountText", rv["short_view_count"]?.try &.as_s
json.field "published", rv["published"]?.try &.as_s
if rv["published"]?.try &.as_s.presence
json.field "publishedText", I18n.translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].as_s), locale))
else
json.field "publishedText", ""
end

View File

@ -15,7 +15,7 @@ struct Video
# NOTE: don't forget to bump this number if any change is made to
# the `params` structure in videos/parser.cr!!!
#
SCHEMA_VERSION = 3
SCHEMA_VERSION = 4
property id : String
@ -87,8 +87,8 @@ struct Video
end
end
def related_videos
info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String)
def related_videos : Array(Hash(String, JSON::Any))
info["relatedVideos"]?.try &.as_a.map(&.as_h) || [] of Hash(String, JSON::Any)
end
# Methods for parsing streaming data

View File

@ -3,17 +3,20 @@ require "json"
module Invidious::Videos::Parser
extend self
# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer".
# The former is preferred as it has more videos in it. The second has
# the same 11 first entries as the compact rendered.
# Used to parse "lockupViewModel", "compactVideoRenderer", and
# "endScreenVideoRenderer" related video entries.
#
# TODO: "compactRadioRenderer" (Mix) and
# TODO: Use a proper struct/class instead of a hacky JSON object
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
if related["contentType"]?
return parse_lockup_related_video(related)
end
return nil if !related["videoId"]?
# The compact renderer has video length in seconds, where the end
# screen rendered has a full text version ("42:40")
# The compact renderer has video length in seconds, whereas the end
# screen renderer has a full text version ("42:40").
length = related["lengthInSeconds"]?.try &.as_i.to_s
length ||= related.dig?("lengthText", "simpleText").try do |box|
decode_length_seconds(box.as_s).to_s
@ -28,6 +31,8 @@ module Invidious::Videos::Parser
ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) }
authors = extract_related_video_authors(channel_info.try &.dig?("navigationEndpoint"))
short_view_count = related.try do |r|
HelperExtractors.get_short_view_count(r).to_s
end
@ -43,7 +48,7 @@ module Invidious::Videos::Parser
# TODO: when refactoring video types, make a struct for related videos
# or reuse an existing type, if that fits.
return {
parsed = {
"id" => related["videoId"],
"title" => related["title"]["simpleText"],
"author" => author || JSON::Any.new(""),
@ -53,6 +58,166 @@ module Invidious::Videos::Parser
"author_verified" => JSON::Any.new(author_verified),
"published" => JSON::Any.new(published || ""),
}
parsed["authors"] = JSON::Any.new(authors) if !authors.empty?
return parsed
end
def parse_related_videos(results : JSON::Any?) : Array(JSON::Any)
related = [] of JSON::Any
results.try &.as_a.each do |element|
items = element.dig?("itemSectionRenderer", "contents").try &.as_a
items ||= [element]
items.each do |item|
renderer = item["compactVideoRenderer"]? || item["lockupViewModel"]?
next if !renderer
related_video = self.parse_related_video(renderer)
related << JSON::Any.new(related_video) if related_video
end
end
return related
end
private def parse_lockup_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
return nil if related["contentType"]?.try &.as_s? != "LOCKUP_CONTENT_TYPE_VIDEO"
video_id = related["contentId"]?.try &.as_s?
metadata = related.dig?("metadata", "lockupMetadataViewModel")
title = metadata.try &.dig?("title", "content").try &.as_s?
return nil if !video_id || !metadata || !title
author_info = metadata.dig?(
"metadata", "contentMetadataViewModel", "metadataRows", 0,
"metadataParts", 0, "text"
)
author = author_info.try &.dig?("content").try &.as_s? || ""
author_verified = text_has_verified_badge?(author_info)
ucid = related.dig?(
"contentImage", "thumbnailViewModel", "decoratedAvatarViewModel",
"rendererContext", "commandContext",
"onTap", "innertubeCommand", "browseEndpoint", "browseId"
).try &.as_s?
ucid ||= author_info.try &.dig?(
"commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId"
).try &.as_s?
author_command = metadata.dig?(
"image", "avatarStackViewModel", "rendererContext", "commandContext",
"onTap", "innertubeCommand"
)
authors = extract_related_video_authors(author_command)
ucid = nil if !authors.empty?
length_text = nil
overlays = related.dig?("contentImage", "thumbnailViewModel", "overlays").try &.as_a?
overlays.try &.each do |overlay|
badges = overlay.dig?("thumbnailBottomOverlayViewModel", "badges").try &.as_a?
badges.try &.each do |badge|
text = badge.dig?("thumbnailBadgeViewModel", "text").try &.as_s?
if text && text.includes?(':')
length_text = text
break
end
end
break if length_text
end
metadata_parts = [] of JSON::Any
metadata_rows = metadata.dig?(
"metadata", "contentMetadataViewModel", "metadataRows"
).try &.as_a?
metadata_rows.try &.each do |row|
parts = row["metadataParts"]?.try &.as_a?
metadata_parts.concat(parts) if parts
end
short_view_count = "0"
published_time_text = nil
metadata_parts.each do |part|
text = part.dig?("text", "content").try &.as_s?
next if !text
if related_view_count_text?(text)
short_view_count = text.gsub(/\s*views?\z/i, "")
elsif related_published_text?(text)
published_time_text = text
end
end
published = published_time_text.try do |text|
begin
decode_date(text).to_rfc3339
rescue
""
end
end || ""
parsed = {
"id" => JSON::Any.new(video_id),
"title" => JSON::Any.new(title),
"author" => JSON::Any.new(author),
"ucid" => JSON::Any.new(ucid || ""),
"length_seconds" => JSON::Any.new(decode_length_seconds(length_text || "").to_s),
"short_view_count" => JSON::Any.new(short_view_count),
"author_verified" => JSON::Any.new(author_verified.to_s),
"published" => JSON::Any.new(published),
}
parsed["authors"] = JSON::Any.new(authors) if !authors.empty?
return parsed
end
private def extract_related_video_authors(command : JSON::Any?) : Array(JSON::Any)
authors = [] of JSON::Any
dialog_items = command.try &.dig?(
"showDialogCommand", "panelLoadingStrategy", "inlineContent",
"dialogViewModel", "customContent", "listViewModel", "listItems"
).try &.as_a?
dialog_items.try &.each do |item|
author_info = item["listItemViewModel"]?
next if !author_info
name = author_info.dig?("title", "content").try &.as_s?
next if !name || name.empty?
author_ucid = author_info.dig?(
"rendererContext", "commandContext", "onTap", "innertubeCommand",
"browseEndpoint", "browseId"
).try &.as_s? || ""
authors << JSON::Any.new({
"author" => JSON::Any.new(name),
"ucid" => JSON::Any.new(author_ucid),
"verified" => JSON::Any.new(text_has_verified_badge?(author_info["title"]?)),
})
end
return authors
end
private def text_has_verified_badge?(text : JSON::Any?) : Bool
badge = text.try &.dig?(
"attachmentRuns", 0, "element", "type", "imageType", "image",
"sources", 0, "clientResource", "imageName"
).try &.as_s?
return {"CHECK_CIRCLE_FILLED", "AUDIO_BADGE"}.includes?(badge)
end
private def related_view_count_text?(text : String) : Bool
return text.matches?(/\A\d+(?:[.,]\d+)?[KMBT]?(?:\s+views?)?\z/i)
end
private def related_published_text?(text : String) : Bool
return text.matches?(
/\A\d+\s*(?:s|sec(?:ond)?s?|min(?:ute)?s?|h|hr|hours?|d|days?|w|wk|weeks?|mo|months?|y|yr|years?)\s+ago\z/i
)
end
def extract_video_info(video_id : String)
@ -238,17 +403,10 @@ module Invidious::Videos::Parser
LOGGER.debug("extract_video_info: parsing related videos...")
related = [] of JSON::Any
# Parse "compactVideoRenderer" items (under secondary results)
# Parse legacy and lockup items under secondary results.
secondary_results = main_results
.dig?("secondaryResults", "secondaryResults", "results")
secondary_results.try &.as_a.each do |element|
if item = element["compactVideoRenderer"]?
related_video = self.parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
related = self.parse_related_videos(secondary_results)
# If nothing was found previously, fall back to end screen renderer
if related.empty?

View File

@ -58,7 +58,7 @@ we're going to need to do it here in order to allow for translations.
"plid" => plid,
"length_seconds" => video.length_seconds.to_f,
"play_next" => !video.related_videos.empty? && !plid && params.continue,
"next_video" => video.related_videos.select { |rv| rv["id"]? }[0]?.try &.["id"],
"next_video" => video.related_videos.select { |rv| rv["id"]? }[0]?.try &.["id"].as_s,
"youtube_comments_text" => HTML.escape(I18n.translate(locale, "View YouTube comments")),
"comments_youtube_disabled_text" => HTML.escape(I18n.translate(locale, "comments_youtube_disabled_text")),
"comments_youtube_disabled_try_reddit" => HTML.escape(I18n.translate(locale, "comments_youtube_disabled_try_reddit")),
@ -369,36 +369,50 @@ we're going to need to do it here in order to allow for translations.
<div class="thumbnail">
<%- if !env.get("preferences").as(Preferences).thin_mode -%>
<a tabindex="-1" href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>">
<img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg" alt="" />
<a tabindex="-1" href="/watch?v=<%= rv["id"].as_s %>&listen=<%= params.listen %>">
<img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"].as_s %>/mqdefault.jpg" alt="" />
</a>
<%- else -%>
<div class="thumbnail-placeholder"></div>
<%- end -%>
<div class="bottom-right-overlay">
<%- if (length_seconds = rv["length_seconds"]?.try &.to_i?) && length_seconds != 0 -%>
<%- if (length_seconds = rv["length_seconds"]?.try &.as_s.to_i?) && length_seconds != 0 -%>
<p class="length"><%= recode_length_seconds(length_seconds) %></p>
<%- end -%>
</div>
</div>
<div class="video-card-row">
<a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>"><p dir="auto"><%= HTML.escape(rv["title"]) %></p></a>
<a href="/watch?v=<%= rv["id"].as_s %>&listen=<%= params.listen %>"><p dir="auto"><%= HTML.escape(rv["title"].as_s) %></p></a>
</div>
<h5 class="pure-g">
<div class="pure-u-14-24">
<% if !rv["ucid"].empty? %>
<b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
<% if authors = rv["authors"]?.try &.as_a? %>
<b style="width:100%">
<% authors.each_with_index do |related_author, index| %>
<% if index > 0 %><% if index == authors.size - 1 %> and <% else %>, <% end %><% end %>
<% related_author_name = related_author["author"].as_s %>
<% related_author_ucid = related_author["ucid"].as_s %>
<% if !related_author_ucid.empty? %>
<a href="/channel/<%= URI.encode_www_form(related_author_ucid) %>"><%= HTML.escape(related_author_name) %></a>
<% else %>
<%= HTML.escape(related_author_name) %>
<% end %>
<% if related_author["verified"].as_bool %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
<% end %>
</b>
<% elsif !rv["ucid"].as_s.empty? %>
<b style="width:100%"><a href="/channel/<%= URI.encode_www_form(rv["ucid"].as_s) %>"><%= HTML.escape(rv["author"]?.try &.as_s || "") %><% if rv["author_verified"]?.try &.as_s == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
<% else %>
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
<b style="width:100%"><%= HTML.escape(rv["author"]?.try &.as_s || "") %><% if rv["author_verified"]?.try &.as_s == "true" %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b>
<% end %>
</div>
<div class="pure-u-10-24" style="text-align:right">
<b class="width:100%"><%=
views = short_text_to_number(rv["short_view_count"]? || "0")
views = short_text_to_number(rv["short_view_count"]?.try &.as_s || "0")
I18n.translate_count(locale, "generic_views_count", views, I18n::NumberFormatting::Short)
%></b>
</div>