invidious-mod/src/invidious/videos.cr

1360 行
50 KiB
Crystal
Raw 通常表示 履歴

2018-08-13 10:04:13 +09:00
CAPTION_LANGUAGES = {
2018-08-07 03:23:36 +09:00
"",
"English",
"English (auto-generated)",
2022-02-08 10:13:14 +09:00
"English (United Kingdom)",
"English (United States)",
2018-08-07 03:23:36 +09:00
"Afrikaans",
"Albanian",
"Amharic",
"Arabic",
"Armenian",
"Azerbaijani",
"Bangla",
"Basque",
"Belarusian",
"Bosnian",
"Bulgarian",
"Burmese",
2022-02-08 10:13:14 +09:00
"Cantonese (Hong Kong)",
2018-08-07 03:23:36 +09:00
"Catalan",
"Cebuano",
2022-02-08 10:13:14 +09:00
"Chinese",
"Chinese (China)",
"Chinese (Hong Kong)",
2018-08-07 03:23:36 +09:00
"Chinese (Simplified)",
2022-02-08 10:13:14 +09:00
"Chinese (Taiwan)",
2018-08-07 03:23:36 +09:00
"Chinese (Traditional)",
"Corsican",
"Croatian",
"Czech",
"Danish",
"Dutch",
2022-02-08 10:13:14 +09:00
"Dutch (auto-generated)",
2018-08-07 03:23:36 +09:00
"Esperanto",
"Estonian",
"Filipino",
"Finnish",
"French",
2022-02-08 10:13:14 +09:00
"French (auto-generated)",
2018-08-07 03:23:36 +09:00
"Galician",
"Georgian",
"German",
2022-02-08 10:13:14 +09:00
"German (auto-generated)",
2018-08-07 03:23:36 +09:00
"Greek",
"Gujarati",
"Haitian Creole",
"Hausa",
"Hawaiian",
"Hebrew",
"Hindi",
"Hmong",
"Hungarian",
"Icelandic",
"Igbo",
"Indonesian",
2022-02-08 10:13:14 +09:00
"Indonesian (auto-generated)",
"Interlingue",
2018-08-07 03:23:36 +09:00
"Irish",
"Italian",
2022-02-08 10:13:14 +09:00
"Italian (auto-generated)",
2018-08-07 03:23:36 +09:00
"Japanese",
2022-02-08 10:13:14 +09:00
"Japanese (auto-generated)",
2018-08-07 03:23:36 +09:00
"Javanese",
"Kannada",
"Kazakh",
"Khmer",
"Korean",
2022-02-08 10:13:14 +09:00
"Korean (auto-generated)",
2018-08-07 03:23:36 +09:00
"Kurdish",
"Kyrgyz",
"Lao",
"Latin",
"Latvian",
"Lithuanian",
"Luxembourgish",
"Macedonian",
"Malagasy",
"Malay",
"Malayalam",
"Maltese",
"Maori",
"Marathi",
"Mongolian",
"Nepali",
2019-04-20 01:14:11 +09:00
"Norwegian Bokmål",
2018-08-07 03:23:36 +09:00
"Nyanja",
"Pashto",
"Persian",
"Polish",
"Portuguese",
2022-02-08 10:13:14 +09:00
"Portuguese (auto-generated)",
"Portuguese (Brazil)",
2018-08-07 03:23:36 +09:00
"Punjabi",
"Romanian",
"Russian",
2022-02-08 10:13:14 +09:00
"Russian (auto-generated)",
2018-08-07 03:23:36 +09:00
"Samoan",
"Scottish Gaelic",
"Serbian",
"Shona",
"Sindhi",
"Sinhala",
"Slovak",
"Slovenian",
"Somali",
"Southern Sotho",
"Spanish",
2022-02-08 10:13:14 +09:00
"Spanish (auto-generated)",
2018-08-07 08:25:25 +09:00
"Spanish (Latin America)",
2022-02-08 10:13:14 +09:00
"Spanish (Mexico)",
"Spanish (Spain)",
2018-08-07 03:23:36 +09:00
"Sundanese",
"Swahili",
"Swedish",
"Tajik",
"Tamil",
"Telugu",
"Thai",
"Turkish",
2022-02-08 10:13:14 +09:00
"Turkish (auto-generated)",
2018-08-07 03:23:36 +09:00
"Ukrainian",
"Urdu",
"Uzbek",
"Vietnamese",
2022-02-08 10:13:14 +09:00
"Vietnamese (auto-generated)",
2018-08-07 03:23:36 +09:00
"Welsh",
"Western Frisian",
"Xhosa",
"Yiddish",
"Yoruba",
"Zulu",
2018-08-13 10:04:13 +09:00
}
2018-08-07 03:23:36 +09:00
REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"}
2018-08-13 23:17:28 +09:00
2018-08-12 23:34:26 +09:00
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476
VIDEO_FORMATS = {
"5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"},
"6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"},
"13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"},
"17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"},
"18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"},
"22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"},
"37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"},
"44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"},
"45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"},
"46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"},
"59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
# 3D videos
"82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"},
"100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"},
"101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"},
"102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"},
# Apple HTTP Live Streaming
"91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"},
"92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"},
"93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"},
"95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"},
"96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"},
"132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"},
"151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"},
# DASH mp4 video
"133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"},
"134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"},
"135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
"136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"},
"137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"},
2019-07-06 03:38:46 +09:00
"138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559)
2018-08-12 23:34:26 +09:00
"160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"},
"212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"},
"264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"},
"298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60},
"299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60},
"266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"},
# Dash mp4 audio
"139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"},
"140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"},
"141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"},
"256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"},
"258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"},
"325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"},
"328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"},
# Dash webm
"167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"},
"278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"},
"242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"},
"243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"},
"244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"},
"245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"},
"246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"},
"247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"},
"248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"},
"271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"},
# itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug)
"272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"},
"302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"},
"315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
"337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60},
# Dash webm audio
"171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128},
"172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256},
# Dash webm audio with opus inside
"249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50},
"250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70},
"251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160},
2019-07-06 03:38:46 +09:00
# av01 video only formats sometimes served with "unknown" codecs
"394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"},
"395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"},
"396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"},
"397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"},
2018-08-12 23:34:26 +09:00
}
2019-05-01 13:39:04 +09:00
struct VideoPreferences
include JSON::Serializable
property annotations : Bool
property autoplay : Bool
property comments : Array(String)
property continue : Bool
property continue_autoplay : Bool
property controls : Bool
property listen : Bool
property local : Bool
property preferred_captions : Array(String)
property player_style : String
property quality : String
property quality_dash : String
property raw : Bool
property region : String?
property related_videos : Bool
property speed : Float32 | Float64
property video_end : Float64 | Int32
property video_loop : Bool
property extend_desc : Bool
property video_start : Float64 | Int32
property volume : Int32
property vr_mode : Bool
property save_player_pos : Bool
2019-05-01 13:39:04 +09:00
end
2019-03-30 06:30:02 +09:00
struct Video
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 07:33:23 +09:00
module JSONConverter
2018-08-05 05:30:44 +09:00
def self.from_rs(rs)
2020-06-16 07:33:23 +09:00
JSON.parse(rs.read(String)).as_h
2018-08-05 05:30:44 +09:00
end
end
def to_json(locale : String?, json : JSON::Builder)
2019-06-09 03:31:41 +09:00
json.object do
json.field "type", "video"
json.field "title", self.title
json.field "videoId", self.id
2020-06-16 07:10:30 +09:00
json.field "error", info["reason"] if info["reason"]?
2019-06-09 03:31:41 +09:00
json.field "videoThumbnails" do
2020-06-16 07:10:30 +09:00
generate_thumbnails(json, self.id)
2019-06-09 03:31:41 +09:00
end
json.field "storyboards" do
2020-06-16 07:10:30 +09:00
generate_storyboards(json, self.id, self.storyboards)
2019-06-09 03:31:41 +09:00
end
2019-04-11 07:58:42 +09:00
2020-06-16 07:33:23 +09:00
json.field "description", self.description
2019-06-09 05:08:27 +09:00
json.field "descriptionHtml", self.description_html
2019-06-09 03:31:41 +09:00
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
json.field "dislikeCount", 0_i64
2019-06-09 03:31:41 +09:00
json.field "paid", self.paid
2019-06-09 03:31:41 +09:00
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 09:16:09 +09:00
json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
2019-06-09 03:31:41 +09:00
json.field "width", quality
json.field "height", quality
2019-04-11 07:58:42 +09:00
end
end
end
2019-06-09 03:31:41 +09:00
end
2019-04-11 07:58:42 +09:00
2019-06-09 03:31:41 +09:00
json.field "subCountText", self.sub_count_text
2019-04-11 07:58:42 +09:00
2019-07-30 09:41:45 +09:00
json.field "lengthSeconds", self.length_seconds
2019-06-09 03:31:41 +09:00
json.field "allowRatings", self.allow_ratings
json.field "rating", 0_i64
2019-06-09 03:31:41 +09:00
json.field "isListed", self.is_listed
json.field "liveNow", self.live_now
json.field "isUpcoming", self.is_upcoming
2019-04-11 07:58:42 +09:00
2019-06-09 03:31:41 +09:00
if self.premiere_timestamp
2020-06-16 07:33:23 +09:00
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
2019-06-09 03:31:41 +09:00
end
2019-04-11 07:58:42 +09:00
2020-06-16 07:10:30 +09:00
if hlsvp = self.hls_manifest_url
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL)
2019-06-09 03:31:41 +09:00
json.field "hlsUrl", hlsvp
end
2019-04-11 07:58:42 +09:00
2020-06-16 07:10:30 +09:00
json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{id}"
2019-06-09 03:31:41 +09:00
json.field "adaptiveFormats" do
json.array do
2020-06-16 07:33:23 +09:00
self.adaptive_fmts.each do |fmt|
2019-06-09 03:31:41 +09:00
json.object do
2022-04-28 04:44:31 +09:00
# Only available on regular videos, not livestreams/OTF streams
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 03:31:41 +09:00
json.field "url", fmt["url"]
2020-06-16 07:33:23 +09:00
json.field "itag", fmt["itag"].as_i.to_s
json.field "type", fmt["mimeType"]
json.field "clen", fmt["contentLength"]? || "-1"
2020-06-16 07:33:23 +09:00
json.field "lmt", fmt["lastModified"]
json.field "projectionType", fmt["projectionType"]
2019-06-09 03:31:41 +09:00
if fmt_info = itag_to_metadata?(fmt["itag"])
2020-06-16 07:33:23 +09:00
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
2019-06-09 03:31:41 +09:00
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 07:58:42 +09:00
2019-06-09 03:31:41 +09:00
if fmt_info["width"]?
json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}"
2019-04-11 07:58:42 +09:00
end
end
end
# 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")
# 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 07:58:42 +09:00
end
end
end
2019-06-09 03:31:41 +09:00
end
2019-04-11 07:58:42 +09:00
2019-06-09 03:31:41 +09:00
json.field "formatStreams" do
json.array do
2020-06-16 07:33:23 +09:00
self.fmt_stream.each do |fmt|
2019-06-09 03:31:41 +09:00
json.object do
json.field "url", fmt["url"]
2020-06-16 07:33:23 +09:00
json.field "itag", fmt["itag"].as_i.to_s
json.field "type", fmt["mimeType"]
2019-06-09 03:31:41 +09:00
json.field "quality", fmt["quality"]
fmt_info = itag_to_metadata?(fmt["itag"])
if fmt_info
2020-06-16 07:33:23 +09:00
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30
2019-06-09 03:31:41 +09:00
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 07:58:42 +09:00
end
end
end
end
end
end
2019-06-09 03:31:41 +09:00
end
2019-04-11 07:58:42 +09:00
2019-06-09 03:31:41 +09:00
json.field "captions" do
json.array do
self.captions.each do |caption|
json.object do
json.field "label", caption.name
2021-09-25 11:15:23 +09:00
json.field "language_code", caption.language_code
json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}"
2019-04-11 07:58:42 +09:00
end
end
end
2019-06-09 03:31:41 +09:00
end
2019-04-11 07:58:42 +09:00
2019-06-09 03:31:41 +09:00
json.field "recommendedVideos" do
json.array do
2020-06-16 07:33:23 +09:00
self.related_videos.each do |rv|
2019-06-09 03:31:41 +09:00
if rv["id"]?
json.object do
json.field "videoId", rv["id"]
json.field "title", rv["title"]
json.field "videoThumbnails" do
2020-06-16 07:10:30 +09:00
generate_thumbnails(json, rv["id"])
2019-04-11 07:58:42 +09:00
end
2019-08-27 22:00:04 +09:00
2019-06-09 03:31:41 +09:00
json.field "author", rv["author"]
json.field "authorUrl", "/channel/#{rv["ucid"]?}"
2019-08-31 12:57:33 +09:00
json.field "authorId", rv["ucid"]?
2019-08-27 22:00:04 +09:00
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
json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-")
2019-08-27 22:00:04 +09:00
json.field "width", quality
json.field "height", quality
end
end
end
end
end
2020-06-16 07:33:23 +09:00
json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i
json.field "viewCountText", rv["short_view_count"]?
2020-06-16 07:33:23 +09:00
json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64
2019-04-11 07:58:42 +09:00
end
end
end
end
end
end
end
2021-10-29 21:53:06 +09:00
# TODO: remove the locale and follow the crystal convention
def to_json(locale : String?, _json : Nil)
2021-10-29 21:53:06 +09:00
JSON.build { |json| to_json(locale, json) }
end
def to_json(json : JSON::Builder | Nil = nil)
to_json(nil, json)
2019-06-09 03:31:41 +09:00
end
2020-06-16 07:33:23 +09:00
def title
info["videoDetails"]["title"]?.try &.as_s || ""
2019-06-09 05:08:27 +09:00
end
2020-06-16 07:33:23 +09:00
def ucid
info["videoDetails"]["channelId"]?.try &.as_s || ""
2019-06-09 05:08:27 +09:00
end
2020-06-16 07:33:23 +09:00
def author
info["videoDetails"]["author"]?.try &.as_s || ""
end
2019-03-23 00:32:42 +09:00
2020-06-16 07:33:23 +09:00
def length_seconds : Int32
info.dig?("microformat", "playerMicroformatRenderer", "lengthSeconds").try &.as_s.to_i ||
2020-06-16 07:33:23 +09:00
info["videoDetails"]["lengthSeconds"]?.try &.as_s.to_i || 0
2019-03-23 00:32:42 +09:00
end
2020-06-16 07:33:23 +09:00
def views : Int64
info["videoDetails"]["viewCount"]?.try &.as_s.to_i64 || 0_i64
end
2019-03-23 00:32:42 +09:00
2020-06-16 07:33:23 +09:00
def likes : Int64
info["likes"]?.try &.as_i64 || 0_i64
end
2019-03-23 00:32:42 +09:00
2020-06-16 07:33:23 +09:00
def dislikes : Int64
info["dislikes"]?.try &.as_i64 || 0_i64
2019-03-23 00:32:42 +09:00
end
2020-06-16 07:33:23 +09:00
def published : Time
info
.dig?("microformat", "playerMicroformatRenderer", "publishDate")
.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
2020-06-16 07:33:23 +09:00
end
2019-03-23 00:32:42 +09:00
2020-06-16 07:33:23 +09:00
def published=(other : Time)
info["microformat"].as_h["playerMicroformatRenderer"].as_h["publishDate"] = JSON::Any.new(other.to_s("%Y-%m-%d"))
2019-03-23 00:32:42 +09:00
end
2020-06-16 07:33:23 +09:00
def allow_ratings
r = info["videoDetails"]["allowRatings"]?.try &.as_bool
r.nil? ? false : r
end
2020-06-16 07:33:23 +09:00
def live_now
2021-02-25 13:06:50 +09:00
info["microformat"]?.try &.["playerMicroformatRenderer"]?
.try &.["liveBroadcastDetails"]?.try &.["isLiveNow"]?.try &.as_bool || false
end
2020-06-16 07:33:23 +09:00
def is_listed
info["videoDetails"]["isCrawlable"]?.try &.as_bool || false
end
2020-06-16 07:33:23 +09:00
def is_upcoming
info["videoDetails"]["isUpcoming"]?.try &.as_bool || false
end
2020-06-16 07:33:23 +09:00
def premiere_timestamp : Time?
info
.dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) }
end
2018-11-02 22:09:28 +09:00
def keywords
2020-06-16 07:33:23 +09:00
info["videoDetails"]["keywords"]?.try &.as_a.map &.as_s || [] of String
2018-11-02 22:09:28 +09:00
end
2020-06-16 07:33:23 +09:00
def related_videos
info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String)
end
2020-06-16 07:33:23 +09:00
def allowed_regions
info
.dig?("microformat", "playerMicroformatRenderer", "availableCountries")
.try &.as_a.map &.as_s || [] of String
2020-06-16 07:33:23 +09:00
end
2020-06-16 07:33:23 +09:00
def author_thumbnail : String
info["authorThumbnail"]?.try &.as_s || ""
end
2022-05-02 04:10:43 +09:00
def author_verified : Bool
2022-05-02 04:11:12 +09:00
info["authorVerified"]?.try &.as_bool || false
2022-05-02 04:10:43 +09:00
end
2020-06-16 07:33:23 +09:00
def sub_count_text : String
info["subCountText"]?.try &.as_s || "-"
end
2020-06-16 07:33:23 +09:00
def fmt_stream
return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream
2020-06-16 07:33:23 +09:00
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)
end
fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}")
2018-08-05 13:07:38 +09:00
end
2020-06-16 07:33:23 +09:00
fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}")
fmt["url"] = JSON::Any.new("#{fmt["url"]}&region=#{self.info["region"]}") if self.info["region"]?
2018-08-05 13:07:38 +09:00
end
2020-06-16 07:33:23 +09:00
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 13:07:38 +09:00
end
2020-06-16 07:33:23 +09:00
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)
end
fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}")
end
2018-08-05 13:07:38 +09:00
2020-06-16 07:33:23 +09:00
fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}")
fmt["url"] = JSON::Any.new("#{fmt["url"]}&region=#{self.info["region"]}") if self.info["region"]?
2018-10-02 09:01:44 +09:00
end
2020-06-16 07:33:23 +09:00
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 13:07:38 +09:00
end
2020-06-16 07:33:23 +09:00
def video_streams
adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("video")
2018-08-05 13:07:38 +09:00
end
2020-06-16 07:33:23 +09:00
def audio_streams
adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("audio")
end
2019-04-12 07:00:00 +09:00
def storyboards
storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec")
.try &.as_s.split("|")
2019-04-12 07:00:00 +09:00
if !storyboards
if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s
2019-04-12 07:00:00 +09:00
return [{
2019-04-19 06:23:50 +09:00
url: storyboard.split("#")[0],
width: 106,
height: 60,
count: -1,
interval: 5000,
storyboard_width: 3,
storyboard_height: 3,
storyboard_count: -1,
}]
2019-04-12 07:00:00 +09:00
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 07:33:23 +09:00
return items if !storyboards
2019-04-12 07:00:00 +09:00
2019-05-27 08:55:22 +09:00
url = URI.parse(storyboards.shift)
params = HTTP::Params.parse(url.query || "")
2019-04-12 07:00:00 +09:00
storyboards.each_with_index do |sb, i|
width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#")
2019-05-27 08:55:22 +09:00
params["sigh"] = sigh
url.query = params.to_s
2019-04-12 07:00:00 +09:00
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 23:23:02 +09:00
storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i
2019-04-12 07:00:00 +09:00
items << {
2019-05-27 08:55:22 +09:00
url: url.to_s.sub("$L", i).sub("$N", "M$M"),
2019-04-12 07:00:00 +09:00
width: width,
height: height,
count: count,
interval: interval,
storyboard_width: storyboard_width,
storyboard_height: storyboard_height,
2019-10-04 23:23:02 +09:00
storyboard_count: storyboard_count,
2019-04-12 07:00:00 +09:00
}
end
items
end
def paid
reason = info.dig?("playabilityStatus", "reason").try &.as_s || ""
return reason.includes? "requires payment"
end
2018-10-17 01:15:14 +09:00
def premium
2020-06-16 07:33:23 +09:00
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|
name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"]
2021-09-25 11:15:23 +09:00
language_code = caption["languageCode"].to_s
base_url = caption["baseUrl"].to_s
2021-09-25 11:15:23 +09:00
caption = Caption.new(name.to_s, language_code, base_url)
caption.name = caption.name.split(" - ")[0]
2020-06-16 07:33:23 +09:00
caption
2019-08-05 10:56:24 +09:00
end
2020-06-16 07:33:23 +09:00
captions ||= [] of Caption
@captions = captions
return @captions.as(Array(Caption))
2018-10-17 01:15:14 +09:00
end
2020-06-16 07:33:23 +09:00
def description
description = info
.dig?("microformat", "playerMicroformatRenderer", "description", "simpleText")
.try &.as_s || ""
2020-06-16 07:33:23 +09:00
end
2018-08-07 08:25:25 +09:00
2020-06-16 07:33:23 +09:00
# TODO
def description=(value : String)
@description = value
end
def description_html
info["descriptionHtml"]?.try &.as_s || "<p></p>"
end
2018-08-05 13:07:38 +09:00
2020-06-16 07:33:23 +09:00
def description_html=(value : String)
info["descriptionHtml"] = JSON::Any.new(value)
2018-08-05 13:07:38 +09:00
end
def short_description
2020-06-18 08:22:28 +09:00
info["shortDescription"]?.try &.as_s? || ""
2020-06-16 07:33:23 +09:00
end
def hls_manifest_url : String?
info.dig?("streamingData", "hlsManifestUrl").try &.as_s
2020-06-16 07:33:23 +09:00
end
def dash_manifest_url
info.dig?("streamingData", "dashManifestUrl").try &.as_s
2020-06-16 07:33:23 +09:00
end
def genre : String
info["genre"]?.try &.as_s || ""
end
2020-06-17 07:51:49 +09:00
def genre_url : String?
info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil
2020-06-16 07:33:23 +09:00
end
def license : String?
info["license"]?.try &.as_s
end
def is_family_friendly : Bool
info.dig?("microformat", "playerMicroformatRenderer", "isFamilySafe").try &.as_bool || false
2020-06-16 07:33:23 +09:00
end
2018-08-05 13:07:38 +09:00
def is_vr : Bool?
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
end
2020-06-16 07:33:23 +09:00
def reason : String?
info["reason"]?.try &.as_s
end
end
2021-07-12 08:17:22 +09:00
struct Caption
property name
2021-09-25 11:15:23 +09:00
property language_code
property base_url
2020-06-16 07:33:23 +09:00
getter name : String
2021-09-25 11:15:23 +09:00
getter language_code : String
getter base_url : String
2018-08-05 05:30:44 +09:00
setter name
2018-08-07 08:25:25 +09:00
2021-09-25 11:15:23 +09:00
def initialize(@name, @language_code, @base_url)
end
2018-08-07 08:25:25 +09:00
end
class VideoRedirect < Exception
2019-09-09 01:08:59 +09:00
property video_id : String
def initialize(@video_id)
end
end
2022-02-03 09:36:42 +09:00
# 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
# TODO: Use a proper struct/class instead of a hacky JSON object
2022-02-03 09:36:42 +09:00
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")
author_verified = has_verified_badge?(related["ownerBadges"]?).to_s
2022-05-02 04:10:43 +09:00
2022-02-03 09:36:42 +09:00
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 04:10:43 +09:00
"author_verified" => JSON::Any.new(author_verified),
2022-02-03 09:36:42 +09:00
}
2018-08-05 05:30:44 +09:00
end
def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil)
# Init client config for the API
client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region)
if context_screen == "embed"
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
end
# Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
if playability_status != "OK"
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
# Stop here if video is not a scheduled livestream
if playability_status != "LIVE_STREAM_OFFLINE"
return {
"reason" => JSON::Any.new(reason),
}
end
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
raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)")
else
reason = nil
end
# Don't fetch the next endpoint if the video is unavailable.
if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status)
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
player_response = player_response.merge(next_response)
end
params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason
# 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
if reason.nil?
if context_screen == "embed"
client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
else
client_config.client_type = YoutubeAPI::ClientType::Android
end
android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
# Sometimes, the video is available from the web client, but not on Android, so check
# that here, and fallback to the streaming data from the web client if needed.
# See: https://github.com/iv-org/invidious/issues/2549
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"
params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("")
else
params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("")
end
end
2019-04-11 08:02:13 +09:00
# TODO: clean that up
2020-06-16 07:33:23 +09:00
{"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f|
params[f] = player_response[f] if player_response[f]?
2019-04-11 08:02:13 +09:00
end
return params
end
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
# Top level elements
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
video_primary_renderer = primary_results
.as_a.find(&.["videoPrimaryInfoRenderer"]?)
.try &.["videoPrimaryInfoRenderer"]
video_secondary_renderer = primary_results
.as_a.find(&.["videoSecondaryInfoRenderer"]?)
.try &.["videoSecondaryInfoRenderer"]
raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer
raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer
2022-02-03 09:36:42 +09:00
# Related videos
LOGGER.debug("extract_video_info: parsing related videos...")
related = [] of JSON::Any
# Parse "compactVideoRenderer" items (under secondary results)
secondary_results = main_results
.dig?("secondaryResults", "secondaryResults", "results")
secondary_results.try &.as_a.each do |element|
2022-02-03 09:36:42 +09:00
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"
)
player_overlays.try &.as_a.each do |element|
2022-02-03 09:36:42 +09:00
if item = element["endScreenVideoRenderer"]?
related_video = parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
end
# Likes
toplevel_buttons = video_primary_renderer
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
if toplevel_buttons
likes_button = toplevel_buttons.as_a
.find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
.try &.["toggleButtonRenderer"]
if likes_button
likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?)
.try &.dig?("accessibility", "accessibilityData", "label")
likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt
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
short_description = player_response.dig?("videoDetails", "shortDescription")
description_html = video_secondary_renderer.try &.dig?("description", "runs")
.try &.as_a.try { |t| content_to_comment_html(t, video_id) }
# Video metadata
metadata = video_secondary_renderer
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
.try &.as_a
2019-04-11 08:02:13 +09:00
genre = player_response.dig?("microformat", "playerMicroformatRenderer", "category")
genre_ucid = nil
license = nil
2020-06-17 07:51:49 +09:00
2019-04-11 08:02:13 +09:00
metadata.try &.each do |row|
metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s
contents = row.dig?("metadataRowRenderer", "contents", 0)
2019-04-11 08:02:13 +09:00
if metadata_title == "Category"
contents = contents.try &.dig?("runs", 0)
2019-04-11 08:02:13 +09:00
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 08:02:13 +09:00
end
end
# Author infos
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 04:10:43 +09:00
subs_text = author_info["subscriberCountText"]?
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
.try &.as_s.split(" ", 2)[0]
end
# Return data
2019-04-11 08:02:13 +09:00
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 || "-"),
}
return params
2020-06-16 07:33:23 +09:00
end
2018-08-05 05:30:44 +09:00
2021-12-07 10:55:43 +09:00
def get_video(id, refresh = true, region = nil, force_refresh = false)
if (video = Invidious::Database::Videos.select(id)) && !region
2020-06-16 07:33:23 +09:00
# 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)
Invidious::Database::Videos.update(video)
2020-06-16 07:33:23 +09:00
rescue ex
Invidious::Database::Videos.delete(id)
2020-06-16 07:33:23 +09:00
raise ex
end
2019-02-07 07:12:11 +09:00
end
else
2020-06-16 07:33:23 +09:00
video = fetch_video(id, region)
Invidious::Database::Videos.insert(video) if !region
2018-08-05 05:30:44 +09:00
end
2020-06-16 07:33:23 +09:00
return video
2022-04-09 05:52:34 +09:00
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 07:12:11 +09:00
end
2019-06-29 11:17:56 +09:00
def fetch_video(id, region)
info = extract_video_info(video_id: id)
allowed_regions = info
.dig?("microformat", "playerMicroformatRenderer", "availableCountries")
.try &.as_a.map &.as_s || [] of String
2019-08-14 05:21:00 +09:00
# Check for region-blocks
2020-06-16 07:33:23 +09:00
if info["reason"]?.try &.as_s.includes?("your country")
bypass_regions = PROXY_LIST.keys & allowed_regions
if !bypass_regions.empty?
region = bypass_regions[rand(bypass_regions.size)]
region_info = extract_video_info(video_id: id, proxy_region: region)
2020-06-16 07:33:23 +09:00
region_info["region"] = JSON::Any.new(region) if region
info = region_info if !region_info["reason"]?
end
2018-08-13 23:17:28 +09:00
end
# Try to fetch video info using an embedded client
2018-08-05 05:30:44 +09:00
if info["reason"]?
embed_info = extract_video_info(video_id: id, context_screen: "embed")
info = embed_info if !embed_info["reason"]?
end
2018-09-10 04:47:26 +09:00
if reason = info["reason"]?
if reason == "Video unavailable"
raise NotFoundException.new(reason.as_s || "")
else
raise InfoException.new(reason.as_s || "")
end
end
2018-08-05 05:30:44 +09:00
video = Video.new({
id: id,
info: info,
updated: Time.utc,
})
2018-08-05 05:30:44 +09:00
return video
end
2020-06-16 07:33:23 +09:00
def itag_to_metadata?(itag : JSON::Any)
return VIDEO_FORMATS[itag.to_s]?
2018-08-05 05:30:44 +09:00
end
2018-08-05 13:07:38 +09:00
2021-12-07 10:55:43 +09:00
def process_continuation(query, plid, id)
2019-08-06 08:49:13 +09:00
continuation = nil
if plid
if index = query["index"]?.try &.to_i?
continuation = index
else
continuation = id
end
continuation ||= 0
end
continuation
end
2018-08-05 13:07:38 +09:00
def process_video_params(query, preferences)
2019-05-01 13:39:04 +09:00
annotations = query["iv_load_policy"]?.try &.to_i?
2019-10-05 01:23:28 +09:00
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
2021-09-25 11:42:43 +09:00
comments = query["comments"]?.try &.split(",").map(&.downcase)
2019-10-05 01:23:28 +09:00
continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe }
continue_autoplay = query["continue_autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
listen = query["listen"]?.try { |q| (q == "true" || q == "1").to_unsafe }
local = query["local"]?.try { |q| (q == "true" || q == "1").to_unsafe }
player_style = query["player_style"]?
2021-09-25 11:42:43 +09:00
preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase)
quality = query["quality"]?
quality_dash = query["quality_dash"]?
region = query["region"]?
2019-10-05 01:23:28 +09:00
related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
2019-06-06 01:10:23 +09:00
speed = query["speed"]?.try &.rchop("x").to_f?
2019-10-05 01:23:28 +09:00
video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe }
extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe }
volume = query["volume"]?.try &.to_i?
vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe }
save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
2018-08-05 13:07:38 +09:00
if preferences
# region ||= preferences.region
2019-05-01 13:39:04 +09:00
annotations ||= preferences.annotations.to_unsafe
2018-08-05 13:07:38 +09:00
autoplay ||= preferences.autoplay.to_unsafe
2019-05-30 04:24:30 +09:00
comments ||= preferences.comments
2018-11-12 02:45:05 +09:00
continue ||= preferences.continue.to_unsafe
2019-04-19 23:38:27 +09:00
continue_autoplay ||= preferences.continue_autoplay.to_unsafe
2018-10-30 23:41:23 +09:00
listen ||= preferences.listen.to_unsafe
2019-03-13 11:05:49 +09:00
local ||= preferences.local.to_unsafe
player_style ||= preferences.player_style
preferred_captions ||= preferences.captions
quality ||= preferences.quality
quality_dash ||= preferences.quality_dash
2019-03-13 11:05:49 +09:00
related_videos ||= preferences.related_videos.to_unsafe
speed ||= preferences.speed
2018-08-05 13:07:38 +09:00
video_loop ||= preferences.video_loop.to_unsafe
extend_desc ||= preferences.extend_desc.to_unsafe
volume ||= preferences.volume
vr_mode ||= preferences.vr_mode.to_unsafe
save_player_pos ||= preferences.save_player_pos.to_unsafe
2018-08-05 13:07:38 +09:00
end
2019-05-01 13:39:04 +09:00
annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe
autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe
2019-05-30 04:24:30 +09:00
comments ||= CONFIG.default_user_preferences.comments
continue ||= CONFIG.default_user_preferences.continue.to_unsafe
2019-04-19 23:38:27 +09:00
continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe
listen ||= CONFIG.default_user_preferences.listen.to_unsafe
local ||= CONFIG.default_user_preferences.local.to_unsafe
player_style ||= CONFIG.default_user_preferences.player_style
preferred_captions ||= CONFIG.default_user_preferences.captions
quality ||= CONFIG.default_user_preferences.quality
quality_dash ||= CONFIG.default_user_preferences.quality_dash
related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe
speed ||= CONFIG.default_user_preferences.speed
video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe
extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe
volume ||= CONFIG.default_user_preferences.volume
vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe
save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe
2019-05-01 13:39:04 +09:00
annotations = annotations == 1
autoplay = autoplay == 1
2018-11-12 02:45:05 +09:00
continue = continue == 1
2019-04-19 23:38:27 +09:00
continue_autoplay = continue_autoplay == 1
2018-10-30 23:41:23 +09:00
listen = listen == 1
local = local == 1
related_videos = related_videos == 1
2018-08-05 13:07:38 +09:00
video_loop = video_loop == 1
extend_desc = extend_desc == 1
vr_mode = vr_mode == 1
save_player_pos = save_player_pos == 1
2018-08-05 13:07:38 +09:00
if CONFIG.disabled?("dash") && quality == "dash"
quality = "high"
end
if CONFIG.disabled?("local") && local
local = false
end
2019-10-05 01:23:28 +09:00
if start = query["t"]? || query["time_continue"]? || query["start"]?
video_start = decode_time(start)
2018-08-05 13:07:38 +09:00
end
video_start ||= 0
if query["end"]?
video_end = decode_time(query["end"])
end
video_end ||= -1
raw = query["raw"]?.try &.to_i?
raw ||= 0
raw = raw == 1
controls = query["controls"]?.try &.to_i?
controls ||= 1
2019-04-05 05:05:54 +09:00
controls = controls >= 1
2018-08-05 13:07:38 +09:00
params = VideoPreferences.new({
annotations: annotations,
autoplay: autoplay,
comments: comments,
continue: continue,
continue_autoplay: continue_autoplay,
controls: controls,
listen: listen,
local: local,
player_style: player_style,
preferred_captions: preferred_captions,
quality: quality,
quality_dash: quality_dash,
raw: raw,
region: region,
related_videos: related_videos,
speed: speed,
video_end: video_end,
video_loop: video_loop,
extend_desc: extend_desc,
video_start: video_start,
volume: volume,
2021-04-12 13:34:56 +09:00
vr_mode: vr_mode,
save_player_pos: save_player_pos,
})
return params
2018-08-05 13:07:38 +09:00
end
2018-08-10 22:50:25 +09:00
2020-06-16 07:10:30 +09:00
def build_thumbnails(id)
2019-03-09 05:42:37 +09:00
return {
{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 05:42:37 +09:00
}
end
2020-06-16 07:10:30 +09:00
def generate_thumbnails(json, id)
2018-08-10 22:50:25 +09:00
json.array do
2020-06-16 07:10:30 +09:00
build_thumbnails(id).each do |thumbnail|
2018-08-10 22:50:25 +09:00
json.object do
json.field "quality", thumbnail[:name]
2019-03-09 05:42:37 +09:00
json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
json.field "width", thumbnail[:width]
json.field "height", thumbnail[:height]
2018-08-10 22:50:25 +09:00
end
end
end
end
2019-04-12 07:00:00 +09:00
2020-06-16 07:10:30 +09:00
def generate_storyboards(json, id, storyboards)
2019-04-12 07:00:00 +09:00
json.array do
storyboards.each do |storyboard|
json.object do
2019-05-03 04:20:19 +09:00
json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
json.field "templateUrl", storyboard[:url]
2019-04-12 07:00:00 +09:00
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