mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 00:52:45 -05:00
修复推荐视频有多个创作者时,频道名称无法点击的问题
This commit is contained in:
parent
b3a3f3a6df
commit
92db7b0e22
349
spec/invidious/videos/related_video_spec.cr
Normal file
349
spec/invidious/videos/related_video_spec.cr
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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" %> <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 %> <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" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b>
|
||||
<% else %>
|
||||
<b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <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" %> <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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user