2018-08-04 20:30:44 +00:00
|
|
|
class Config
|
|
|
|
YAML.mapping({
|
2019-01-23 20:30:45 +00:00
|
|
|
crawl_threads: Int32, # Number of threads to use for finding new videos from YouTube (used to populate "top" page)
|
2019-01-23 20:28:31 +00:00
|
|
|
channel_threads: Int32, # Number of threads to use for crawling videos from channels (for updating subscriptions)
|
|
|
|
feed_threads: Int32, # Number of threads to use for updating feeds
|
|
|
|
video_threads: Int32, # Number of threads to use for updating videos in cache (mostly non-functional)
|
|
|
|
db: NamedTuple( # Database configuration
|
2019-01-23 20:37:04 +00:00
|
|
|
user: String,
|
|
|
|
password: String,
|
|
|
|
host: String,
|
|
|
|
port: Int32,
|
|
|
|
dbname: String,
|
|
|
|
),
|
|
|
|
dl_api_key: String?, # DetectLanguage API Key (used to filter non-English results from "top" page), mostly non-functional
|
|
|
|
https_only: Bool?, # Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
|
|
|
hmac_key: String?, # HMAC signing key for CSRF tokens
|
|
|
|
full_refresh: Bool, # Used for crawling channels: threads should check all videos uploaded by a channel
|
|
|
|
domain: String, # Domain to be used for links to resources on the site where an absolute URL is required
|
2018-08-04 20:30:44 +00:00
|
|
|
})
|
|
|
|
end
|
|
|
|
|
|
|
|
class FilteredCompressHandler < Kemal::Handler
|
2018-09-17 23:39:28 +00:00
|
|
|
exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/api/*", "/ggpht/*"]
|
2018-08-04 20:30:44 +00:00
|
|
|
|
|
|
|
def call(env)
|
|
|
|
return call_next env if exclude_match? env
|
|
|
|
|
|
|
|
{% if flag?(:without_zlib) %}
|
|
|
|
call_next env
|
|
|
|
{% else %}
|
|
|
|
request_headers = env.request.headers
|
|
|
|
|
|
|
|
if request_headers.includes_word?("Accept-Encoding", "gzip")
|
|
|
|
env.response.headers["Content-Encoding"] = "gzip"
|
|
|
|
env.response.output = Gzip::Writer.new(env.response.output, sync_close: true)
|
|
|
|
elsif request_headers.includes_word?("Accept-Encoding", "deflate")
|
|
|
|
env.response.headers["Content-Encoding"] = "deflate"
|
|
|
|
env.response.output = Flate::Writer.new(env.response.output, sync_close: true)
|
|
|
|
end
|
|
|
|
|
|
|
|
call_next env
|
|
|
|
{% end %}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-02-03 04:48:47 +00:00
|
|
|
class APIHandler < Kemal::Handler
|
|
|
|
only ["/api/v1/*"]
|
|
|
|
|
|
|
|
def call(env)
|
|
|
|
return call_next env unless only_match? env
|
|
|
|
|
|
|
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
|
|
|
|
|
|
|
call_next env
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-09-06 02:06:30 +00:00
|
|
|
class DenyFrame < Kemal::Handler
|
|
|
|
exclude ["/embed/*"]
|
|
|
|
|
|
|
|
def call(env)
|
|
|
|
return call_next env if exclude_match? env
|
|
|
|
|
|
|
|
env.response.headers["X-Frame-Options"] = "sameorigin"
|
|
|
|
call_next env
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-08-04 20:30:44 +00:00
|
|
|
def rank_videos(db, n, filter, url)
|
|
|
|
top = [] of {Float64, String}
|
|
|
|
|
|
|
|
db.query("SELECT id, wilson_score, published FROM videos WHERE views > 5000 ORDER BY published DESC LIMIT 1000") do |rs|
|
|
|
|
rs.each do
|
|
|
|
id = rs.read(String)
|
|
|
|
wilson_score = rs.read(Float64)
|
|
|
|
published = rs.read(Time)
|
|
|
|
|
|
|
|
# Exponential decay, older videos tend to rank lower
|
|
|
|
temperature = wilson_score * Math.exp(-0.000005*((Time.now - published).total_minutes))
|
|
|
|
top << {temperature, id}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
top.sort!
|
|
|
|
|
|
|
|
# Make hottest come first
|
|
|
|
top.reverse!
|
|
|
|
top = top.map { |a, b| b }
|
|
|
|
|
|
|
|
if filter
|
|
|
|
language_list = [] of String
|
|
|
|
top.each do |id|
|
|
|
|
if language_list.size == n
|
|
|
|
break
|
|
|
|
else
|
|
|
|
client = make_client(url)
|
|
|
|
begin
|
|
|
|
video = get_video(id, db)
|
|
|
|
rescue ex
|
|
|
|
next
|
|
|
|
end
|
|
|
|
|
|
|
|
if video.language
|
|
|
|
language = video.language
|
|
|
|
else
|
|
|
|
description = XML.parse(video.description)
|
|
|
|
content = [video.title, description.content].join(" ")
|
|
|
|
content = content[0, 10000]
|
|
|
|
|
|
|
|
results = DetectLanguage.detect(content)
|
|
|
|
language = results[0].language
|
|
|
|
|
|
|
|
db.exec("UPDATE videos SET language = $1 WHERE id = $2", language, id)
|
|
|
|
end
|
|
|
|
|
|
|
|
if language == "en"
|
|
|
|
language_list << id
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
return language_list
|
|
|
|
else
|
|
|
|
return top[0..n - 1]
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def login_req(login_form, f_req)
|
|
|
|
data = {
|
|
|
|
"pstMsg" => "1",
|
|
|
|
"checkConnection" => "youtube",
|
|
|
|
"checkedDomains" => "youtube",
|
|
|
|
"hl" => "en",
|
|
|
|
"deviceinfo" => %q([null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]),
|
|
|
|
"f.req" => f_req,
|
|
|
|
"flowName" => "GlifWebSignIn",
|
|
|
|
"flowEntry" => "ServiceLogin",
|
|
|
|
}
|
|
|
|
|
|
|
|
data = login_form.merge(data)
|
|
|
|
|
|
|
|
return HTTP::Params.encode(data)
|
|
|
|
end
|
|
|
|
|
2018-09-04 13:52:30 +00:00
|
|
|
def html_to_content(description_html)
|
2018-08-10 13:38:31 +00:00
|
|
|
if !description_html
|
|
|
|
description = ""
|
|
|
|
description_html = ""
|
|
|
|
else
|
|
|
|
description_html = description_html.to_s
|
|
|
|
description = description_html.gsub("<br>", "\n")
|
|
|
|
description = description.gsub("<br/>", "\n")
|
2018-09-06 21:50:12 +00:00
|
|
|
|
|
|
|
if description.empty?
|
|
|
|
description = ""
|
|
|
|
else
|
|
|
|
description = XML.parse_html(description).content.strip("\n ")
|
|
|
|
end
|
2018-08-10 13:38:31 +00:00
|
|
|
end
|
|
|
|
|
2018-09-04 13:52:30 +00:00
|
|
|
return description_html, description
|
2018-08-10 13:38:31 +00:00
|
|
|
end
|
2018-08-10 14:44:19 +00:00
|
|
|
|
|
|
|
def extract_videos(nodeset, ucid = nil)
|
2018-09-20 14:36:09 +00:00
|
|
|
videos = extract_items(nodeset, ucid)
|
|
|
|
videos.select! { |item| !item.is_a?(SearchChannel | SearchPlaylist) }
|
|
|
|
videos.map { |video| video.as(SearchVideo) }
|
|
|
|
end
|
|
|
|
|
|
|
|
def extract_items(nodeset, ucid = nil)
|
2018-08-10 14:44:19 +00:00
|
|
|
# TODO: Make this a 'common', so it makes more sense to be used here
|
2018-09-20 14:36:09 +00:00
|
|
|
items = [] of SearchItem
|
2018-08-10 14:44:19 +00:00
|
|
|
|
|
|
|
nodeset.each do |node|
|
|
|
|
anchor = node.xpath_node(%q(.//h3[contains(@class,"yt-lockup-title")]/a))
|
|
|
|
if !anchor
|
|
|
|
next
|
|
|
|
end
|
|
|
|
|
|
|
|
if anchor["href"].starts_with? "https://www.googleadservices.com"
|
|
|
|
next
|
|
|
|
end
|
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
|
|
|
|
if !anchor
|
2018-08-10 14:44:19 +00:00
|
|
|
author = ""
|
|
|
|
author_id = ""
|
|
|
|
else
|
2018-09-22 15:49:42 +00:00
|
|
|
author = anchor.content.strip
|
2018-08-10 14:44:19 +00:00
|
|
|
author_id = anchor["href"].split("/")[-1]
|
|
|
|
end
|
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
|
|
|
|
if !anchor
|
2018-08-16 14:05:48 +00:00
|
|
|
next
|
2018-08-21 00:25:12 +00:00
|
|
|
end
|
2018-09-20 14:36:09 +00:00
|
|
|
title = anchor.content.strip
|
|
|
|
id = anchor["href"]
|
2018-08-10 14:44:19 +00:00
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
|
|
|
|
description_html, description = html_to_content(description_html)
|
2018-09-19 20:24:19 +00:00
|
|
|
|
2018-09-23 17:13:08 +00:00
|
|
|
tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")]))
|
|
|
|
if !tile
|
|
|
|
next
|
|
|
|
end
|
|
|
|
|
|
|
|
case tile["class"]
|
2018-09-20 14:36:09 +00:00
|
|
|
when .includes? "yt-lockup-playlist"
|
|
|
|
plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"]
|
2018-08-21 00:25:12 +00:00
|
|
|
|
2018-09-22 16:14:57 +00:00
|
|
|
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-meta")]/a))
|
2018-09-29 04:12:35 +00:00
|
|
|
|
2018-09-22 16:14:57 +00:00
|
|
|
if !anchor
|
|
|
|
anchor = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/a))
|
|
|
|
end
|
2018-09-29 04:12:35 +00:00
|
|
|
|
|
|
|
video_count = node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b))
|
|
|
|
if video_count
|
|
|
|
video_count = video_count.content
|
|
|
|
|
|
|
|
if video_count == "50+"
|
|
|
|
author = "YouTube"
|
|
|
|
author_id = "UC-9-kyTW8ZkZNDHQJ6FgpwQ"
|
|
|
|
video_count = video_count.rchop("+")
|
|
|
|
end
|
|
|
|
|
|
|
|
video_count = video_count.to_i?
|
2018-09-20 14:36:09 +00:00
|
|
|
end
|
|
|
|
video_count ||= 0
|
|
|
|
|
|
|
|
videos = [] of SearchPlaylistVideo
|
2018-09-22 15:49:42 +00:00
|
|
|
node.xpath_nodes(%q(.//*[contains(@class, "yt-lockup-playlist-items")]/li)).each do |video|
|
2018-09-20 14:36:09 +00:00
|
|
|
anchor = video.xpath_node(%q(.//a))
|
|
|
|
if anchor
|
2018-09-22 15:49:42 +00:00
|
|
|
video_title = anchor.content.strip
|
2018-09-20 14:36:09 +00:00
|
|
|
id = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)["v"]
|
|
|
|
end
|
|
|
|
video_title ||= ""
|
|
|
|
id ||= ""
|
2018-09-19 20:24:19 +00:00
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
anchor = video.xpath_node(%q(.//span/span))
|
|
|
|
if anchor
|
|
|
|
length_seconds = decode_length_seconds(anchor.content)
|
|
|
|
end
|
|
|
|
length_seconds ||= 0
|
2018-08-10 14:44:19 +00:00
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
videos << SearchPlaylistVideo.new(
|
|
|
|
video_title,
|
|
|
|
id,
|
|
|
|
length_seconds
|
|
|
|
)
|
|
|
|
end
|
2018-08-10 14:44:19 +00:00
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
items << SearchPlaylist.new(
|
|
|
|
title,
|
|
|
|
plid,
|
|
|
|
author,
|
|
|
|
author_id,
|
|
|
|
video_count,
|
|
|
|
videos
|
|
|
|
)
|
|
|
|
when .includes? "yt-lockup-channel"
|
2018-09-22 15:49:42 +00:00
|
|
|
author = title.strip
|
2018-11-28 16:20:52 +00:00
|
|
|
|
|
|
|
ucid = node.xpath_node(%q(.//button[contains(@class, "yt-uix-subscription-button")])).try &.["data-channel-external-id"]?
|
|
|
|
ucid ||= id.split("/")[-1]
|
2018-09-20 14:36:09 +00:00
|
|
|
|
|
|
|
author_thumbnail = node.xpath_node(%q(.//div/span/img)).try &.["data-thumb"]?
|
|
|
|
author_thumbnail ||= node.xpath_node(%q(.//div/span/img)).try &.["src"]
|
|
|
|
author_thumbnail ||= ""
|
|
|
|
|
|
|
|
subscriber_count = node.xpath_node(%q(.//span[contains(@class, "yt-subscriber-count")])).try &.["title"].delete(",").to_i?
|
|
|
|
subscriber_count ||= 0
|
|
|
|
|
|
|
|
video_count = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li)).try &.content.split(" ")[0].delete(",").to_i?
|
|
|
|
video_count ||= 0
|
|
|
|
|
|
|
|
items << SearchChannel.new(
|
2018-12-15 19:02:53 +00:00
|
|
|
author: author,
|
|
|
|
ucid: ucid,
|
|
|
|
author_thumbnail: author_thumbnail,
|
|
|
|
subscriber_count: subscriber_count,
|
|
|
|
video_count: video_count,
|
|
|
|
description: description,
|
|
|
|
description_html: description_html
|
2018-09-20 14:36:09 +00:00
|
|
|
)
|
2018-08-10 14:44:19 +00:00
|
|
|
else
|
2018-09-20 14:36:09 +00:00
|
|
|
id = id.lchop("/watch?v=")
|
2018-08-10 14:44:19 +00:00
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
metadata = node.xpath_nodes(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li))
|
|
|
|
|
|
|
|
begin
|
|
|
|
published = decode_date(metadata[0].content.lchop("Streamed ").lchop("Starts "))
|
|
|
|
rescue ex
|
|
|
|
end
|
|
|
|
begin
|
2018-11-04 15:37:12 +00:00
|
|
|
published ||= Time.unix(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
|
2018-09-20 14:36:09 +00:00
|
|
|
rescue ex
|
|
|
|
end
|
|
|
|
published ||= Time.now
|
|
|
|
|
|
|
|
begin
|
|
|
|
view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64?
|
|
|
|
rescue ex
|
|
|
|
end
|
|
|
|
begin
|
|
|
|
view_count ||= metadata.try &.[1].content.delete("No views,").try &.to_i64?
|
|
|
|
rescue ex
|
|
|
|
end
|
|
|
|
view_count ||= 0_i64
|
|
|
|
|
|
|
|
length_seconds = node.xpath_node(%q(.//span[@class="video-time"]))
|
|
|
|
if length_seconds
|
|
|
|
length_seconds = decode_length_seconds(length_seconds.content)
|
|
|
|
else
|
|
|
|
length_seconds = -1
|
|
|
|
end
|
|
|
|
|
|
|
|
live_now = node.xpath_node(%q(.//span[contains(@class, "yt-badge-live")]))
|
|
|
|
if live_now
|
|
|
|
live_now = true
|
|
|
|
else
|
|
|
|
live_now = false
|
|
|
|
end
|
|
|
|
|
2018-10-16 16:15:14 +00:00
|
|
|
if node.xpath_node(%q(.//span[text()="Premium"]))
|
|
|
|
premium = true
|
|
|
|
else
|
|
|
|
premium = false
|
|
|
|
end
|
|
|
|
|
2019-01-03 02:09:00 +00:00
|
|
|
if !premium || node.xpath_node(%q(.//span[contains(text(), "Free episode")]))
|
2018-10-16 16:15:14 +00:00
|
|
|
paid = false
|
2019-01-03 02:09:00 +00:00
|
|
|
else
|
|
|
|
paid = true
|
2018-10-16 16:15:14 +00:00
|
|
|
end
|
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
items << SearchVideo.new(
|
2018-12-15 19:02:53 +00:00
|
|
|
title: title,
|
|
|
|
id: id,
|
|
|
|
author: author,
|
|
|
|
ucid: author_id,
|
|
|
|
published: published,
|
|
|
|
views: view_count,
|
|
|
|
description: description,
|
|
|
|
description_html: description_html,
|
|
|
|
length_seconds: length_seconds,
|
|
|
|
live_now: live_now,
|
|
|
|
paid: paid,
|
|
|
|
premium: premium
|
2018-09-20 14:36:09 +00:00
|
|
|
)
|
|
|
|
end
|
2018-08-10 14:44:19 +00:00
|
|
|
end
|
|
|
|
|
2018-09-20 14:36:09 +00:00
|
|
|
return items
|
2018-08-10 14:44:19 +00:00
|
|
|
end
|