forked from midou/invidious
5b020e81ca
* Put youtube API functions under the YoutubeAPI namespace * Implement the following endpoints: - `next` - `player` - `resolve_url` * Allow a ClientConfig to be passed to YoutubeAPI endpoint handlers. * Add constants for many new clients * Fix documentation of YoutubeAPI.browse(): Comments and search result aren't returned by the browse() endpoint but by the next() and search() endpoints, respectively. * Accept gzip compressed data, to help save on bandwidth * Add debug/trace logging * Other minor fixes
469 lines
13 KiB
Crystal
469 lines
13 KiB
Crystal
struct SearchVideo
|
|
include DB::Serializable
|
|
|
|
property title : String
|
|
property id : String
|
|
property author : String
|
|
property ucid : String
|
|
property published : Time
|
|
property views : Int64
|
|
property description_html : String
|
|
property length_seconds : Int32
|
|
property live_now : Bool
|
|
property paid : Bool
|
|
property premium : Bool
|
|
property premiere_timestamp : Time?
|
|
|
|
def to_xml(auto_generated, query_params, xml : XML::Builder)
|
|
query_params["v"] = self.id
|
|
|
|
xml.element("entry") do
|
|
xml.element("id") { xml.text "yt:video:#{self.id}" }
|
|
xml.element("yt:videoId") { xml.text self.id }
|
|
xml.element("yt:channelId") { xml.text self.ucid }
|
|
xml.element("title") { xml.text self.title }
|
|
xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}")
|
|
|
|
xml.element("author") do
|
|
if auto_generated
|
|
xml.element("name") { xml.text self.author }
|
|
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
|
|
else
|
|
xml.element("name") { xml.text author }
|
|
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" }
|
|
end
|
|
end
|
|
|
|
xml.element("content", type: "xhtml") do
|
|
xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
|
|
xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do
|
|
xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
|
|
end
|
|
|
|
xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) }
|
|
end
|
|
end
|
|
|
|
xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
|
|
|
|
xml.element("media:group") do
|
|
xml.element("media:title") { xml.text self.title }
|
|
xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
|
|
width: "320", height: "180")
|
|
xml.element("media:description") { xml.text html_to_content(self.description_html) }
|
|
end
|
|
|
|
xml.element("media:community") do
|
|
xml.element("media:statistics", views: self.views)
|
|
end
|
|
end
|
|
end
|
|
|
|
def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil)
|
|
if xml
|
|
to_xml(HOST_URL, auto_generated, query_params, xml)
|
|
else
|
|
XML.build do |json|
|
|
to_xml(HOST_URL, auto_generated, query_params, xml)
|
|
end
|
|
end
|
|
end
|
|
|
|
def to_json(locale, json : JSON::Builder)
|
|
json.object do
|
|
json.field "type", "video"
|
|
json.field "title", self.title
|
|
json.field "videoId", self.id
|
|
|
|
json.field "author", self.author
|
|
json.field "authorId", self.ucid
|
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
|
|
|
json.field "videoThumbnails" do
|
|
generate_thumbnails(json, self.id)
|
|
end
|
|
|
|
json.field "description", html_to_content(self.description_html)
|
|
json.field "descriptionHtml", self.description_html
|
|
|
|
json.field "viewCount", self.views
|
|
json.field "published", self.published.to_unix
|
|
json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale))
|
|
json.field "lengthSeconds", self.length_seconds
|
|
json.field "liveNow", self.live_now
|
|
json.field "paid", self.paid
|
|
json.field "premium", self.premium
|
|
json.field "isUpcoming", self.is_upcoming
|
|
|
|
if self.premiere_timestamp
|
|
json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix
|
|
end
|
|
end
|
|
end
|
|
|
|
def to_json(locale, json : JSON::Builder | Nil = nil)
|
|
if json
|
|
to_json(locale, json)
|
|
else
|
|
JSON.build do |json|
|
|
to_json(locale, json)
|
|
end
|
|
end
|
|
end
|
|
|
|
def is_upcoming
|
|
premiere_timestamp ? true : false
|
|
end
|
|
end
|
|
|
|
struct SearchPlaylistVideo
|
|
include DB::Serializable
|
|
|
|
property title : String
|
|
property id : String
|
|
property length_seconds : Int32
|
|
end
|
|
|
|
struct SearchPlaylist
|
|
include DB::Serializable
|
|
|
|
property title : String
|
|
property id : String
|
|
property author : String
|
|
property ucid : String
|
|
property video_count : Int32
|
|
property videos : Array(SearchPlaylistVideo)
|
|
property thumbnail : String?
|
|
|
|
def to_json(locale, json : JSON::Builder)
|
|
json.object do
|
|
json.field "type", "playlist"
|
|
json.field "title", self.title
|
|
json.field "playlistId", self.id
|
|
json.field "playlistThumbnail", self.thumbnail
|
|
|
|
json.field "author", self.author
|
|
json.field "authorId", self.ucid
|
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
|
|
|
json.field "videoCount", self.video_count
|
|
json.field "videos" do
|
|
json.array do
|
|
self.videos.each do |video|
|
|
json.object do
|
|
json.field "title", video.title
|
|
json.field "videoId", video.id
|
|
json.field "lengthSeconds", video.length_seconds
|
|
|
|
json.field "videoThumbnails" do
|
|
generate_thumbnails(json, video.id)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def to_json(locale, json : JSON::Builder | Nil = nil)
|
|
if json
|
|
to_json(locale, json)
|
|
else
|
|
JSON.build do |json|
|
|
to_json(locale, json)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
struct SearchChannel
|
|
include DB::Serializable
|
|
|
|
property author : String
|
|
property ucid : String
|
|
property author_thumbnail : String
|
|
property subscriber_count : Int32
|
|
property video_count : Int32
|
|
property description_html : String
|
|
property auto_generated : Bool
|
|
|
|
def to_json(locale, json : JSON::Builder)
|
|
json.object do
|
|
json.field "type", "channel"
|
|
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
|
|
json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
|
|
json.field "width", quality
|
|
json.field "height", quality
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
json.field "autoGenerated", self.auto_generated
|
|
json.field "subCount", self.subscriber_count
|
|
json.field "videoCount", self.video_count
|
|
|
|
json.field "description", html_to_content(self.description_html)
|
|
json.field "descriptionHtml", self.description_html
|
|
end
|
|
end
|
|
|
|
def to_json(locale, json : JSON::Builder | Nil = nil)
|
|
if json
|
|
to_json(locale, json)
|
|
else
|
|
JSON.build do |json|
|
|
to_json(locale, json)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist
|
|
|
|
def channel_search(query, page, channel)
|
|
response = YT_POOL.client &.get("/channel/#{channel}")
|
|
|
|
if response.status_code == 404
|
|
response = YT_POOL.client &.get("/user/#{channel}")
|
|
response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404
|
|
initial_data = extract_initial_data(response.body)
|
|
ucid = initial_data["header"]["c4TabbedHeaderRenderer"]?.try &.["channelId"].as_s?
|
|
raise InfoException.new("Impossible to extract channel ID from page") if !ucid
|
|
else
|
|
ucid = channel
|
|
end
|
|
|
|
continuation = produce_channel_search_continuation(ucid, query, page)
|
|
response_json = YoutubeAPI.browse(continuation)
|
|
|
|
continuationItems = response_json["onResponseReceivedActions"]?
|
|
.try &.[0]["appendContinuationItemsAction"]["continuationItems"]
|
|
|
|
return 0, [] of SearchItem if !continuationItems
|
|
|
|
items = [] of SearchItem
|
|
continuationItems.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each { |item|
|
|
extract_item(item["itemSectionRenderer"]["contents"].as_a[0])
|
|
.try { |t| items << t }
|
|
}
|
|
|
|
return items.size, items
|
|
end
|
|
|
|
def search(query, search_params = produce_search_params(content_type: "all"), region = nil)
|
|
return 0, [] of SearchItem if query.empty?
|
|
|
|
client_config = YoutubeAPI::ClientConfig.new(region: region)
|
|
initial_data = YoutubeAPI.search(query, search_params, client_config: client_config)
|
|
items = extract_items(initial_data)
|
|
|
|
return items.size, items
|
|
end
|
|
|
|
def produce_search_params(page = 1, sort : String = "relevance", date : String = "", content_type : String = "",
|
|
duration : String = "", features : Array(String) = [] of String)
|
|
object = {
|
|
"1:varint" => 0_i64,
|
|
"2:embedded" => {} of String => Int64,
|
|
"9:varint" => ((page - 1) * 20).to_i64,
|
|
}
|
|
|
|
case sort
|
|
when "relevance"
|
|
object["1:varint"] = 0_i64
|
|
when "rating"
|
|
object["1:varint"] = 1_i64
|
|
when "upload_date", "date"
|
|
object["1:varint"] = 2_i64
|
|
when "view_count", "views"
|
|
object["1:varint"] = 3_i64
|
|
else
|
|
raise "No sort #{sort}"
|
|
end
|
|
|
|
case date
|
|
when "hour"
|
|
object["2:embedded"].as(Hash)["1:varint"] = 1_i64
|
|
when "today"
|
|
object["2:embedded"].as(Hash)["1:varint"] = 2_i64
|
|
when "week"
|
|
object["2:embedded"].as(Hash)["1:varint"] = 3_i64
|
|
when "month"
|
|
object["2:embedded"].as(Hash)["1:varint"] = 4_i64
|
|
when "year"
|
|
object["2:embedded"].as(Hash)["1:varint"] = 5_i64
|
|
else nil # Ignore
|
|
end
|
|
|
|
case content_type
|
|
when "video"
|
|
object["2:embedded"].as(Hash)["2:varint"] = 1_i64
|
|
when "channel"
|
|
object["2:embedded"].as(Hash)["2:varint"] = 2_i64
|
|
when "playlist"
|
|
object["2:embedded"].as(Hash)["2:varint"] = 3_i64
|
|
when "movie"
|
|
object["2:embedded"].as(Hash)["2:varint"] = 4_i64
|
|
when "show"
|
|
object["2:embedded"].as(Hash)["2:varint"] = 5_i64
|
|
when "all"
|
|
#
|
|
else
|
|
object["2:embedded"].as(Hash)["2:varint"] = 1_i64
|
|
end
|
|
|
|
case duration
|
|
when "short"
|
|
object["2:embedded"].as(Hash)["3:varint"] = 1_i64
|
|
when "long"
|
|
object["2:embedded"].as(Hash)["3:varint"] = 2_i64
|
|
else nil # Ignore
|
|
end
|
|
|
|
features.each do |feature|
|
|
case feature
|
|
when "hd"
|
|
object["2:embedded"].as(Hash)["4:varint"] = 1_i64
|
|
when "subtitles"
|
|
object["2:embedded"].as(Hash)["5:varint"] = 1_i64
|
|
when "creative_commons", "cc"
|
|
object["2:embedded"].as(Hash)["6:varint"] = 1_i64
|
|
when "3d"
|
|
object["2:embedded"].as(Hash)["7:varint"] = 1_i64
|
|
when "live", "livestream"
|
|
object["2:embedded"].as(Hash)["8:varint"] = 1_i64
|
|
when "purchased"
|
|
object["2:embedded"].as(Hash)["9:varint"] = 1_i64
|
|
when "4k"
|
|
object["2:embedded"].as(Hash)["14:varint"] = 1_i64
|
|
when "360"
|
|
object["2:embedded"].as(Hash)["15:varint"] = 1_i64
|
|
when "location"
|
|
object["2:embedded"].as(Hash)["23:varint"] = 1_i64
|
|
when "hdr"
|
|
object["2:embedded"].as(Hash)["25:varint"] = 1_i64
|
|
else nil # Ignore
|
|
end
|
|
end
|
|
|
|
if object["2:embedded"].as(Hash).empty?
|
|
object.delete("2:embedded")
|
|
end
|
|
|
|
params = object.try { |i| Protodec::Any.cast_json(object) }
|
|
.try { |i| Protodec::Any.from_json(i) }
|
|
.try { |i| Base64.urlsafe_encode(i) }
|
|
.try { |i| URI.encode_www_form(i) }
|
|
|
|
return params
|
|
end
|
|
|
|
def produce_channel_search_continuation(ucid, query, page)
|
|
if page <= 1
|
|
idx = 0_i64
|
|
else
|
|
idx = 30_i64 * (page - 1)
|
|
end
|
|
|
|
object = {
|
|
"80226972:embedded" => {
|
|
"2:string" => ucid,
|
|
"3:base64" => {
|
|
"2:string" => "search",
|
|
"6:varint" => 1_i64,
|
|
"7:varint" => 1_i64,
|
|
"12:varint" => 1_i64,
|
|
"15:base64" => {
|
|
"3:varint" => idx,
|
|
},
|
|
"23:varint" => 0_i64,
|
|
},
|
|
"11:string" => query,
|
|
"35:string" => "browse-feed#{ucid}search",
|
|
},
|
|
}
|
|
|
|
continuation = object.try { |i| Protodec::Any.cast_json(object) }
|
|
.try { |i| Protodec::Any.from_json(i) }
|
|
.try { |i| Base64.urlsafe_encode(i) }
|
|
.try { |i| URI.encode_www_form(i) }
|
|
|
|
return continuation
|
|
end
|
|
|
|
def process_search_query(query, page, user, region)
|
|
if user
|
|
user = user.as(User)
|
|
view_name = "subscriptions_#{sha256(user.email)}"
|
|
end
|
|
|
|
channel = nil
|
|
content_type = "all"
|
|
date = ""
|
|
duration = ""
|
|
features = [] of String
|
|
sort = "relevance"
|
|
subscriptions = nil
|
|
|
|
operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) }
|
|
operators.each do |operator|
|
|
key, value = operator.downcase.split(":")
|
|
|
|
case key
|
|
when "channel", "user"
|
|
channel = operator.split(":")[-1]
|
|
when "content_type", "type"
|
|
content_type = value
|
|
when "date"
|
|
date = value
|
|
when "duration"
|
|
duration = value
|
|
when "feature", "features"
|
|
features = value.split(",")
|
|
when "sort"
|
|
sort = value
|
|
when "subscriptions"
|
|
subscriptions = value == "true"
|
|
else
|
|
operators.delete(operator)
|
|
end
|
|
end
|
|
|
|
search_query = (query.split(" ") - operators).join(" ")
|
|
|
|
if channel
|
|
count, items = channel_search(search_query, page, channel)
|
|
elsif subscriptions
|
|
if view_name
|
|
items = PG_DB.query_all("SELECT id,title,published,updated,ucid,author,length_seconds FROM (
|
|
SELECT *,
|
|
to_tsvector(#{view_name}.title) ||
|
|
to_tsvector(#{view_name}.author)
|
|
as document
|
|
FROM #{view_name}
|
|
) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", search_query, (page - 1) * 20, as: ChannelVideo)
|
|
count = items.size
|
|
else
|
|
items = [] of ChannelVideo
|
|
count = 0
|
|
end
|
|
else
|
|
search_params = produce_search_params(page: page, sort: sort, date: date, content_type: content_type,
|
|
duration: duration, features: features)
|
|
|
|
count, items = search(search_query, search_params, region).as(Tuple)
|
|
end
|
|
|
|
{search_query, count, items, operators}
|
|
end
|