2018-08-13 06:34:13 +05:30
|
|
|
CAPTION_LANGUAGES = {
|
2018-08-06 23:53:36 +05:30
|
|
|
"",
|
|
|
|
"English",
|
|
|
|
"English (auto-generated)",
|
2022-02-08 06:43:14 +05:30
|
|
|
"English (United Kingdom)",
|
|
|
|
"English (United States)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Afrikaans",
|
|
|
|
"Albanian",
|
|
|
|
"Amharic",
|
|
|
|
"Arabic",
|
|
|
|
"Armenian",
|
|
|
|
"Azerbaijani",
|
|
|
|
"Bangla",
|
|
|
|
"Basque",
|
|
|
|
"Belarusian",
|
|
|
|
"Bosnian",
|
|
|
|
"Bulgarian",
|
|
|
|
"Burmese",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Cantonese (Hong Kong)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Catalan",
|
|
|
|
"Cebuano",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Chinese",
|
|
|
|
"Chinese (China)",
|
|
|
|
"Chinese (Hong Kong)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Chinese (Simplified)",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Chinese (Taiwan)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Chinese (Traditional)",
|
|
|
|
"Corsican",
|
|
|
|
"Croatian",
|
|
|
|
"Czech",
|
|
|
|
"Danish",
|
|
|
|
"Dutch",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Dutch (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Esperanto",
|
|
|
|
"Estonian",
|
|
|
|
"Filipino",
|
|
|
|
"Finnish",
|
|
|
|
"French",
|
2022-02-08 06:43:14 +05:30
|
|
|
"French (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Galician",
|
|
|
|
"Georgian",
|
|
|
|
"German",
|
2022-02-08 06:43:14 +05:30
|
|
|
"German (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Greek",
|
|
|
|
"Gujarati",
|
|
|
|
"Haitian Creole",
|
|
|
|
"Hausa",
|
|
|
|
"Hawaiian",
|
|
|
|
"Hebrew",
|
|
|
|
"Hindi",
|
|
|
|
"Hmong",
|
|
|
|
"Hungarian",
|
|
|
|
"Icelandic",
|
|
|
|
"Igbo",
|
|
|
|
"Indonesian",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Indonesian (auto-generated)",
|
|
|
|
"Interlingue",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Irish",
|
|
|
|
"Italian",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Italian (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Japanese",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Japanese (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Javanese",
|
|
|
|
"Kannada",
|
|
|
|
"Kazakh",
|
|
|
|
"Khmer",
|
|
|
|
"Korean",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Korean (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Kurdish",
|
|
|
|
"Kyrgyz",
|
|
|
|
"Lao",
|
|
|
|
"Latin",
|
|
|
|
"Latvian",
|
|
|
|
"Lithuanian",
|
|
|
|
"Luxembourgish",
|
|
|
|
"Macedonian",
|
|
|
|
"Malagasy",
|
|
|
|
"Malay",
|
|
|
|
"Malayalam",
|
|
|
|
"Maltese",
|
|
|
|
"Maori",
|
|
|
|
"Marathi",
|
|
|
|
"Mongolian",
|
|
|
|
"Nepali",
|
2019-04-19 21:44:11 +05:30
|
|
|
"Norwegian Bokmål",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Nyanja",
|
|
|
|
"Pashto",
|
|
|
|
"Persian",
|
|
|
|
"Polish",
|
|
|
|
"Portuguese",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Portuguese (auto-generated)",
|
|
|
|
"Portuguese (Brazil)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Punjabi",
|
|
|
|
"Romanian",
|
|
|
|
"Russian",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Russian (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Samoan",
|
|
|
|
"Scottish Gaelic",
|
|
|
|
"Serbian",
|
|
|
|
"Shona",
|
|
|
|
"Sindhi",
|
|
|
|
"Sinhala",
|
|
|
|
"Slovak",
|
|
|
|
"Slovenian",
|
|
|
|
"Somali",
|
|
|
|
"Southern Sotho",
|
|
|
|
"Spanish",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Spanish (auto-generated)",
|
2018-08-07 04:55:25 +05:30
|
|
|
"Spanish (Latin America)",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Spanish (Mexico)",
|
|
|
|
"Spanish (Spain)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Sundanese",
|
|
|
|
"Swahili",
|
|
|
|
"Swedish",
|
|
|
|
"Tajik",
|
|
|
|
"Tamil",
|
|
|
|
"Telugu",
|
|
|
|
"Thai",
|
|
|
|
"Turkish",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Turkish (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Ukrainian",
|
|
|
|
"Urdu",
|
|
|
|
"Uzbek",
|
|
|
|
"Vietnamese",
|
2022-02-08 06:43:14 +05:30
|
|
|
"Vietnamese (auto-generated)",
|
2018-08-06 23:53:36 +05:30
|
|
|
"Welsh",
|
|
|
|
"Western Frisian",
|
|
|
|
"Xhosa",
|
|
|
|
"Yiddish",
|
|
|
|
"Yoruba",
|
|
|
|
"Zulu",
|
2018-08-13 06:34:13 +05:30
|
|
|
}
|
2018-08-06 23:53:36 +05:30
|
|
|
|
2019-03-30 03:00:02 +05:30
|
|
|
struct Video
|
2020-07-26 20:28:50 +05:30
|
|
|
include DB::Serializable
|
|
|
|
|
|
|
|
property id : String
|
|
|
|
|
|
|
|
@[DB::Field(converter: Video::JSONConverter)]
|
|
|
|
property info : Hash(String, JSON::Any)
|
|
|
|
property updated : Time
|
|
|
|
|
|
|
|
@[DB::Field(ignore: true)]
|
|
|
|
property captions : Array(Caption)?
|
|
|
|
|
|
|
|
@[DB::Field(ignore: true)]
|
|
|
|
property adaptive_fmts : Array(Hash(String, JSON::Any))?
|
|
|
|
|
|
|
|
@[DB::Field(ignore: true)]
|
|
|
|
property fmt_stream : Array(Hash(String, JSON::Any))?
|
|
|
|
|
|
|
|
@[DB::Field(ignore: true)]
|
|
|
|
property description : String?
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
module JSONConverter
|
2018-08-05 02:00:44 +05:30
|
|
|
def self.from_rs(rs)
|
2020-06-16 04:03:23 +05:30
|
|
|
JSON.parse(rs.read(String)).as_h
|
2018-08-05 02:00:44 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2021-11-09 04:22:55 +05:30
|
|
|
def to_json(locale : String?, json : JSON::Builder)
|
2019-06-09 00:01:41 +05:30
|
|
|
json.object do
|
|
|
|
json.field "type", "video"
|
|
|
|
|
|
|
|
json.field "title", self.title
|
|
|
|
json.field "videoId", self.id
|
2020-06-16 03:40:30 +05:30
|
|
|
|
|
|
|
json.field "error", info["reason"] if info["reason"]?
|
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "videoThumbnails" do
|
2020-06-16 03:40:30 +05:30
|
|
|
generate_thumbnails(json, self.id)
|
2019-06-09 00:01:41 +05:30
|
|
|
end
|
|
|
|
json.field "storyboards" do
|
2020-06-16 03:40:30 +05:30
|
|
|
generate_storyboards(json, self.id, self.storyboards)
|
2019-06-09 00:01:41 +05:30
|
|
|
end
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
json.field "description", self.description
|
2019-06-09 01:38:27 +05:30
|
|
|
json.field "descriptionHtml", self.description_html
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "published", self.published.to_unix
|
|
|
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
|
|
|
json.field "keywords", self.keywords
|
|
|
|
|
|
|
|
json.field "viewCount", self.views
|
|
|
|
json.field "likeCount", self.likes
|
2022-06-21 04:31:25 +05:30
|
|
|
json.field "dislikeCount", 0_i64
|
2019-06-09 00:01:41 +05:30
|
|
|
|
2021-08-15 14:08:30 +05:30
|
|
|
json.field "paid", self.paid
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "premium", self.premium
|
|
|
|
json.field "isFamilyFriendly", self.is_family_friendly
|
|
|
|
json.field "allowedRegions", self.allowed_regions
|
|
|
|
json.field "genre", self.genre
|
|
|
|
json.field "genreUrl", self.genre_url
|
|
|
|
|
|
|
|
json.field "author", self.author
|
|
|
|
json.field "authorId", self.ucid
|
|
|
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
|
|
|
|
|
|
|
json.field "authorThumbnails" do
|
|
|
|
json.array do
|
|
|
|
qualities = {32, 48, 76, 100, 176, 512}
|
|
|
|
|
|
|
|
qualities.each do |quality|
|
|
|
|
json.object do
|
2019-08-01 05:46:09 +05:30
|
|
|
json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "width", quality
|
|
|
|
json.field "height", quality
|
2019-04-11 04:28:42 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2019-06-09 00:01:41 +05:30
|
|
|
end
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "subCountText", self.sub_count_text
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2019-07-30 06:11:45 +05:30
|
|
|
json.field "lengthSeconds", self.length_seconds
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "allowRatings", self.allow_ratings
|
2022-06-21 04:31:25 +05:30
|
|
|
json.field "rating", 0_i64
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "isListed", self.is_listed
|
|
|
|
json.field "liveNow", self.live_now
|
|
|
|
json.field "isUpcoming", self.is_upcoming
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
if self.premiere_timestamp
|
2020-06-16 04:03:23 +05:30
|
|
|
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
|
2019-06-09 00:01:41 +05:30
|
|
|
end
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2020-06-16 03:40:30 +05:30
|
|
|
if hlsvp = self.hls_manifest_url
|
|
|
|
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL)
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "hlsUrl", hlsvp
|
|
|
|
end
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2020-06-16 03:40:30 +05:30
|
|
|
json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{id}"
|
2019-06-09 00:01:41 +05:30
|
|
|
|
|
|
|
json.field "adaptiveFormats" do
|
|
|
|
json.array do
|
2020-06-16 04:03:23 +05:30
|
|
|
self.adaptive_fmts.each do |fmt|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.object do
|
2022-04-28 01:14:31 +05:30
|
|
|
# Only available on regular videos, not livestreams/OTF streams
|
2022-04-27 03:50:48 +05:30
|
|
|
if init_range = fmt["initRange"]?
|
|
|
|
json.field "init", "#{init_range["start"]}-#{init_range["end"]}"
|
|
|
|
end
|
|
|
|
if index_range = fmt["indexRange"]?
|
|
|
|
json.field "index", "#{index_range["start"]}-#{index_range["end"]}"
|
|
|
|
end
|
|
|
|
|
|
|
|
# Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only)
|
|
|
|
json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]?
|
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "url", fmt["url"]
|
2020-06-16 04:03:23 +05:30
|
|
|
json.field "itag", fmt["itag"].as_i.to_s
|
|
|
|
json.field "type", fmt["mimeType"]
|
2022-04-27 03:50:48 +05:30
|
|
|
json.field "clen", fmt["contentLength"]? || "-1"
|
2020-06-16 04:03:23 +05:30
|
|
|
json.field "lmt", fmt["lastModified"]
|
|
|
|
json.field "projectionType", fmt["projectionType"]
|
2019-06-09 00:01:41 +05:30
|
|
|
|
2022-05-24 01:24:48 +05:30
|
|
|
if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
|
2020-06-16 04:03:23 +05:30
|
|
|
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "fps", fps
|
|
|
|
json.field "container", fmt_info["ext"]
|
|
|
|
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
|
|
|
|
|
|
|
|
if fmt_info["height"]?
|
|
|
|
json.field "resolution", "#{fmt_info["height"]}p"
|
|
|
|
|
|
|
|
quality_label = "#{fmt_info["height"]}p"
|
|
|
|
if fps > 30
|
|
|
|
quality_label += "60"
|
|
|
|
end
|
|
|
|
json.field "qualityLabel", quality_label
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
if fmt_info["width"]?
|
|
|
|
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
|
2019-04-11 04:28:42 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2022-04-27 03:51:23 +05:30
|
|
|
|
2022-05-01 20:30:56 +05:30
|
|
|
# Livestream chunk infos
|
|
|
|
json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec")
|
|
|
|
json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec")
|
|
|
|
|
2022-04-27 03:51:23 +05:30
|
|
|
# Audio-related data
|
|
|
|
json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality")
|
|
|
|
json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate")
|
|
|
|
json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels")
|
|
|
|
|
|
|
|
# Extra misc stuff
|
|
|
|
json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo")
|
|
|
|
json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack")
|
2019-04-11 04:28:42 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2019-06-09 00:01:41 +05:30
|
|
|
end
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "formatStreams" do
|
|
|
|
json.array do
|
2020-06-16 04:03:23 +05:30
|
|
|
self.fmt_stream.each do |fmt|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.object do
|
|
|
|
json.field "url", fmt["url"]
|
2020-06-16 04:03:23 +05:30
|
|
|
json.field "itag", fmt["itag"].as_i.to_s
|
|
|
|
json.field "type", fmt["mimeType"]
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "quality", fmt["quality"]
|
|
|
|
|
2022-05-24 01:24:48 +05:30
|
|
|
fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"])
|
2019-06-09 00:01:41 +05:30
|
|
|
if fmt_info
|
2020-06-16 04:03:23 +05:30
|
|
|
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "fps", fps
|
|
|
|
json.field "container", fmt_info["ext"]
|
|
|
|
json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"]
|
|
|
|
|
|
|
|
if fmt_info["height"]?
|
|
|
|
json.field "resolution", "#{fmt_info["height"]}p"
|
|
|
|
|
|
|
|
quality_label = "#{fmt_info["height"]}p"
|
|
|
|
if fps > 30
|
|
|
|
quality_label += "60"
|
|
|
|
end
|
|
|
|
json.field "qualityLabel", quality_label
|
|
|
|
|
|
|
|
if fmt_info["width"]?
|
|
|
|
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
|
2019-04-11 04:28:42 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2019-06-09 00:01:41 +05:30
|
|
|
end
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "captions" do
|
|
|
|
json.array do
|
|
|
|
self.captions.each do |caption|
|
|
|
|
json.object do
|
2021-06-27 19:48:16 +05:30
|
|
|
json.field "label", caption.name
|
2021-09-25 07:45:23 +05:30
|
|
|
json.field "language_code", caption.language_code
|
2021-06-27 19:48:16 +05:30
|
|
|
json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}"
|
2019-04-11 04:28:42 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2019-06-09 00:01:41 +05:30
|
|
|
end
|
2019-04-11 04:28:42 +05:30
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "recommendedVideos" do
|
|
|
|
json.array do
|
2020-06-16 04:03:23 +05:30
|
|
|
self.related_videos.each do |rv|
|
2019-06-09 00:01:41 +05:30
|
|
|
if rv["id"]?
|
|
|
|
json.object do
|
|
|
|
json.field "videoId", rv["id"]
|
|
|
|
json.field "title", rv["title"]
|
|
|
|
json.field "videoThumbnails" do
|
2020-06-16 03:40:30 +05:30
|
|
|
generate_thumbnails(json, rv["id"])
|
2019-04-11 04:28:42 +05:30
|
|
|
end
|
2019-08-27 18:30:04 +05:30
|
|
|
|
2019-06-09 00:01:41 +05:30
|
|
|
json.field "author", rv["author"]
|
2022-02-03 06:14:11 +05:30
|
|
|
json.field "authorUrl", "/channel/#{rv["ucid"]?}"
|
2019-08-31 09:27:33 +05:30
|
|
|
json.field "authorId", rv["ucid"]?
|
2019-08-27 18:30:04 +05:30
|
|
|
if rv["author_thumbnail"]?
|
|
|
|
json.field "authorThumbnails" do
|
|
|
|
json.array do
|
|
|
|
qualities = {32, 48, 76, 100, 176, 512}
|
|
|
|
|
|
|
|
qualities.each do |quality|
|
|
|
|
json.object do
|
2022-02-03 08:25:43 +05:30
|
|
|
json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-")
|
2019-08-27 18:30:04 +05:30
|
|
|
json.field "width", quality
|
|
|
|
json.field "height", quality
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
|
2022-02-03 06:14:11 +05:30
|
|
|
json.field "viewCountText", rv["short_view_count"]?
|
2020-06-16 04:03:23 +05:30
|
|
|
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
|
2019-04-11 04:28:42 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2021-10-29 18:23:06 +05:30
|
|
|
# TODO: remove the locale and follow the crystal convention
|
2021-11-09 04:22:55 +05:30
|
|
|
def to_json(locale : String?, _json : Nil)
|
2021-10-29 18:23:06 +05:30
|
|
|
JSON.build { |json| to_json(locale, json) }
|
|
|
|
end
|
|
|
|
|
|
|
|
def to_json(json : JSON::Builder | Nil = nil)
|
|
|
|
to_json(nil, json)
|
2019-06-09 00:01:41 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def title
|
|
|
|
info["videoDetails"]["title"]?.try &.as_s || ""
|
2019-06-09 01:38:27 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def ucid
|
|
|
|
info["videoDetails"]["channelId"]?.try &.as_s || ""
|
2019-06-09 01:38:27 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def author
|
|
|
|
info["videoDetails"]["author"]?.try &.as_s || ""
|
|
|
|
end
|
2019-03-22 21:02:42 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def length_seconds : Int32
|
2022-01-21 02:52:48 +05:30
|
|
|
info.dig?("microformat", "playerMicroformatRenderer", "lengthSeconds").try &.as_s.to_i ||
|
2020-06-16 04:03:23 +05:30
|
|
|
info["videoDetails"]["lengthSeconds"]?.try &.as_s.to_i || 0
|
2019-03-22 21:02:42 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def views : Int64
|
|
|
|
info["videoDetails"]["viewCount"]?.try &.as_s.to_i64 || 0_i64
|
|
|
|
end
|
2019-03-22 21:02:42 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def likes : Int64
|
|
|
|
info["likes"]?.try &.as_i64 || 0_i64
|
|
|
|
end
|
2019-03-22 21:02:42 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def dislikes : Int64
|
|
|
|
info["dislikes"]?.try &.as_i64 || 0_i64
|
2019-03-22 21:02:42 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def published : Time
|
2022-01-21 02:52:48 +05:30
|
|
|
info
|
|
|
|
.dig?("microformat", "playerMicroformatRenderer", "publishDate")
|
|
|
|
.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
2019-03-22 21:02:42 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def published=(other : Time)
|
|
|
|
info["microformat"].as_h["playerMicroformatRenderer"].as_h["publishDate"] = JSON::Any.new(other.to_s("%Y-%m-%d"))
|
2019-03-22 21:02:42 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def allow_ratings
|
|
|
|
r = info["videoDetails"]["allowRatings"]?.try &.as_bool
|
|
|
|
r.nil? ? false : r
|
|
|
|
end
|
2019-03-22 21:36:58 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def live_now
|
2021-02-25 09:36:50 +05:30
|
|
|
info["microformat"]?.try &.["playerMicroformatRenderer"]?
|
|
|
|
.try &.["liveBroadcastDetails"]?.try &.["isLiveNow"]?.try &.as_bool || false
|
2019-03-22 21:36:58 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def is_listed
|
|
|
|
info["videoDetails"]["isCrawlable"]?.try &.as_bool || false
|
|
|
|
end
|
2019-03-22 21:36:58 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def is_upcoming
|
|
|
|
info["videoDetails"]["isUpcoming"]?.try &.as_bool || false
|
|
|
|
end
|
2019-03-22 22:54:47 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def premiere_timestamp : Time?
|
2022-01-21 02:52:48 +05:30
|
|
|
info
|
|
|
|
.dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp")
|
|
|
|
.try { |t| Time.parse_rfc3339(t.as_s) }
|
2019-03-22 21:36:58 +05:30
|
|
|
end
|
|
|
|
|
2018-11-02 18:39:28 +05:30
|
|
|
def keywords
|
2020-06-16 04:03:23 +05:30
|
|
|
info["videoDetails"]["keywords"]?.try &.as_a.map &.as_s || [] of String
|
2018-11-02 18:39:28 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def related_videos
|
|
|
|
info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String)
|
|
|
|
end
|
2019-02-26 04:58:35 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def allowed_regions
|
2022-01-21 02:52:48 +05:30
|
|
|
info
|
2022-03-30 23:22:39 +05:30
|
|
|
.dig?("microformat", "playerMicroformatRenderer", "availableCountries")
|
2022-01-21 02:52:48 +05:30
|
|
|
.try &.as_a.map &.as_s || [] of String
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
2019-02-26 04:58:35 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def author_thumbnail : String
|
|
|
|
info["authorThumbnail"]?.try &.as_s || ""
|
|
|
|
end
|
2019-02-26 04:58:35 +05:30
|
|
|
|
2022-05-02 00:40:43 +05:30
|
|
|
def author_verified : Bool
|
2022-05-02 00:41:12 +05:30
|
|
|
info["authorVerified"]?.try &.as_bool || false
|
2022-05-02 00:40:43 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def sub_count_text : String
|
|
|
|
info["subCountText"]?.try &.as_s || "-"
|
|
|
|
end
|
2019-02-26 04:58:35 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def fmt_stream
|
|
|
|
return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream
|
2020-07-26 20:28:50 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
fmt_stream = info["streamingData"]?.try &.["formats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
|
|
|
|
fmt_stream.each do |fmt|
|
|
|
|
if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) }
|
|
|
|
s.each do |k, v|
|
|
|
|
fmt[k] = JSON::Any.new(v)
|
2019-02-26 04:58:35 +05:30
|
|
|
end
|
2020-09-27 22:49:44 +05:30
|
|
|
fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}")
|
2018-08-05 09:37:38 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}")
|
|
|
|
fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]?
|
2018-08-05 09:37:38 +05:30
|
|
|
end
|
2022-04-27 03:50:48 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
|
|
|
|
@fmt_stream = fmt_stream
|
|
|
|
return @fmt_stream.as(Array(Hash(String, JSON::Any)))
|
2018-08-05 09:37:38 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def adaptive_fmts
|
|
|
|
return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts
|
|
|
|
fmt_stream = info["streamingData"]?.try &.["adaptiveFormats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any)
|
|
|
|
fmt_stream.each do |fmt|
|
|
|
|
if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) }
|
|
|
|
s.each do |k, v|
|
|
|
|
fmt[k] = JSON::Any.new(v)
|
2018-09-13 09:01:47 +05:30
|
|
|
end
|
2020-09-27 22:49:44 +05:30
|
|
|
fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}")
|
2018-09-13 09:01:47 +05:30
|
|
|
end
|
2018-08-05 09:37:38 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}")
|
|
|
|
fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]?
|
2018-10-02 05:31:44 +05:30
|
|
|
end
|
2022-04-27 03:50:48 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 }
|
|
|
|
@adaptive_fmts = fmt_stream
|
|
|
|
return @adaptive_fmts.as(Array(Hash(String, JSON::Any)))
|
2018-08-05 09:37:38 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def video_streams
|
|
|
|
adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("video")
|
2018-08-05 09:37:38 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def audio_streams
|
|
|
|
adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("audio")
|
2018-08-18 22:17:16 +05:30
|
|
|
end
|
|
|
|
|
2019-04-12 03:30:00 +05:30
|
|
|
def storyboards
|
2022-01-21 02:52:48 +05:30
|
|
|
storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec")
|
|
|
|
.try &.as_s.split("|")
|
2019-04-12 03:30:00 +05:30
|
|
|
|
|
|
|
if !storyboards
|
2022-01-21 02:52:48 +05:30
|
|
|
if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s
|
2019-04-12 03:30:00 +05:30
|
|
|
return [{
|
2019-04-19 02:53:50 +05:30
|
|
|
url: storyboard.split("#")[0],
|
|
|
|
width: 106,
|
|
|
|
height: 60,
|
|
|
|
count: -1,
|
|
|
|
interval: 5000,
|
|
|
|
storyboard_width: 3,
|
|
|
|
storyboard_height: 3,
|
|
|
|
storyboard_count: -1,
|
|
|
|
}]
|
2019-04-12 03:30:00 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
items = [] of NamedTuple(
|
|
|
|
url: String,
|
|
|
|
width: Int32,
|
|
|
|
height: Int32,
|
|
|
|
count: Int32,
|
|
|
|
interval: Int32,
|
|
|
|
storyboard_width: Int32,
|
|
|
|
storyboard_height: Int32,
|
|
|
|
storyboard_count: Int32)
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
return items if !storyboards
|
2019-04-12 03:30:00 +05:30
|
|
|
|
2019-05-27 05:25:22 +05:30
|
|
|
url = URI.parse(storyboards.shift)
|
|
|
|
params = HTTP::Params.parse(url.query || "")
|
2019-04-12 03:30:00 +05:30
|
|
|
|
2022-01-20 21:47:22 +05:30
|
|
|
storyboards.each_with_index do |sb, i|
|
|
|
|
width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#")
|
2019-05-27 05:25:22 +05:30
|
|
|
params["sigh"] = sigh
|
|
|
|
url.query = params.to_s
|
2019-04-12 03:30:00 +05:30
|
|
|
|
|
|
|
width = width.to_i
|
|
|
|
height = height.to_i
|
|
|
|
count = count.to_i
|
|
|
|
interval = interval.to_i
|
|
|
|
storyboard_width = storyboard_width.to_i
|
|
|
|
storyboard_height = storyboard_height.to_i
|
2019-10-04 19:53:02 +05:30
|
|
|
storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i
|
2019-04-12 03:30:00 +05:30
|
|
|
|
|
|
|
items << {
|
2019-05-27 05:25:22 +05:30
|
|
|
url: url.to_s.sub("$L", i).sub("$N", "M$M"),
|
2019-04-12 03:30:00 +05:30
|
|
|
width: width,
|
|
|
|
height: height,
|
|
|
|
count: count,
|
|
|
|
interval: interval,
|
|
|
|
storyboard_width: storyboard_width,
|
|
|
|
storyboard_height: storyboard_height,
|
2019-10-04 19:53:02 +05:30
|
|
|
storyboard_count: storyboard_count,
|
2019-04-12 03:30:00 +05:30
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
items
|
|
|
|
end
|
|
|
|
|
2021-08-15 14:08:30 +05:30
|
|
|
def paid
|
2022-01-21 02:53:21 +05:30
|
|
|
reason = info.dig?("playabilityStatus", "reason").try &.as_s || ""
|
2022-01-21 02:52:48 +05:30
|
|
|
return reason.includes? "requires payment"
|
2021-08-15 14:08:30 +05:30
|
|
|
end
|
|
|
|
|
2018-10-16 21:45:14 +05:30
|
|
|
def premium
|
2020-06-16 04:03:23 +05:30
|
|
|
keywords.includes? "YouTube Red"
|
|
|
|
end
|
|
|
|
|
|
|
|
def captions : Array(Caption)
|
|
|
|
return @captions.as(Array(Caption)) if @captions
|
|
|
|
captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption|
|
2021-06-27 19:48:16 +05:30
|
|
|
name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"]
|
2021-09-25 07:45:23 +05:30
|
|
|
language_code = caption["languageCode"].to_s
|
|
|
|
base_url = caption["baseUrl"].to_s
|
2021-06-27 19:48:16 +05:30
|
|
|
|
2021-09-25 07:45:23 +05:30
|
|
|
caption = Caption.new(name.to_s, language_code, base_url)
|
2021-06-27 19:48:16 +05:30
|
|
|
caption.name = caption.name.split(" - ")[0]
|
2020-06-16 04:03:23 +05:30
|
|
|
caption
|
2019-08-05 07:26:24 +05:30
|
|
|
end
|
2020-06-16 04:03:23 +05:30
|
|
|
captions ||= [] of Caption
|
|
|
|
@captions = captions
|
|
|
|
return @captions.as(Array(Caption))
|
2018-10-16 21:45:14 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def description
|
2022-01-21 02:53:21 +05:30
|
|
|
description = info
|
2022-01-21 02:52:48 +05:30
|
|
|
.dig?("microformat", "playerMicroformatRenderer", "description", "simpleText")
|
|
|
|
.try &.as_s || ""
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
2018-08-07 04:55:25 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
# TODO
|
|
|
|
def description=(value : String)
|
|
|
|
@description = value
|
|
|
|
end
|
|
|
|
|
|
|
|
def description_html
|
|
|
|
info["descriptionHtml"]?.try &.as_s || "<p></p>"
|
|
|
|
end
|
2018-08-05 09:37:38 +05:30
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def description_html=(value : String)
|
|
|
|
info["descriptionHtml"] = JSON::Any.new(value)
|
2018-08-05 09:37:38 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
def short_description
|
2020-06-18 04:52:28 +05:30
|
|
|
info["shortDescription"]?.try &.as_s? || ""
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
def hls_manifest_url : String?
|
2022-01-21 02:52:48 +05:30
|
|
|
info.dig?("streamingData", "hlsManifestUrl").try &.as_s
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
def dash_manifest_url
|
2022-01-21 02:52:48 +05:30
|
|
|
info.dig?("streamingData", "dashManifestUrl").try &.as_s
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
def genre : String
|
|
|
|
info["genre"]?.try &.as_s || ""
|
|
|
|
end
|
|
|
|
|
2020-06-17 04:21:49 +05:30
|
|
|
def genre_url : String?
|
|
|
|
info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
|
|
|
|
|
|
|
def license : String?
|
|
|
|
info["license"]?.try &.as_s
|
|
|
|
end
|
|
|
|
|
|
|
|
def is_family_friendly : Bool
|
2022-01-21 02:52:48 +05:30
|
|
|
info.dig?("microformat", "playerMicroformatRenderer", "isFamilySafe").try &.as_bool || false
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
2018-08-05 09:37:38 +05:30
|
|
|
|
2021-08-13 00:56:50 +05:30
|
|
|
def is_vr : Bool?
|
2021-09-10 13:12:39 +05:30
|
|
|
projection_type = info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s
|
|
|
|
return {"EQUIRECTANGULAR", "MESH"}.includes? projection_type
|
|
|
|
end
|
|
|
|
|
|
|
|
def projection_type : String?
|
|
|
|
return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s
|
2021-04-11 18:39:10 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
def reason : String?
|
|
|
|
info["reason"]?.try &.as_s
|
|
|
|
end
|
2020-07-26 20:28:50 +05:30
|
|
|
end
|
2018-10-21 07:07:55 +05:30
|
|
|
|
2021-07-12 04:47:22 +05:30
|
|
|
struct Caption
|
2021-06-27 19:48:16 +05:30
|
|
|
property name
|
2021-09-25 07:45:23 +05:30
|
|
|
property language_code
|
|
|
|
property base_url
|
2020-06-16 04:03:23 +05:30
|
|
|
|
2021-06-27 19:48:16 +05:30
|
|
|
getter name : String
|
2021-09-25 07:45:23 +05:30
|
|
|
getter language_code : String
|
|
|
|
getter base_url : String
|
2018-08-05 02:00:44 +05:30
|
|
|
|
2021-06-27 19:48:16 +05:30
|
|
|
setter name
|
2018-08-07 04:55:25 +05:30
|
|
|
|
2021-09-25 07:45:23 +05:30
|
|
|
def initialize(@name, @language_code, @base_url)
|
2021-06-27 19:48:16 +05:30
|
|
|
end
|
2018-08-07 04:55:25 +05:30
|
|
|
end
|
|
|
|
|
2018-10-07 08:52:22 +05:30
|
|
|
class VideoRedirect < Exception
|
2019-09-08 21:38:59 +05:30
|
|
|
property video_id : String
|
|
|
|
|
|
|
|
def initialize(@video_id)
|
|
|
|
end
|
2018-10-07 08:52:22 +05:30
|
|
|
end
|
|
|
|
|
2022-02-03 06:06:42 +05:30
|
|
|
# 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.
|
|
|
|
#
|
|
|
|
# TODO: "compactRadioRenderer" (Mix) and
|
2022-06-09 02:53:34 +05:30
|
|
|
# TODO: Use a proper struct/class instead of a hacky JSON object
|
2022-02-03 06:06:42 +05:30
|
|
|
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
|
|
|
|
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")
|
|
|
|
length = related["lengthInSeconds"]?.try &.as_i.to_s
|
|
|
|
length ||= related.dig?("lengthText", "simpleText").try do |box|
|
|
|
|
decode_length_seconds(box.as_s).to_s
|
|
|
|
end
|
|
|
|
|
|
|
|
# Both have "short", so the "long" option shouldn't be required
|
|
|
|
channel_info = (related["shortBylineText"]? || related["longBylineText"]?)
|
|
|
|
.try &.dig?("runs", 0)
|
|
|
|
|
|
|
|
author = channel_info.try &.dig?("text")
|
2022-06-09 02:53:34 +05:30
|
|
|
author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
|
2022-05-02 00:40:43 +05:30
|
|
|
|
2022-02-03 06:06:42 +05:30
|
|
|
ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) }
|
|
|
|
|
|
|
|
# "4,088,033 views", only available on compact renderer
|
|
|
|
# and when video is not a livestream
|
|
|
|
view_count = related.dig?("viewCountText", "simpleText")
|
|
|
|
.try &.as_s.gsub(/\D/, "")
|
|
|
|
|
|
|
|
short_view_count = related.try do |r|
|
|
|
|
HelperExtractors.get_short_view_count(r).to_s
|
|
|
|
end
|
|
|
|
|
|
|
|
LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container")
|
|
|
|
|
|
|
|
# TODO: when refactoring video types, make a struct for related videos
|
|
|
|
# or reuse an existing type, if that fits.
|
|
|
|
return {
|
|
|
|
"id" => related["videoId"],
|
|
|
|
"title" => related["title"]["simpleText"],
|
|
|
|
"author" => author || JSON::Any.new(""),
|
|
|
|
"ucid" => JSON::Any.new(ucid || ""),
|
|
|
|
"length_seconds" => JSON::Any.new(length || "0"),
|
|
|
|
"view_count" => JSON::Any.new(view_count || "0"),
|
|
|
|
"short_view_count" => JSON::Any.new(short_view_count || "0"),
|
2022-05-02 00:40:43 +05:30
|
|
|
"author_verified" => JSON::Any.new(author_verified),
|
2022-02-03 06:06:42 +05:30
|
|
|
}
|
2018-08-05 02:00:44 +05:30
|
|
|
end
|
|
|
|
|
2021-08-16 23:11:16 +05:30
|
|
|
def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil)
|
2022-07-19 03:05:34 +05:30
|
|
|
# Init client config for the API
|
2021-08-14 01:59:43 +05:30
|
|
|
client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region)
|
2021-08-16 23:11:16 +05:30
|
|
|
if context_screen == "embed"
|
2022-03-30 23:22:39 +05:30
|
|
|
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
|
2021-08-16 23:11:16 +05:30
|
|
|
end
|
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
# Fetch data from the player endpoint
|
2021-08-14 01:59:43 +05:30
|
|
|
player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
|
|
|
|
|
2022-06-24 01:02:02 +05:30
|
|
|
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
|
|
|
|
|
|
|
|
if playability_status != "OK"
|
2022-02-11 11:13:14 +05:30
|
|
|
subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
|
|
|
|
reason = subreason.try &.[]?("simpleText").try &.as_s
|
|
|
|
reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
|
|
|
|
reason ||= player_response.dig("playabilityStatus", "reason").as_s
|
2022-06-24 01:02:02 +05:30
|
|
|
|
|
|
|
# Stop here if video is not a scheduled livestream
|
|
|
|
if playability_status != "LIVE_STREAM_OFFLINE"
|
2022-07-19 03:05:34 +05:30
|
|
|
return {
|
|
|
|
"reason" => JSON::Any.new(reason),
|
|
|
|
}
|
2022-06-24 01:02:02 +05:30
|
|
|
end
|
2022-08-23 15:20:57 +05:30
|
|
|
elsif video_id != player_response.dig("videoDetails", "videoId")
|
|
|
|
# YouTube may return a different video player response than expected.
|
|
|
|
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
|
2022-08-25 14:09:10 +05:30
|
|
|
raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)")
|
2022-07-19 03:05:34 +05:30
|
|
|
else
|
|
|
|
reason = nil
|
2019-09-01 01:28:38 +05:30
|
|
|
end
|
|
|
|
|
2021-08-14 01:59:43 +05:30
|
|
|
# Don't fetch the next endpoint if the video is unavailable.
|
2022-07-14 21:26:53 +05:30
|
|
|
if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status)
|
2021-08-14 01:59:43 +05:30
|
|
|
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
|
|
|
|
player_response = player_response.merge(next_response)
|
|
|
|
end
|
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
params = parse_video_info(video_id, player_response)
|
|
|
|
params["reason"] = JSON::Any.new(reason) if reason
|
|
|
|
|
2021-08-14 01:59:43 +05:30
|
|
|
# Fetch the video streams using an Android client in order to get the decrypted URLs and
|
|
|
|
# maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs:
|
|
|
|
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
|
2022-07-19 03:05:34 +05:30
|
|
|
if reason.nil?
|
2021-08-16 23:11:16 +05:30
|
|
|
if context_screen == "embed"
|
|
|
|
client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
|
|
|
|
else
|
|
|
|
client_config.client_type = YoutubeAPI::ClientType::Android
|
|
|
|
end
|
2021-11-21 22:04:17 +05:30
|
|
|
android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
|
|
|
|
|
2022-08-25 14:09:10 +05:30
|
|
|
# Sometimes, the video is available from the web client, but not on Android, so check
|
2021-11-21 22:04:17 +05:30
|
|
|
# that here, and fallback to the streaming data from the web client if needed.
|
|
|
|
# See: https://github.com/iv-org/invidious/issues/2549
|
2022-08-25 14:09:10 +05:30
|
|
|
if video_id != android_player.dig("videoDetails", "videoId")
|
|
|
|
# YouTube may return a different video player response than expected.
|
|
|
|
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
|
|
|
|
raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (ANDROID client)")
|
|
|
|
elsif android_player["playabilityStatus"]["status"] == "OK"
|
2021-11-21 22:04:17 +05:30
|
|
|
params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("")
|
|
|
|
else
|
|
|
|
params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("")
|
|
|
|
end
|
2021-08-14 01:59:43 +05:30
|
|
|
end
|
2019-04-11 04:32:13 +05:30
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
# TODO: clean that up
|
2020-06-16 04:03:23 +05:30
|
|
|
{"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f|
|
|
|
|
params[f] = player_response[f] if player_response[f]?
|
2019-04-11 04:32:13 +05:30
|
|
|
end
|
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
return params
|
|
|
|
end
|
|
|
|
|
|
|
|
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
|
2021-11-24 05:52:09 +05:30
|
|
|
# Top level elements
|
|
|
|
|
2022-02-03 04:40:32 +05:30
|
|
|
main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
|
|
|
|
|
|
|
|
raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results
|
|
|
|
|
|
|
|
primary_results = main_results.dig?("results", "results", "contents")
|
|
|
|
|
|
|
|
raise BrokenTubeException.new("results") if !primary_results
|
2021-11-24 05:52:09 +05:30
|
|
|
|
|
|
|
video_primary_renderer = primary_results
|
2022-02-03 04:40:32 +05:30
|
|
|
.as_a.find(&.["videoPrimaryInfoRenderer"]?)
|
|
|
|
.try &.["videoPrimaryInfoRenderer"]
|
2021-11-24 05:52:09 +05:30
|
|
|
|
|
|
|
video_secondary_renderer = primary_results
|
2022-02-03 04:40:32 +05:30
|
|
|
.as_a.find(&.["videoSecondaryInfoRenderer"]?)
|
|
|
|
.try &.["videoSecondaryInfoRenderer"]
|
|
|
|
|
|
|
|
raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer
|
|
|
|
raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer
|
2021-11-24 05:52:09 +05:30
|
|
|
|
2022-02-03 06:06:42 +05:30
|
|
|
# Related videos
|
|
|
|
|
|
|
|
LOGGER.debug("extract_video_info: parsing related videos...")
|
|
|
|
|
|
|
|
related = [] of JSON::Any
|
|
|
|
|
|
|
|
# Parse "compactVideoRenderer" items (under secondary results)
|
2022-02-11 11:13:14 +05:30
|
|
|
secondary_results = main_results
|
|
|
|
.dig?("secondaryResults", "secondaryResults", "results")
|
|
|
|
secondary_results.try &.as_a.each do |element|
|
2022-02-03 06:06:42 +05:30
|
|
|
if item = element["compactVideoRenderer"]?
|
|
|
|
related_video = parse_related_video(item)
|
|
|
|
related << JSON::Any.new(related_video) if related_video
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# If nothing was found previously, fall back to end screen renderer
|
|
|
|
if related.empty?
|
|
|
|
# Container for "endScreenVideoRenderer" items
|
|
|
|
player_overlays = player_response.dig?(
|
|
|
|
"playerOverlays", "playerOverlayRenderer",
|
|
|
|
"endScreen", "watchNextEndScreenRenderer", "results"
|
|
|
|
)
|
|
|
|
|
2022-02-03 08:25:43 +05:30
|
|
|
player_overlays.try &.as_a.each do |element|
|
2022-02-03 06:06:42 +05:30
|
|
|
if item = element["endScreenVideoRenderer"]?
|
|
|
|
related_video = parse_related_video(item)
|
|
|
|
related << JSON::Any.new(related_video) if related_video
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2022-06-21 04:31:25 +05:30
|
|
|
# Likes
|
2021-11-24 05:52:09 +05:30
|
|
|
|
|
|
|
toplevel_buttons = video_primary_renderer
|
|
|
|
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
|
|
|
|
|
|
|
|
if toplevel_buttons
|
|
|
|
likes_button = toplevel_buttons.as_a
|
2022-08-15 14:04:52 +05:30
|
|
|
.find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
|
2021-11-24 05:52:09 +05:30
|
|
|
.try &.["toggleButtonRenderer"]
|
|
|
|
|
|
|
|
if likes_button
|
|
|
|
likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?)
|
|
|
|
.try &.dig?("accessibility", "accessibilityData", "label")
|
2021-11-26 03:46:50 +05:30
|
|
|
likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
|
2021-11-24 05:52:09 +05:30
|
|
|
|
|
|
|
LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
|
|
|
|
LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# Description
|
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
short_description = player_response.dig?("videoDetails", "shortDescription")
|
|
|
|
|
2021-11-24 05:52:09 +05:30
|
|
|
description_html = video_secondary_renderer.try &.dig?("description", "runs")
|
2022-05-01 22:18:08 +05:30
|
|
|
.try &.as_a.try { |t| content_to_comment_html(t, video_id) }
|
2021-11-24 05:52:09 +05:30
|
|
|
|
|
|
|
# Video metadata
|
|
|
|
|
|
|
|
metadata = video_secondary_renderer
|
|
|
|
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
|
|
|
|
.try &.as_a
|
2019-04-11 04:32:13 +05:30
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
genre = player_response.dig?("microformat", "playerMicroformatRenderer", "category")
|
|
|
|
genre_ucid = nil
|
|
|
|
license = nil
|
2020-06-17 04:21:49 +05:30
|
|
|
|
2019-04-11 04:32:13 +05:30
|
|
|
metadata.try &.each do |row|
|
2022-07-19 03:05:34 +05:30
|
|
|
metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s
|
2021-11-24 05:52:09 +05:30
|
|
|
contents = row.dig?("metadataRowRenderer", "contents", 0)
|
2019-04-11 04:32:13 +05:30
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
if metadata_title == "Category"
|
2021-11-29 04:14:37 +05:30
|
|
|
contents = contents.try &.dig?("runs", 0)
|
2019-04-11 04:32:13 +05:30
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
genre = contents.try &.["text"]?
|
|
|
|
genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
|
|
|
|
elsif metadata_title == "License"
|
|
|
|
license = contents.try &.dig?("runs", 0, "text")
|
|
|
|
elsif metadata_title == "Licensed to YouTube by"
|
|
|
|
license = contents.try &.["simpleText"]?
|
2019-04-11 04:32:13 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2021-11-24 05:52:09 +05:30
|
|
|
# Author infos
|
|
|
|
|
2022-06-02 02:37:18 +05:30
|
|
|
if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
|
|
|
|
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
|
|
|
|
author_verified = has_verified_badge?(author_info["badges"]?)
|
2022-05-02 00:40:43 +05:30
|
|
|
|
2022-06-02 02:37:18 +05:30
|
|
|
subs_text = author_info["subscriberCountText"]?
|
|
|
|
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
|
|
|
|
.try &.as_s.split(" ", 2)[0]
|
|
|
|
end
|
2021-11-24 05:52:09 +05:30
|
|
|
|
|
|
|
# Return data
|
2019-04-11 04:32:13 +05:30
|
|
|
|
2022-07-19 03:05:34 +05:30
|
|
|
params = {
|
|
|
|
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
|
|
|
|
"relatedVideos" => JSON::Any.new(related),
|
|
|
|
"likes" => JSON::Any.new(likes || 0_i64),
|
|
|
|
"dislikes" => JSON::Any.new(0_i64),
|
|
|
|
"descriptionHtml" => JSON::Any.new(description_html || "<p></p>"),
|
|
|
|
"genre" => JSON::Any.new(genre.try &.as_s || ""),
|
|
|
|
"genreUrl" => JSON::Any.new(nil),
|
|
|
|
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""),
|
|
|
|
"license" => JSON::Any.new(license.try &.as_s || ""),
|
|
|
|
"authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
|
|
|
|
"authorVerified" => JSON::Any.new(author_verified),
|
|
|
|
"subCountText" => JSON::Any.new(subs_text || "-"),
|
|
|
|
}
|
|
|
|
|
2021-11-24 05:52:09 +05:30
|
|
|
return params
|
2020-06-16 04:03:23 +05:30
|
|
|
end
|
2018-08-05 02:00:44 +05:30
|
|
|
|
2021-12-07 07:25:43 +05:30
|
|
|
def get_video(id, refresh = true, region = nil, force_refresh = false)
|
2021-11-27 00:06:31 +05:30
|
|
|
if (video = Invidious::Database::Videos.select(id)) && !region
|
2020-06-16 04:03:23 +05:30
|
|
|
# If record was last updated over 10 minutes ago, or video has since premiered,
|
|
|
|
# refresh (expire param in response lasts for 6 hours)
|
|
|
|
if (refresh &&
|
|
|
|
(Time.utc - video.updated > 10.minutes) ||
|
|
|
|
(video.premiere_timestamp.try &.< Time.utc)) ||
|
|
|
|
force_refresh
|
|
|
|
begin
|
|
|
|
video = fetch_video(id, region)
|
2021-11-27 00:06:31 +05:30
|
|
|
Invidious::Database::Videos.update(video)
|
2020-06-16 04:03:23 +05:30
|
|
|
rescue ex
|
2021-11-27 00:06:31 +05:30
|
|
|
Invidious::Database::Videos.delete(id)
|
2020-06-16 04:03:23 +05:30
|
|
|
raise ex
|
|
|
|
end
|
2019-02-07 03:42:11 +05:30
|
|
|
end
|
|
|
|
else
|
2020-06-16 04:03:23 +05:30
|
|
|
video = fetch_video(id, region)
|
2021-11-27 00:06:31 +05:30
|
|
|
Invidious::Database::Videos.insert(video) if !region
|
2018-08-05 02:00:44 +05:30
|
|
|
end
|
|
|
|
|
2020-06-16 04:03:23 +05:30
|
|
|
return video
|
2022-04-09 02:22:34 +05:30
|
|
|
rescue DB::Error
|
|
|
|
# Avoid common `DB::PoolRetryAttemptsExceeded` error and friends
|
|
|
|
# Note: All DB errors inherit from `DB::Error`
|
|
|
|
return fetch_video(id, region)
|
2019-02-07 03:42:11 +05:30
|
|
|
end
|
|
|
|
|
2019-06-29 07:47:56 +05:30
|
|
|
def fetch_video(id, region)
|
2021-08-14 01:59:43 +05:30
|
|
|
info = extract_video_info(video_id: id)
|
2018-10-07 08:52:22 +05:30
|
|
|
|
2021-08-14 01:59:43 +05:30
|
|
|
allowed_regions = info
|
|
|
|
.dig?("microformat", "playerMicroformatRenderer", "availableCountries")
|
|
|
|
.try &.as_a.map &.as_s || [] of String
|
2019-08-14 01:51:00 +05:30
|
|
|
|
|
|
|
# Check for region-blocks
|
2020-06-16 04:03:23 +05:30
|
|
|
if info["reason"]?.try &.as_s.includes?("your country")
|
2019-08-19 19:30:37 +05:30
|
|
|
bypass_regions = PROXY_LIST.keys & allowed_regions
|
|
|
|
if !bypass_regions.empty?
|
|
|
|
region = bypass_regions[rand(bypass_regions.size)]
|
2021-08-14 01:59:43 +05:30
|
|
|
region_info = extract_video_info(video_id: id, proxy_region: region)
|
2020-06-16 04:03:23 +05:30
|
|
|
region_info["region"] = JSON::Any.new(region) if region
|
|
|
|
info = region_info if !region_info["reason"]?
|
2019-08-19 19:30:37 +05:30
|
|
|
end
|
2018-08-13 19:47:28 +05:30
|
|
|
end
|
|
|
|
|
2021-08-16 23:11:16 +05:30
|
|
|
# Try to fetch video info using an embedded client
|
2018-08-05 02:00:44 +05:30
|
|
|
if info["reason"]?
|
2021-08-16 23:11:16 +05:30
|
|
|
embed_info = extract_video_info(video_id: id, context_screen: "embed")
|
|
|
|
info = embed_info if !embed_info["reason"]?
|
2018-09-23 22:43:08 +05:30
|
|
|
end
|
2018-09-10 01:17:26 +05:30
|
|
|
|
2022-02-11 11:13:14 +05:30
|
|
|
if reason = info["reason"]?
|
2022-05-27 19:06:13 +05:30
|
|
|
if reason == "Video unavailable"
|
|
|
|
raise NotFoundException.new(reason.as_s || "")
|
|
|
|
else
|
|
|
|
raise InfoException.new(reason.as_s || "")
|
|
|
|
end
|
2022-02-11 11:13:14 +05:30
|
|
|
end
|
2018-08-05 02:00:44 +05:30
|
|
|
|
2020-07-26 20:28:50 +05:30
|
|
|
video = Video.new({
|
|
|
|
id: id,
|
|
|
|
info: info,
|
|
|
|
updated: Time.utc,
|
|
|
|
})
|
|
|
|
|
2018-08-05 02:00:44 +05:30
|
|
|
return video
|
|
|
|
end
|
|
|
|
|
2021-12-07 07:25:43 +05:30
|
|
|
def process_continuation(query, plid, id)
|
2019-08-06 05:19:13 +05:30
|
|
|
continuation = nil
|
|
|
|
if plid
|
|
|
|
if index = query["index"]?.try &.to_i?
|
|
|
|
continuation = index
|
|
|
|
else
|
|
|
|
continuation = id
|
|
|
|
end
|
|
|
|
continuation ||= 0
|
|
|
|
end
|
|
|
|
|
|
|
|
continuation
|
|
|
|
end
|
|
|
|
|
2020-06-16 03:40:30 +05:30
|
|
|
def build_thumbnails(id)
|
2019-03-09 02:12:37 +05:30
|
|
|
return {
|
2021-04-01 05:53:59 +05:30
|
|
|
{host: HOST_URL, height: 720, width: 1280, name: "maxres", url: "maxres"},
|
|
|
|
{host: HOST_URL, height: 720, width: 1280, name: "maxresdefault", url: "maxresdefault"},
|
|
|
|
{host: HOST_URL, height: 480, width: 640, name: "sddefault", url: "sddefault"},
|
|
|
|
{host: HOST_URL, height: 360, width: 480, name: "high", url: "hqdefault"},
|
|
|
|
{host: HOST_URL, height: 180, width: 320, name: "medium", url: "mqdefault"},
|
|
|
|
{host: HOST_URL, height: 90, width: 120, name: "default", url: "default"},
|
|
|
|
{host: HOST_URL, height: 90, width: 120, name: "start", url: "1"},
|
|
|
|
{host: HOST_URL, height: 90, width: 120, name: "middle", url: "2"},
|
|
|
|
{host: HOST_URL, height: 90, width: 120, name: "end", url: "3"},
|
2019-03-09 02:12:37 +05:30
|
|
|
}
|
|
|
|
end
|
|
|
|
|
2020-06-16 03:40:30 +05:30
|
|
|
def generate_thumbnails(json, id)
|
2018-08-10 19:20:25 +05:30
|
|
|
json.array do
|
2020-06-16 03:40:30 +05:30
|
|
|
build_thumbnails(id).each do |thumbnail|
|
2018-08-10 19:20:25 +05:30
|
|
|
json.object do
|
2018-08-12 20:16:47 +05:30
|
|
|
json.field "quality", thumbnail[:name]
|
2019-03-09 02:12:37 +05:30
|
|
|
json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
|
2018-08-12 20:16:47 +05:30
|
|
|
json.field "width", thumbnail[:width]
|
|
|
|
json.field "height", thumbnail[:height]
|
2018-08-10 19:20:25 +05:30
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2019-04-12 03:30:00 +05:30
|
|
|
|
2020-06-16 03:40:30 +05:30
|
|
|
def generate_storyboards(json, id, storyboards)
|
2019-04-12 03:30:00 +05:30
|
|
|
json.array do
|
|
|
|
storyboards.each do |storyboard|
|
|
|
|
json.object do
|
2019-05-03 00:50:19 +05:30
|
|
|
json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
|
|
|
|
json.field "templateUrl", storyboard[:url]
|
2019-04-12 03:30:00 +05:30
|
|
|
json.field "width", storyboard[:width]
|
|
|
|
json.field "height", storyboard[:height]
|
|
|
|
json.field "count", storyboard[:count]
|
|
|
|
json.field "interval", storyboard[:interval]
|
|
|
|
json.field "storyboardWidth", storyboard[:storyboard_width]
|
|
|
|
json.field "storyboardHeight", storyboard[:storyboard_height]
|
|
|
|
json.field "storyboardCount", storyboard[:storyboard_count]
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|