invidious/src/invidious/videos.cr

1438 行
51 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)",
"Afrikaans",
"Albanian",
"Amharic",
"Arabic",
"Armenian",
"Azerbaijani",
"Bangla",
"Basque",
"Belarusian",
"Bosnian",
"Bulgarian",
"Burmese",
"Catalan",
"Cebuano",
"Chinese (Simplified)",
"Chinese (Traditional)",
"Corsican",
"Croatian",
"Czech",
"Danish",
"Dutch",
"Esperanto",
"Estonian",
"Filipino",
"Finnish",
"French",
"Galician",
"Georgian",
"German",
"Greek",
"Gujarati",
"Haitian Creole",
"Hausa",
"Hawaiian",
"Hebrew",
"Hindi",
"Hmong",
"Hungarian",
"Icelandic",
"Igbo",
"Indonesian",
"Irish",
"Italian",
"Japanese",
"Javanese",
"Kannada",
"Kazakh",
"Khmer",
"Korean",
"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",
"Punjabi",
"Romanian",
"Russian",
"Samoan",
"Scottish Gaelic",
"Serbian",
"Shona",
"Sindhi",
"Sinhala",
"Slovak",
"Slovenian",
"Somali",
"Southern Sotho",
"Spanish",
2018-08-07 08:25:25 +09:00
"Spanish (Latin America)",
2018-08-07 03:23:36 +09:00
"Sundanese",
"Swahili",
"Swedish",
"Tajik",
"Tamil",
"Telugu",
"Thai",
"Turkish",
"Ukrainian",
"Urdu",
"Uzbek",
"Vietnamese",
"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
json_mapping({
annotations: Bool,
autoplay: Bool,
2019-05-30 04:24:30 +09:00
comments: Array(String),
2019-05-01 13:39:04 +09:00
continue: Bool,
continue_autoplay: Bool,
controls: Bool,
listen: Bool,
local: Bool,
preferred_captions: Array(String),
player_style: String,
2019-05-01 13:39:04 +09:00
quality: String,
raw: Bool,
region: String?,
related_videos: Bool,
speed: (Float32 | Float64),
video_end: (Float64 | Int32),
video_loop: Bool,
video_start: (Float64 | Int32),
volume: Int32,
})
end
2019-03-30 06:30:02 +09:00
struct Video
property player_json : JSON::Any?
2019-08-27 22:00:04 +09:00
property recommended_json : JSON::Any?
2018-08-05 05:30:44 +09:00
module HTTPParamConverter
def self.from_rs(rs)
HTTP::Params.parse(rs.read(String))
end
end
2019-06-09 03:31:41 +09:00
def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder)
json.object do
json.field "type", "video"
json.field "title", self.title
json.field "videoId", self.id
json.field "videoThumbnails" do
generate_thumbnails(json, self.id, config, kemal_config)
end
json.field "storyboards" do
generate_storyboards(json, self.id, self.storyboards, config, kemal_config)
end
2019-04-11 07:58:42 +09:00
2019-06-09 05:08:27 +09:00
json.field "description", html_to_content(self.description_html)
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", self.dislikes
json.field "paid", self.paid
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", self.info["avg_rating"].to_f32
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
json.field "premiereTimestamp", self.premiere_timestamp.not_nil!.to_unix
end
2019-04-11 07:58:42 +09:00
if player_response["streamingData"]?.try &.["hlsManifestUrl"]?
2019-06-09 03:31:41 +09:00
host_url = make_host_url(config, kemal_config)
2019-04-11 07:58:42 +09:00
hlsvp = player_response["streamingData"]["hlsManifestUrl"].as_s
2019-06-09 03:31:41 +09:00
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
2019-04-11 07:58:42 +09:00
2019-06-09 03:31:41 +09:00
json.field "hlsUrl", hlsvp
end
2019-04-11 07:58:42 +09:00
2019-06-09 03:31:41 +09:00
json.field "dashUrl", "#{make_host_url(config, kemal_config)}/api/manifest/dash/id/#{id}"
json.field "adaptiveFormats" do
json.array do
self.adaptive_fmts(decrypt_function).each do |fmt|
json.object do
json.field "index", fmt["index"]
json.field "bitrate", fmt["bitrate"]
json.field "init", fmt["init"]
json.field "url", fmt["url"]
json.field "itag", fmt["itag"]
json.field "type", fmt["type"]
json.field "clen", fmt["clen"]
json.field "lmt", fmt["lmt"]
json.field "projectionType", fmt["projection_type"]
fmt_info = itag_to_metadata?(fmt["itag"])
if fmt_info
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 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 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
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
self.fmt_stream(decrypt_function).each do |fmt|
json.object do
json.field "url", fmt["url"]
json.field "itag", fmt["itag"]
json.field "type", fmt["type"]
json.field "quality", fmt["quality"]
fmt_info = itag_to_metadata?(fmt["itag"])
if fmt_info
fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.to_i || 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 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.simpleText
json.field "languageCode", caption.languageCode
json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}"
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
self.info["rvs"]?.try &.split(",").each do |rv|
rv = HTTP::Params.parse(rv)
if rv["id"]?
json.object do
json.field "videoId", rv["id"]
json.field "title", rv["title"]
json.field "videoThumbnails" do
generate_thumbnails(json, rv["id"], config, kemal_config)
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"]
2019-08-31 12:57:33 +09:00
json.field "authorUrl", rv["author_url"]?
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}-")
json.field "width", quality
json.field "height", quality
end
end
end
end
end
2019-06-09 03:31:41 +09:00
json.field "lengthSeconds", rv["length_seconds"].to_i
json.field "viewCountText", rv["short_view_count_text"]
2019-08-31 12:57:33 +09:00
json.field "viewCount", rv["view_count"]?.try &.to_i64
2019-04-11 07:58:42 +09:00
end
end
end
end
end
end
end
2019-06-09 03:31:41 +09:00
def to_json(locale, config, kemal_config, decrypt_function, json : JSON::Builder | Nil = nil)
if json
to_json(locale, config, kemal_config, decrypt_function, json)
else
JSON.build do |json|
to_json(locale, config, kemal_config, decrypt_function, json)
end
end
end
2019-06-09 05:08:27 +09:00
# `description_html` is stored in DB as `description`, which can be
# quite confusing. Since it currently isn't very practical to rename
# it, we instead define a getter and setter here.
def description_html
self.description
end
def description_html=(other : String)
self.description = other
end
2019-03-23 00:32:42 +09:00
def allow_ratings
2019-03-27 03:47:06 +09:00
allow_ratings = player_response["videoDetails"]?.try &.["allowRatings"]?.try &.as_bool
2019-03-23 00:32:42 +09:00
if allow_ratings.nil?
2019-03-23 00:32:42 +09:00
return true
end
return allow_ratings
end
def live_now
live_now = player_response["videoDetails"]?.try &.["isLive"]?.try &.as_bool
2019-03-23 00:32:42 +09:00
if live_now.nil?
2019-03-23 00:32:42 +09:00
return false
end
return live_now
end
def is_listed
2019-03-27 03:47:06 +09:00
is_listed = player_response["videoDetails"]?.try &.["isCrawlable"]?.try &.as_bool
2019-03-23 00:32:42 +09:00
if is_listed.nil?
2019-03-23 00:32:42 +09:00
return true
end
return is_listed
end
def is_upcoming
2019-03-27 03:47:06 +09:00
is_upcoming = player_response["videoDetails"]?.try &.["isUpcoming"]?.try &.as_bool
if is_upcoming.nil?
return false
end
return is_upcoming
end
def premiere_timestamp
if self.is_upcoming
premiere_timestamp = player_response["playabilityStatus"]?
.try &.["liveStreamability"]?
.try &.["liveStreamabilityRenderer"]?
.try &.["offlineSlate"]?
.try &.["liveStreamOfflineSlateRenderer"]?
.try &.["scheduledStartTime"]?.try &.as_s.to_i64
end
if premiere_timestamp
premiere_timestamp = Time.unix(premiere_timestamp)
end
return premiere_timestamp
end
2018-11-02 22:09:28 +09:00
def keywords
keywords = player_response["videoDetails"]?.try &.["keywords"]?.try &.as_a
2018-11-02 22:26:35 +09:00
keywords ||= [] of String
return keywords
2018-11-02 22:09:28 +09:00
end
2018-08-05 13:07:38 +09:00
def fmt_stream(decrypt_function)
streams = [] of HTTP::Params
if fmt_streams = player_response["streamingData"]?.try &.["formats"]?
fmt_streams.as_a.each do |fmt_stream|
if !fmt_stream.as_h?
next
end
fmt = {} of String => String
2019-02-26 23:12:56 +09:00
fmt["lmt"] = fmt_stream["lastModified"]?.try &.as_s || "0"
fmt["projection_type"] = "1"
fmt["type"] = fmt_stream["mimeType"].as_s
fmt["clen"] = fmt_stream["contentLength"]?.try &.as_s || "0"
2019-02-26 23:12:56 +09:00
fmt["bitrate"] = fmt_stream["bitrate"]?.try &.as_i.to_s || "0"
fmt["itag"] = fmt_stream["itag"].as_i.to_s
2019-07-30 09:41:45 +09:00
if fmt_stream["url"]?
fmt["url"] = fmt_stream["url"].as_s
end
if fmt_stream["cipher"]?
HTTP::Params.parse(fmt_stream["cipher"].as_s).each do |key, value|
fmt[key] = value
end
end
fmt["quality"] = fmt_stream["quality"].as_s
if fmt_stream["width"]?
fmt["size"] = "#{fmt_stream["width"]}x#{fmt_stream["height"]}"
fmt["height"] = fmt_stream["height"].as_i.to_s
end
if fmt_stream["fps"]?
fmt["fps"] = fmt_stream["fps"].as_i.to_s
end
if fmt_stream["qualityLabel"]?
fmt["quality_label"] = fmt_stream["qualityLabel"].as_s
end
params = HTTP::Params.new
fmt.each do |key, value|
params[key] = value
end
streams << params
end
streams.sort_by! { |stream| stream["height"].to_i }.reverse!
elsif fmt_stream = self.info["url_encoded_fmt_stream_map"]?
fmt_stream.split(",").each do |string|
if !string.empty?
streams << HTTP::Params.parse(string)
end
2018-08-05 13:07:38 +09:00
end
end
streams.each { |s| s.add("label", "#{s["quality"]} - #{s["type"].split(";")[0].split("/")[1]}") }
streams = streams.uniq { |s| s["label"] }
2018-10-02 09:01:44 +09:00
if self.info["region"]?
streams.each do |fmt|
fmt["url"] += "&region=" + self.info["region"]
end
end
streams.each do |fmt|
2019-03-12 03:14:30 +09:00
fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "")
fmt["url"] += decrypt_signature(fmt, decrypt_function)
2018-08-05 13:07:38 +09:00
end
return streams
end
def adaptive_fmts(decrypt_function)
adaptive_fmts = [] of HTTP::Params
2018-09-16 00:25:43 +09:00
if fmts = player_response["streamingData"]?.try &.["adaptiveFormats"]?
fmts.as_a.each do |adaptive_fmt|
if !adaptive_fmt.as_h?
next
end
fmt = {} of String => String
if init = adaptive_fmt["initRange"]?
fmt["init"] = "#{init["start"]}-#{init["end"]}"
end
fmt["init"] ||= "0-0"
2019-02-26 23:12:56 +09:00
fmt["lmt"] = adaptive_fmt["lastModified"]?.try &.as_s || "0"
fmt["projection_type"] = "1"
fmt["type"] = adaptive_fmt["mimeType"].as_s
fmt["clen"] = adaptive_fmt["contentLength"]?.try &.as_s || "0"
2019-02-26 23:12:56 +09:00
fmt["bitrate"] = adaptive_fmt["bitrate"]?.try &.as_i.to_s || "0"
fmt["itag"] = adaptive_fmt["itag"].as_i.to_s
2019-07-30 09:41:45 +09:00
if adaptive_fmt["url"]?
fmt["url"] = adaptive_fmt["url"].as_s
end
if adaptive_fmt["cipher"]?
HTTP::Params.parse(adaptive_fmt["cipher"].as_s).each do |key, value|
fmt[key] = value
end
end
if index = adaptive_fmt["indexRange"]?
fmt["index"] = "#{index["start"]}-#{index["end"]}"
end
fmt["index"] ||= "0-0"
if adaptive_fmt["width"]?
fmt["size"] = "#{adaptive_fmt["width"]}x#{adaptive_fmt["height"]}"
end
if adaptive_fmt["fps"]?
fmt["fps"] = adaptive_fmt["fps"].as_i.to_s
end
if adaptive_fmt["qualityLabel"]?
fmt["quality_label"] = adaptive_fmt["qualityLabel"].as_s
end
params = HTTP::Params.new
fmt.each do |key, value|
params[key] = value
end
adaptive_fmts << params
end
elsif fmts = self.info["adaptive_fmts"]?
fmts.split(",") do |string|
adaptive_fmts << HTTP::Params.parse(string)
end
2018-08-05 13:07:38 +09:00
end
2018-10-02 09:01:44 +09:00
if self.info["region"]?
adaptive_fmts.each do |fmt|
fmt["url"] += "&region=" + self.info["region"]
end
end
adaptive_fmts.each do |fmt|
2019-03-12 03:14:30 +09:00
fmt["url"] += "&host=" + (URI.parse(fmt["url"]).host || "")
fmt["url"] += decrypt_signature(fmt, decrypt_function)
2018-08-05 13:07:38 +09:00
end
return adaptive_fmts
end
2018-08-08 01:39:56 +09:00
def video_streams(adaptive_fmts)
video_streams = adaptive_fmts.select { |s| s["type"].starts_with? "video" }
2018-08-08 01:39:56 +09:00
return video_streams
end
2018-08-05 13:07:38 +09:00
def audio_streams(adaptive_fmts)
audio_streams = adaptive_fmts.select { |s| s["type"].starts_with? "audio" }
2018-08-05 13:07:38 +09:00
audio_streams.sort_by! { |s| s["bitrate"].to_i }.reverse!
audio_streams.each do |stream|
stream["bitrate"] = (stream["bitrate"].to_f64/1000).to_i.to_s
end
return audio_streams
end
2019-08-27 22:00:04 +09:00
def player_response
@player_json = JSON.parse(@info["player_response"]) if !@player_json
@player_json.not_nil!
end
2019-04-12 07:00:00 +09:00
def storyboards
storyboards = player_response["storyboards"]?
2019-04-12 07:00:00 +09:00
.try &.as_h
.try &.["playerStoryboardSpecRenderer"]?
2019-10-04 23:23:02 +09:00
.try &.["spec"]?
.try &.as_s.split("|")
2019-04-12 07:00:00 +09:00
if !storyboards
2019-10-04 23:23:02 +09:00
if storyboard = player_response["storyboards"]?
.try &.as_h
.try &.["playerLiveStoryboardSpecRenderer"]?
.try &.["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)
if !storyboards
return items
end
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 |storyboard, i|
width, height, count, storyboard_width, storyboard_height, interval, _, sigh = storyboard.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
2018-10-17 01:15:14 +09:00
def paid
reason = player_response["playabilityStatus"]?.try &.["reason"]?
paid = reason == "This video requires payment to watch." ? true : false
2018-10-17 01:15:14 +09:00
return paid
end
def premium
2019-08-05 10:56:24 +09:00
if info["premium"]?
self.info["premium"] == "true"
else
false
end
2018-10-17 01:15:14 +09:00
end
def captions
2018-08-07 08:25:25 +09:00
captions = [] of Caption
2018-08-05 13:07:38 +09:00
if player_response["captions"]?
caption_list = player_response["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]?.try &.as_a
caption_list ||= [] of JSON::Any
2018-08-07 08:25:25 +09:00
caption_list.each do |caption|
caption = Caption.from_json(caption.to_json)
caption.name.simpleText = caption.name.simpleText.split(" - ")[0]
captions << caption
end
2018-08-05 13:07:38 +09:00
end
return captions
end
def short_description
2019-06-09 05:08:27 +09:00
short_description = self.description_html.gsub(/(<br>)|(<br\/>|"|\n)/, {
2019-06-09 06:34:55 +09:00
"<br>": " ",
"<br/>": " ",
"\"": "&quot;",
"\n": " ",
2019-06-09 05:08:27 +09:00
})
short_description = XML.parse_html(short_description).content[0..200].strip(" ")
if short_description.empty?
short_description = " "
2018-08-05 13:07:38 +09:00
end
2019-06-09 05:08:27 +09:00
return short_description
2018-08-05 13:07:38 +09:00
end
def length_seconds
player_response["videoDetails"]["lengthSeconds"].as_s.to_i
end
db_mapping({
2018-08-05 05:30:44 +09:00
id: String,
info: {
type: HTTP::Params,
default: HTTP::Params.parse(""),
converter: Video::HTTPParamConverter,
},
updated: Time,
title: String,
views: Int64,
likes: Int32,
dislikes: Int32,
wilson_score: Float64,
published: Time,
description: String,
language: String?,
author: String,
ucid: String,
allowed_regions: Array(String),
is_family_friendly: Bool,
genre: String,
2018-09-10 04:34:16 +09:00
genre_url: String,
license: String,
sub_count_text: String,
2018-10-30 23:04:01 +09:00
author_thumbnail: String,
2018-08-05 05:30:44 +09:00
})
end
2019-03-30 06:30:02 +09:00
struct Caption
2019-05-21 10:22:01 +09:00
json_mapping({
name: CaptionName,
baseUrl: String,
languageCode: String,
})
2018-08-07 08:25:25 +09:00
end
2019-03-30 06:30:02 +09:00
struct CaptionName
2019-05-21 10:22:01 +09:00
json_mapping({
2018-08-07 08:25:25 +09:00
simpleText: String,
2019-05-21 10:22:01 +09:00
})
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
2019-06-29 11:17:56 +09:00
def get_video(id, db, refresh = true, region = nil, force_refresh = false)
2019-06-09 03:31:41 +09:00
if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region
2019-06-09 00:18:45 +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 && video.premiere_timestamp.as(Time) < Time.utc)) ||
force_refresh
2018-08-05 05:30:44 +09:00
begin
2019-06-29 11:17:56 +09:00
video = fetch_video(id, region)
2018-08-05 05:30:44 +09:00
video_array = video.to_a
2018-09-04 23:50:19 +09:00
2018-08-05 05:30:44 +09:00
args = arg_array(video_array[1..-1], 2)
db.exec("UPDATE videos SET (info,updated,title,views,likes,dislikes,wilson_score,\
2018-10-14 10:03:48 +09:00
published,description,language,author,ucid,allowed_regions,is_family_friendly,\
genre,genre_url,license,sub_count_text,author_thumbnail)\
2019-09-25 02:37:06 +09:00
= (#{args}) WHERE id = $1", args: video_array)
2018-08-05 05:30:44 +09:00
rescue ex
db.exec("DELETE FROM videos * WHERE id = $1", id)
raise ex
end
end
else
2019-06-29 11:17:56 +09:00
video = fetch_video(id, region)
2018-08-05 05:30:44 +09:00
video_array = video.to_a
2018-09-04 23:50:19 +09:00
2018-08-05 05:30:44 +09:00
args = arg_array(video_array)
2018-11-27 11:46:08 +09:00
if !region
2019-09-25 02:37:06 +09:00
db.exec("INSERT INTO videos VALUES (#{args}) ON CONFLICT (id) DO NOTHING", args: video_array)
2018-11-27 11:46:08 +09:00
end
2018-08-05 05:30:44 +09:00
end
return video
end
def extract_recommended(recommended_videos)
rvs = [] of HTTP::Params
recommended_videos.try &.each do |compact_renderer|
if compact_renderer["compactRadioRenderer"]? || compact_renderer["compactPlaylistRenderer"]?
# TODO
elsif video_renderer = compact_renderer["compactVideoRenderer"]?
recommended_video = HTTP::Params.new
recommended_video["id"] = video_renderer["videoId"].as_s
recommended_video["title"] = video_renderer["title"]["simpleText"].as_s
2019-09-13 02:06:10 +09:00
next if !video_renderer["shortBylineText"]?
recommended_video["author"] = video_renderer["shortBylineText"]["runs"].as_a[0]["text"].as_s
recommended_video["ucid"] = video_renderer["shortBylineText"]["runs"].as_a[0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
recommended_video["author_thumbnail"] = video_renderer["channelThumbnail"]["thumbnails"][0]["url"].as_s
if view_count = video_renderer["viewCountText"]?.try { |field| field["simpleText"]?.try &.as_s || field["runs"][0]?.try &.["text"].as_s }.try &.delete(", views watching").to_i64?.try &.to_s
recommended_video["view_count"] = view_count
recommended_video["short_view_count_text"] = "#{number_to_short_text(view_count.to_i64)} views"
end
recommended_video["length_seconds"] = decode_length_seconds(video_renderer["lengthText"]?.try &.["simpleText"]?.try &.as_s || "0:00").to_s
rvs << recommended_video
end
end
rvs
end
2019-04-11 08:02:13 +09:00
def extract_polymer_config(body, html)
params = HTTP::Params.new
params["session_token"] = body.match(/"XSRF_TOKEN":"(?<session_token>[A-Za-z0-9\_\-\=]+)"/).try &.["session_token"] || ""
html_info = JSON.parse(body.match(/ytplayer\.config = (?<info>.*?);ytplayer\.load/).try &.["info"] || "{}").try &.["args"]?.try &.as_h
if html_info
html_info.each do |key, value|
params[key] = value.to_s
end
end
2019-07-11 21:27:42 +09:00
initial_data = extract_initial_data(body)
2019-04-11 08:02:13 +09:00
primary_results = initial_data["contents"]?
.try &.["twoColumnWatchNextResults"]?
.try &.["results"]?
.try &.["results"]?
.try &.["contents"]?
comment_continuation = primary_results.try &.as_a.select { |object| object["itemSectionRenderer"]? }[0]?
.try &.["itemSectionRenderer"]?
.try &.["continuations"]?
.try &.[0]?
.try &.["nextContinuationData"]?
params["ctoken"] = comment_continuation.try &.["continuation"]?.try &.as_s || ""
params["itct"] = comment_continuation.try &.["clickTrackingParams"]?.try &.as_s || ""
rvs = initial_data["contents"]?
2019-04-11 08:02:13 +09:00
.try &.["twoColumnWatchNextResults"]?
.try &.["secondaryResults"]?
.try &.["secondaryResults"]?
.try &.["results"]?
.try &.as_a
params["rvs"] = extract_recommended(rvs).join(",")
2019-04-11 08:02:13 +09:00
# TODO: Watching now
params["views"] = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]?
.try &.["videoPrimaryInfoRenderer"]?
.try &.["viewCount"]?
.try &.["videoViewCountRenderer"]?
.try &.["viewCount"]?
.try &.["simpleText"]?
.try &.as_s.gsub(/\D/, "").to_i64.to_s || "0"
sentiment_bar = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]?
.try &.["videoPrimaryInfoRenderer"]?
.try &.["sentimentBar"]?
.try &.["sentimentBarRenderer"]?
.try &.["tooltip"]?
.try &.as_s
likes, dislikes = sentiment_bar.try &.split(" / ").map { |a| a.delete(", ").to_i32 }[0, 2] || {0, 0}
params["likes"] = "#{likes}"
params["dislikes"] = "#{dislikes}"
published = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]?
.try &.["videoSecondaryInfoRenderer"]?
.try &.["dateText"]?
.try &.["simpleText"]?
.try &.as_s.split(" ")[-3..-1].join(" ")
if published
params["published"] = Time.parse(published, "%b %-d, %Y", Time::Location.local).to_unix.to_s
else
2019-06-08 10:23:37 +09:00
params["published"] = Time.utc(1990, 1, 1).to_unix.to_s
2019-04-11 08:02:13 +09:00
end
params["description_html"] = "<p></p>"
description_html = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]?
.try &.["videoSecondaryInfoRenderer"]?
.try &.["description"]?
.try &.["runs"]?
.try &.as_a
if description_html
params["description_html"] = content_to_comment_html(description_html)
end
metadata = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]?
.try &.["videoSecondaryInfoRenderer"]?
.try &.["metadataRowContainer"]?
.try &.["metadataRowContainerRenderer"]?
.try &.["rows"]?
.try &.as_a
params["genre"] = ""
params["genre_ucid"] = ""
params["license"] = ""
metadata.try &.each do |row|
title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s
contents = row["metadataRowRenderer"]?
.try &.["contents"]?
.try &.as_a[0]?
if title.try &.== "Category"
contents = contents.try &.["runs"]?
.try &.as_a[0]?
params["genre"] = contents.try &.["text"]?
.try &.as_s || ""
params["genre_ucid"] = contents.try &.["navigationEndpoint"]?
.try &.["browseEndpoint"]?
.try &.["browseId"]?.try &.as_s || ""
elsif title.try &.== "License"
contents = contents.try &.["runs"]?
.try &.as_a[0]?
params["license"] = contents.try &.["text"]?
.try &.as_s || ""
elsif title.try &.== "Licensed to YouTube by"
params["license"] = contents.try &.["simpleText"]?
.try &.as_s || ""
end
end
author_info = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]?
.try &.["videoSecondaryInfoRenderer"]?
.try &.["owner"]?
.try &.["videoOwnerRenderer"]?
params["author_thumbnail"] = author_info.try &.["thumbnail"]?
.try &.["thumbnails"]?
.try &.as_a[0]?
.try &.["url"]?
.try &.as_s || ""
params["sub_count_text"] = author_info.try &.["subscriberCountText"]?
.try &.["simpleText"]?
.try &.as_s.gsub(/\D/, "") || "0"
return params
end
2019-02-07 07:12:11 +09:00
def extract_player_config(body, html)
params = HTTP::Params.new
2018-08-05 05:30:44 +09:00
if md = body.match(/'XSRF_TOKEN': "(?<session_token>[A-Za-z0-9\_\-\=]+)"/)
params["session_token"] = md["session_token"]
end
2019-08-27 22:00:04 +09:00
if md = body.match(/'RELATED_PLAYER_ARGS': (?<json>.*?),\n/)
recommended_json = JSON.parse(md["json"])
rvs_params = recommended_json["rvs"].as_s.split(",").map { |params| HTTP::Params.parse(params) }
2019-08-27 22:00:04 +09:00
if watch_next_response = recommended_json["watch_next_response"]?
watch_next_json = JSON.parse(watch_next_response.as_s)
rvs = watch_next_json["contents"]?
2019-08-27 22:00:04 +09:00
.try &.["twoColumnWatchNextResults"]?
.try &.["secondaryResults"]?
.try &.["secondaryResults"]?
.try &.["results"]?
.try &.as_a
rvs = extract_recommended(rvs).compact_map do |rv|
if !rv["short_view_count_text"]?
2019-09-01 05:24:13 +09:00
rv_params = rvs_params.select { |rv_params| rv_params["id"]? == (rv["id"]? || "") }[0]?
if rv_params.try &.["short_view_count_text"]?
rv["short_view_count_text"] = rv_params.not_nil!["short_view_count_text"]
rv
else
nil
end
2019-09-08 10:56:33 +09:00
else
rv
2019-08-27 22:00:04 +09:00
end
end
params["rvs"] = (rvs.map &.to_s).join(",")
2019-08-27 22:00:04 +09:00
end
end
html_info = body.match(/ytplayer\.config = (?<info>.*?);ytplayer\.load/).try &.["info"]
2018-08-05 05:30:44 +09:00
if html_info
JSON.parse(html_info)["args"].as_h.each do |key, value|
params[key] = value.to_s
2019-02-07 07:12:11 +09:00
end
else
error_message = html.xpath_node(%q(//h1[@id="unavailable-message"]))
if error_message
params["reason"] = error_message.content.strip
else
params["reason"] = "Could not extract video info."
end
2018-08-05 05:30:44 +09:00
end
2019-02-07 07:12:11 +09:00
return params
end
2019-06-29 11:17:56 +09:00
def fetch_video(id, region)
client = make_client(YT_URL, region)
2019-02-07 07:12:11 +09:00
response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
if md = response.headers["location"]?.try &.match(/v=(?<id>[a-zA-Z0-9_-]{11})/)
2019-09-09 01:08:59 +09:00
raise VideoRedirect.new(video_id: md["id"])
end
2019-02-07 07:12:11 +09:00
html = XML.parse_html(response.body)
info = extract_player_config(response.body, html)
info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ")
2018-08-05 05:30:44 +09:00
2019-08-14 05:21:00 +09:00
allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
if !allowed_regions || allowed_regions == [""]
allowed_regions = [] of String
end
2019-08-14 05:21:00 +09:00
# Check for region-blocks
if info["reason"]? && info["reason"].includes?("your country")
bypass_regions = PROXY_LIST.keys & allowed_regions
if !bypass_regions.empty?
region = bypass_regions[rand(bypass_regions.size)]
client = make_client(YT_URL, region)
response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
html = XML.parse_html(response.body)
info = extract_player_config(response.body, html)
info["region"] = region if region
info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ")
end
2018-08-13 23:17:28 +09:00
end
2019-02-07 07:12:11 +09:00
# Try to pull streams from embed URL
2018-08-05 05:30:44 +09:00
if info["reason"]?
embed_page = client.get("/embed/#{id}").body
sts = embed_page.match(/"sts"\s*:\s*(?<sts>\d+)/).try &.["sts"]?
sts ||= ""
embed_info = HTTP::Params.parse(client.get("/get_video_info?video_id=#{id}&eurl=https://youtube.googleapis.com/v/#{id}&gl=US&hl=en&disable_polymer=1&sts=#{sts}").body)
2018-11-11 01:50:09 +09:00
2019-02-07 07:12:11 +09:00
if !embed_info["reason"]?
embed_info.each do |key, value|
info[key] = value.to_s
end
2019-02-07 07:12:11 +09:00
else
2018-11-11 01:50:09 +09:00
raise info["reason"]
end
2018-08-05 05:30:44 +09:00
end
2019-08-06 04:19:02 +09:00
if !info["player_response"]? || info["errorcode"]?.try &.== "2"
2019-01-31 23:48:44 +09:00
raise "Video unavailable."
end
if info["reason"]? && !info["player_response"]["videoDetails"]?
2019-07-30 09:41:45 +09:00
raise info["reason"]
2019-03-23 12:17:39 +09:00
end
2019-07-30 09:41:45 +09:00
player_json = JSON.parse(info["player_response"])
reason = player_json["playabilityStatus"]?.try &.["reason"]?.try &.as_s
if reason == "This video is not available."
raise "This video is not available."
end
2019-07-30 09:41:45 +09:00
title = player_json["videoDetails"]["title"].as_s
author = player_json["videoDetails"]["author"]?.try &.as_s || ""
2019-07-31 00:12:41 +09:00
ucid = player_json["videoDetails"]["channelId"]?.try &.as_s || ""
2018-08-05 05:30:44 +09:00
2019-08-05 10:56:24 +09:00
info["premium"] = html.xpath_node(%q(.//span[text()="Premium"])) ? "true" : "false"
2018-11-02 22:09:28 +09:00
views = html.xpath_node(%q(//meta[@itemprop="interactionCount"]))
2019-06-09 05:08:27 +09:00
.try &.["content"].to_i64? || 0_i64
2018-11-02 22:09:28 +09:00
2018-08-05 05:30:44 +09:00
likes = html.xpath_node(%q(//button[@title="I like this"]/span))
2019-06-09 05:08:27 +09:00
.try &.content.delete(",").try &.to_i? || 0
2018-08-05 05:30:44 +09:00
dislikes = html.xpath_node(%q(//button[@title="I dislike this"]/span))
2019-06-09 05:08:27 +09:00
.try &.content.delete(",").try &.to_i? || 0
2018-08-05 05:30:44 +09:00
avg_rating = (likes.to_f/(likes.to_f + dislikes.to_f) * 4 + 1)
avg_rating = avg_rating.nan? ? 0.0 : avg_rating
info["avg_rating"] = "#{avg_rating}"
2019-01-10 23:06:54 +09:00
2019-06-09 05:08:27 +09:00
description_html = html.xpath_node(%q(//p[@id="eow-description"])).try &.to_xml(options: XML::SaveOptions::NO_DECL) || ""
2018-08-05 05:30:44 +09:00
wilson_score = ci_lower_bound(likes, likes + dislikes)
published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).try &.["content"]
2019-06-08 09:56:41 +09:00
published ||= Time.utc.to_s("%Y-%m-%d")
2018-08-05 05:30:44 +09:00
published = Time.parse(published, "%Y-%m-%d", Time::Location.local)
is_family_friendly = html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).try &.["content"] == "True"
is_family_friendly ||= true
2018-09-04 23:50:19 +09:00
genre = html.xpath_node(%q(//meta[@itemprop="genre"])).try &.["content"]
genre ||= ""
2019-06-09 05:08:27 +09:00
genre_url = html.xpath_node(%(//ul[contains(@class, "watch-info-tag-list")]/li/a[text()="#{genre}"])).try &.["href"]?
genre_url ||= ""
2019-01-26 02:35:25 +09:00
2019-05-14 22:02:55 +09:00
# YouTube provides invalid URLs for some genres, so we fix that here
case genre
2019-05-14 22:02:55 +09:00
when "Comedy"
genre_url = "/channel/UCQZ43c4dAA9eXCQuXWu9aTw"
2019-01-26 02:35:25 +09:00
when "Education"
genre_url = "/channel/UCdxpofrI-dO6oYfsqHDHphw"
when "Gaming"
genre_url = "/channel/UCOpNcN46UbXVtpKMrmU4Abg"
when "Movies"
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
2019-01-26 02:35:25 +09:00
when "Nonprofits & Activism"
genre_url = "/channel/UCfFyYRYslvuhwMDnx6KjUvw"
when "Trailers"
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
end
2018-09-10 04:47:26 +09:00
2019-06-09 05:08:27 +09:00
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li)).try &.content || ""
2019-09-13 10:09:23 +09:00
sub_count_text = html.xpath_node(%q(//span[contains(@class, "subscriber-count")])).try &.["title"]? || "0"
author_thumbnail = html.xpath_node(%(//span[@class="yt-thumb-clip"]/img)).try &.["data-thumb"]?.try &.gsub(/^\/\//, "https://") || ""
2019-06-09 05:08:27 +09:00
video = Video.new(id, info, Time.utc, title, views, likes, dislikes, wilson_score, published, description_html,
nil, author, ucid, allowed_regions, is_family_friendly, genre, genre_url, license, sub_count_text, author_thumbnail)
2018-08-05 05:30:44 +09:00
return video
end
2018-08-12 23:24:59 +09:00
def itag_to_metadata?(itag : String)
2018-08-12 23:34:26 +09:00
return VIDEO_FORMATS[itag]?
2018-08-05 05:30:44 +09:00
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?
2018-08-05 13:07:38 +09:00
autoplay = query["autoplay"]?.try &.to_i?
2019-05-30 04:24:30 +09:00
comments = query["comments"]?.try &.split(",").map { |a| a.downcase }
2018-11-12 02:45:05 +09:00
continue = query["continue"]?.try &.to_i?
2019-04-19 23:38:27 +09:00
continue_autoplay = query["continue_autoplay"]?.try &.to_i?
2018-10-30 23:41:23 +09:00
listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe
2019-06-07 11:31:10 +09:00
local = query["local"]? && (query["local"] == "true" || query["local"] == "1").to_unsafe
player_style = query["player_style"]?
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]?
region = query["region"]?
related_videos = query["related_videos"]? && (query["related_videos"] == "true" || query["related_videos"] == "1").to_unsafe
2019-06-06 01:10:23 +09:00
speed = query["speed"]?.try &.rchop("x").to_f?
2018-08-05 13:07:38 +09:00
video_loop = query["loop"]?.try &.to_i?
volume = query["volume"]?.try &.to_i?
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
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
volume ||= preferences.volume
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
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
volume ||= CONFIG.default_user_preferences.volume
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
if CONFIG.disabled?("dash") && quality == "dash"
quality = "high"
end
if CONFIG.disabled?("local") && local
local = false
end
2018-08-05 13:07:38 +09:00
if query["t"]?
video_start = decode_time(query["t"])
end
video_start ||= 0
2018-08-31 10:25:43 +09:00
if query["time_continue"]?
2018-08-31 11:04:41 +09:00
video_start = decode_time(query["time_continue"])
2018-08-06 02:35:33 +09:00
end
video_start ||= 0
if query["start"]?
video_start = decode_time(query["start"])
end
2018-08-05 13:07:38 +09:00
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
2019-05-01 13:39:04 +09:00
params = VideoPreferences.new(
annotations: annotations,
autoplay: autoplay,
2019-05-30 04:24:30 +09:00
comments: comments,
2019-05-01 13:39:04 +09:00
continue: continue,
continue_autoplay: continue_autoplay,
controls: controls,
listen: listen,
local: local,
player_style: player_style,
preferred_captions: preferred_captions,
2019-05-01 13:39:04 +09:00
quality: quality,
raw: raw,
region: region,
related_videos: related_videos,
speed: speed,
video_end: video_end,
video_loop: video_loop,
video_start: video_start,
volume: volume,
)
return params
2018-08-05 13:07:38 +09:00
end
2018-08-10 22:50:25 +09:00
2019-03-09 05:42:37 +09:00
def build_thumbnails(id, config, kemal_config)
return {
{name: "maxres", host: "#{make_host_url(config, kemal_config)}", url: "maxres", height: 720, width: 1280},
{name: "maxresdefault", host: "https://i.ytimg.com", url: "maxresdefault", height: 720, width: 1280},
{name: "sddefault", host: "https://i.ytimg.com", url: "sddefault", height: 480, width: 640},
{name: "high", host: "https://i.ytimg.com", url: "hqdefault", height: 360, width: 480},
{name: "medium", host: "https://i.ytimg.com", url: "mqdefault", height: 180, width: 320},
{name: "default", host: "https://i.ytimg.com", url: "default", height: 90, width: 120},
{name: "start", host: "https://i.ytimg.com", url: "1", height: 90, width: 120},
{name: "middle", host: "https://i.ytimg.com", url: "2", height: 90, width: 120},
{name: "end", host: "https://i.ytimg.com", url: "3", height: 90, width: 120},
}
end
def generate_thumbnails(json, id, config, kemal_config)
2018-08-10 22:50:25 +09:00
json.array do
2019-03-09 05:42:37 +09:00
build_thumbnails(id, config, kemal_config).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
2019-05-03 04:20:19 +09:00
def generate_storyboards(json, id, storyboards, config, kemal_config)
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