forked from midou/invidious
Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
35bee987f6 | |||
bd5ec2f2f3 | |||
296771809a | |||
83ba4e2a4c | |||
6cb834a18d | |||
0a4e9e6252 | |||
9619d3f1bc | |||
f39ed3d145 | |||
f38aac851e | |||
b6adeb80e6 | |||
c74cc1123f | |||
0e1b5d7cdd | |||
d2bbf9d33c | |||
3ccee120d3 | |||
6753294ee1 | |||
f9881ebaab | |||
429a4b2dec | |||
4287c0d96a | |||
5cd137d808 | |||
62ae836565 | |||
b7acdfad24 | |||
d3eadccd51 | |||
2232bc0495 | |||
f7ca81c384 | |||
d4ee786cab | |||
a54668688b |
@ -173,7 +173,7 @@ div {
|
||||
|
||||
/* ProgressBar marker */
|
||||
.vjs-marker {
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* Big "Play" Button */
|
||||
@ -196,3 +196,23 @@ div {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.video-js .vjs-poster {
|
||||
background-size: cover;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#player {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#player-container {
|
||||
position: relative;
|
||||
padding-bottom: 56.25%;
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
height: 0;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* videojs-share
|
||||
* @version 1.1.0
|
||||
* @version 2.0.1
|
||||
* @copyright 2018 Mikhail Khazov <mkhazov.work@gmail.com>
|
||||
* @license MIT
|
||||
*/
|
||||
|
220
src/invidious.cr
220
src/invidious.cr
@ -264,8 +264,7 @@ get "/watch" do |env|
|
||||
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
|
||||
end
|
||||
|
||||
# TODO: Find highest resolution thumbnail automatically
|
||||
thumbnail = "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg"
|
||||
thumbnail = "/vi/#{video.id}/maxres.jpg"
|
||||
|
||||
if params[:raw]
|
||||
url = fmt_stream[0]["url"]
|
||||
@ -364,8 +363,7 @@ get "/embed/:id" do |env|
|
||||
hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", host_url)
|
||||
end
|
||||
|
||||
# TODO: Find highest resolution thumbnail automatically
|
||||
thumbnail = "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg"
|
||||
thumbnail = "/vi/#{video.id}/maxres.jpg"
|
||||
|
||||
if params[:raw]
|
||||
url = fmt_stream[0]["url"]
|
||||
@ -432,32 +430,64 @@ get "/search" do |env|
|
||||
page = env.params.query["page"]?.try &.to_i?
|
||||
page ||= 1
|
||||
|
||||
sort = "relevance"
|
||||
user = env.get? "user"
|
||||
if user
|
||||
user = user.as(User)
|
||||
ucids = user.subscriptions
|
||||
end
|
||||
ucids ||= [] of String
|
||||
|
||||
channel = nil
|
||||
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.split(":")
|
||||
|
||||
case key
|
||||
when "sort"
|
||||
sort = value
|
||||
when "channel", "user"
|
||||
channel = value
|
||||
when "date"
|
||||
date = value
|
||||
when "duration"
|
||||
duration = value
|
||||
when "features"
|
||||
when "feature", "features"
|
||||
features = value.split(",")
|
||||
when "sort"
|
||||
sort = value
|
||||
when "subscriptions"
|
||||
subscriptions = value == "true"
|
||||
end
|
||||
end
|
||||
|
||||
search_query = (query.split(" ") - operators).join(" ")
|
||||
|
||||
search_params = build_search_params(sort: sort, date: date, content_type: "video",
|
||||
duration: duration, features: features)
|
||||
count, videos = search(search_query, page, search_params).as(Tuple)
|
||||
if channel
|
||||
count, videos = channel_search(search_query, page, channel)
|
||||
elsif subscriptions
|
||||
videos = PG_DB.query_all("SELECT id,title,published,updated,ucid,author FROM (
|
||||
SELECT *,
|
||||
to_tsvector(channel_videos.title) ||
|
||||
to_tsvector(channel_videos.author)
|
||||
as document
|
||||
FROM channel_videos WHERE ucid IN (#{arg_array(ucids, 3)})
|
||||
) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", [search_query, (page - 1) * 20] + ucids, as: ChannelVideo)
|
||||
count = videos.size
|
||||
else
|
||||
begin
|
||||
search_params = produce_search_params(sort: sort, date: date, content_type: "video",
|
||||
duration: duration, features: features)
|
||||
rescue ex
|
||||
error_message = ex.message
|
||||
next templated "error"
|
||||
end
|
||||
|
||||
count, videos = search(search_query, page, search_params).as(Tuple)
|
||||
end
|
||||
|
||||
templated "search"
|
||||
end
|
||||
@ -1471,7 +1501,7 @@ get "/feed/channel/:ucid" do |env|
|
||||
|
||||
xml.element("media:group") do
|
||||
xml.element("media:title") { xml.text video.title }
|
||||
xml.element("media:thumbnail", url: "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg",
|
||||
xml.element("media:thumbnail", url: "/vi/#{video.id}/mqdefault.jpg",
|
||||
width: "320", height: "180")
|
||||
xml.element("media:description") { xml.text video.description }
|
||||
end
|
||||
@ -1576,7 +1606,7 @@ get "/feed/private" do |env|
|
||||
|
||||
xml.element("media:group") do
|
||||
xml.element("media:title") { xml.text video.title }
|
||||
xml.element("media:thumbnail", url: "https://i.ytimg.com/vi/#{video.id}/mqdefault.jpg",
|
||||
xml.element("media:thumbnail", url: "/vi/#{video.id}/mqdefault.jpg",
|
||||
width: "320", height: "180")
|
||||
end
|
||||
end
|
||||
@ -1588,6 +1618,38 @@ get "/feed/private" do |env|
|
||||
feed
|
||||
end
|
||||
|
||||
get "/feed/playlist/:plid" do |env|
|
||||
plid = env.params.url["plid"]
|
||||
|
||||
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"]?)
|
||||
path = env.request.path
|
||||
|
||||
client = make_client(YT_URL)
|
||||
response = client.get("/feeds/videos.xml?playlist_id=#{plid}")
|
||||
document = XML.parse(response.body)
|
||||
|
||||
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
|
||||
node.attributes.each do |attribute|
|
||||
case attribute.name
|
||||
when "url"
|
||||
node["url"] = "#{host_url}#{URI.parse(node["url"]).full_path}"
|
||||
when "href"
|
||||
node["href"] = "#{host_url}#{URI.parse(node["href"]).full_path}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
document = document.to_xml(options: XML::SaveOptions::NO_DECL)
|
||||
|
||||
document.scan(/<uri>(?<url>[^<]+)<\/uri>/).each do |match|
|
||||
content = "#{host_url}#{URI.parse(match["url"]).full_path}"
|
||||
document = document.gsub(match[0], "<uri>#{content}</uri>")
|
||||
end
|
||||
|
||||
env.response.content_type = "text/xml"
|
||||
document
|
||||
end
|
||||
|
||||
# Channels
|
||||
|
||||
# YouTube appears to let users set a "brand" URL that
|
||||
@ -1666,6 +1728,14 @@ get "/channel/:ucid" do |env|
|
||||
auto_generated = true
|
||||
end
|
||||
|
||||
if !auto_generated
|
||||
if author.includes? " "
|
||||
env.set "search", "channel:#{ucid} "
|
||||
else
|
||||
env.set "search", "channel:#{author.downcase} "
|
||||
end
|
||||
end
|
||||
|
||||
videos = [] of SearchVideo
|
||||
2.times do |i|
|
||||
url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated)
|
||||
@ -1803,7 +1873,7 @@ get "/api/v1/comments/:id" do |env|
|
||||
if source == "youtube"
|
||||
client = make_client(YT_URL)
|
||||
headers = HTTP::Headers.new
|
||||
html = client.get("/watch?v=#{id}&disable_polymer=1")
|
||||
html = client.get("/watch?v=#{id}&bpctr=#{Time.new.epoch + 2000}&disable_polymer=1")
|
||||
|
||||
headers["cookie"] = html.cookies.add_request_headers(headers)["cookie"]
|
||||
headers["content-type"] = "application/x-www-form-urlencoded"
|
||||
@ -1918,11 +1988,11 @@ get "/api/v1/comments/:id" do |env|
|
||||
url = URI.parse(url)
|
||||
|
||||
if {"m.youtube.com", "www.youtube.com", "youtu.be"}.includes? url.host
|
||||
if url.path == "/redirect"
|
||||
url = HTTP::Params.parse(url.query.not_nil!)["q"]
|
||||
if url.path == "/redirect"
|
||||
url = HTTP::Params.parse(url.query.not_nil!)["q"]
|
||||
else
|
||||
url = url.full_path
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
url = run["navigationEndpoint"]["commandMetadata"]?.try &.["webCommandMetadata"]["url"].as_s
|
||||
@ -1965,6 +2035,7 @@ get "/api/v1/comments/:id" do |env|
|
||||
json.field "content", content
|
||||
json.field "contentHtml", content_html
|
||||
json.field "published", published.epoch
|
||||
json.field "publishedText", "#{recode_date(published)} ago"
|
||||
json.field "likeCount", node_comment["likeCount"]
|
||||
json.field "commentId", node_comment["commentId"]
|
||||
|
||||
@ -2083,6 +2154,7 @@ get "/api/v1/videos/:id" do |env|
|
||||
json.field "description", description
|
||||
json.field "descriptionHtml", video.description
|
||||
json.field "published", video.published.epoch
|
||||
json.field "publishedText", "#{recode_date(video.published)} ago"
|
||||
json.field "keywords" do
|
||||
json.array do
|
||||
video.info["keywords"].split(",").each { |keyword| json.string keyword }
|
||||
@ -2260,6 +2332,7 @@ get "/api/v1/trending" do |env|
|
||||
json.field "authorUrl", "/channel/#{video.ucid}"
|
||||
|
||||
json.field "published", video.published.epoch
|
||||
json.field "publishedText", "#{recode_date(video.published)} ago"
|
||||
json.field "description", video.description
|
||||
json.field "descriptionHtml", video.description_html
|
||||
end
|
||||
@ -2288,6 +2361,7 @@ get "/api/v1/top" do |env|
|
||||
json.field "author", video.author
|
||||
json.field "authorUrl", "/channel/#{video.ucid}"
|
||||
json.field "published", video.published.epoch
|
||||
json.field "publishedText", "#{recode_date(video.published)} ago"
|
||||
|
||||
description = video.description.gsub("<br>", "\n")
|
||||
description = description.gsub("<br/>", "\n")
|
||||
@ -2471,6 +2545,7 @@ get "/api/v1/channels/:ucid" do |env|
|
||||
|
||||
json.field "viewCount", video.views
|
||||
json.field "published", video.published.epoch
|
||||
json.field "publishedText", "#{recode_date(video.published)} ago"
|
||||
json.field "lengthSeconds", video.length_seconds
|
||||
end
|
||||
end
|
||||
@ -2570,6 +2645,7 @@ get "/api/v1/channels/:ucid/videos" do |env|
|
||||
|
||||
json.field "viewCount", video.views
|
||||
json.field "published", video.published.epoch
|
||||
json.field "publishedText", "#{recode_date(video.published)} ago"
|
||||
json.field "lengthSeconds", video.length_seconds
|
||||
end
|
||||
end
|
||||
@ -2605,7 +2681,7 @@ get "/api/v1/search" do |env|
|
||||
env.response.content_type = "application/json"
|
||||
|
||||
begin
|
||||
search_params = build_search_params(sort_by, date, content_type, duration, features)
|
||||
search_params = produce_search_params(sort_by, date, content_type, duration, features)
|
||||
rescue ex
|
||||
next JSON.build do |json|
|
||||
json.object do
|
||||
@ -2635,6 +2711,7 @@ get "/api/v1/search" do |env|
|
||||
|
||||
json.field "viewCount", video.views
|
||||
json.field "published", video.published.epoch
|
||||
json.field "publishedText", "#{recode_date(video.published)} ago"
|
||||
json.field "lengthSeconds", video.length_seconds
|
||||
end
|
||||
end
|
||||
@ -2947,6 +3024,113 @@ get "/videoplayback" do |env|
|
||||
end
|
||||
end
|
||||
|
||||
get "/ggpht*" do |env|
|
||||
end
|
||||
|
||||
get "/ggpht/*" do |env|
|
||||
host = "https://yt3.ggpht.com"
|
||||
client = make_client(URI.parse(host))
|
||||
url = env.request.path.lchop("/ggpht")
|
||||
|
||||
headers = env.request.headers
|
||||
headers.delete("Host")
|
||||
headers.delete("Cookie")
|
||||
headers.delete("User-Agent")
|
||||
headers.delete("Referer")
|
||||
|
||||
client.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
|
||||
if response.status_code == 304
|
||||
break
|
||||
end
|
||||
|
||||
chunk_size = 4096
|
||||
size = 1
|
||||
if response.headers.includes_word?("Content-Encoding", "gzip")
|
||||
Gzip::Writer.open(env.response) do |deflate|
|
||||
until size == 0
|
||||
size = IO.copy(response.body_io, deflate)
|
||||
env.response.flush
|
||||
end
|
||||
end
|
||||
elsif response.headers.includes_word?("Content-Encoding", "deflate")
|
||||
Flate::Writer.open(env.response) do |deflate|
|
||||
until size == 0
|
||||
size = IO.copy(response.body_io, deflate)
|
||||
env.response.flush
|
||||
end
|
||||
end
|
||||
else
|
||||
until size == 0
|
||||
size = IO.copy(response.body_io, env.response, chunk_size)
|
||||
env.response.flush
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
get "/vi/:id/:name" do |env|
|
||||
id = env.params.url["id"]
|
||||
name = env.params.url["name"]
|
||||
|
||||
host = "https://i.ytimg.com"
|
||||
client = make_client(URI.parse(host))
|
||||
|
||||
if name == "maxres.jpg"
|
||||
VIDEO_THUMBNAILS.each do |thumb|
|
||||
if client.head("/vi/#{id}/#{thumb[:url]}.jpg").status_code == 200
|
||||
name = thumb[:url] + ".jpg"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
url = "/vi/#{id}/#{name}"
|
||||
|
||||
headers = env.request.headers
|
||||
headers.delete("Host")
|
||||
headers.delete("Cookie")
|
||||
headers.delete("User-Agent")
|
||||
headers.delete("Referer")
|
||||
|
||||
client.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
|
||||
if response.status_code == 304
|
||||
break
|
||||
end
|
||||
|
||||
chunk_size = 4096
|
||||
size = 1
|
||||
if response.headers.includes_word?("Content-Encoding", "gzip")
|
||||
Gzip::Writer.open(env.response) do |deflate|
|
||||
until size == 0
|
||||
size = IO.copy(response.body_io, deflate)
|
||||
env.response.flush
|
||||
end
|
||||
end
|
||||
elsif response.headers.includes_word?("Content-Encoding", "deflate")
|
||||
Flate::Writer.open(env.response) do |deflate|
|
||||
until size == 0
|
||||
size = IO.copy(response.body_io, deflate)
|
||||
env.response.flush
|
||||
end
|
||||
end
|
||||
else
|
||||
until size == 0
|
||||
size = IO.copy(response.body_io, env.response, chunk_size)
|
||||
env.response.flush
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
error 404 do |env|
|
||||
error_message = "404 Page not found"
|
||||
templated "error"
|
||||
|
@ -48,6 +48,13 @@ def fetch_channel(ucid, client, db, pull_all_videos = true)
|
||||
end
|
||||
author = author.content
|
||||
|
||||
# Auto-generated channels
|
||||
# https://support.google.com/youtube/answer/2579942
|
||||
if author.ends_with?(" - Topic") ||
|
||||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
|
||||
auto_generated = true
|
||||
end
|
||||
|
||||
if !pull_all_videos
|
||||
rss.xpath_nodes("//feed/entry").each do |entry|
|
||||
video_id = entry.xpath_node("videoid").not_nil!.content
|
||||
@ -69,61 +76,52 @@ def fetch_channel(ucid, client, db, pull_all_videos = true)
|
||||
updated = $4, ucid = $5, author = $6", video_array)
|
||||
end
|
||||
else
|
||||
videos = [] of ChannelVideo
|
||||
page = 1
|
||||
ids = [] of String
|
||||
|
||||
loop do
|
||||
url = produce_channel_videos_url(ucid, page)
|
||||
url = produce_channel_videos_url(ucid, page, auto_generated: auto_generated)
|
||||
response = client.get(url)
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
content_html = json["content_html"].as_s
|
||||
if content_html.empty?
|
||||
# If we don't get anything, move on
|
||||
|
||||
if json["content_html"]? && !json["content_html"].as_s.empty?
|
||||
document = XML.parse_html(json["content_html"].as_s)
|
||||
nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
|
||||
else
|
||||
break
|
||||
end
|
||||
document = XML.parse_html(content_html)
|
||||
|
||||
document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")])).each do |item|
|
||||
anchor = item.xpath_node(%q(.//h3[contains(@class,"yt-lockup-title")]/a))
|
||||
if !anchor
|
||||
raise "could not find anchor"
|
||||
end
|
||||
|
||||
title = anchor.content.strip
|
||||
video_id = anchor["href"].lchop("/watch?v=")
|
||||
|
||||
published = item.xpath_node(%q(.//div[@class="yt-lockup-meta"]/ul/li[1]))
|
||||
if !published
|
||||
# This happens on Youtube red videos, here we just skip them
|
||||
next
|
||||
end
|
||||
published = published.content
|
||||
published = decode_date(published)
|
||||
|
||||
videos << ChannelVideo.new(video_id, title, published, Time.now, ucid, author)
|
||||
if auto_generated
|
||||
videos = extract_videos(nodeset)
|
||||
else
|
||||
videos = extract_videos(nodeset, ucid)
|
||||
videos.each { |video| video.ucid = ucid }
|
||||
videos.each { |video| video.author = author }
|
||||
end
|
||||
|
||||
if document.xpath_nodes(%q(//li[contains(@class, "channels-content-item")])).size < 30
|
||||
count = nodeset.size
|
||||
videos = videos.map { |video| ChannelVideo.new(video.id, video.title, video.published, Time.now, video.ucid, video.author) }
|
||||
|
||||
videos.each do |video|
|
||||
ids << video.id
|
||||
db.exec("UPDATE users SET notifications = notifications || $1 \
|
||||
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, video.ucid)
|
||||
|
||||
video_array = video.to_a
|
||||
args = arg_array(video_array)
|
||||
db.exec("INSERT INTO channel_videos VALUES (#{args}) ON CONFLICT (id) DO UPDATE SET title = $2, \
|
||||
published = $3, updated = $4, ucid = $5, author = $6", video_array)
|
||||
end
|
||||
|
||||
if count < 30
|
||||
break
|
||||
end
|
||||
|
||||
page += 1
|
||||
end
|
||||
|
||||
video_ids = [] of String
|
||||
videos.each do |video|
|
||||
db.exec("UPDATE users SET notifications = notifications || $1 \
|
||||
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, ucid)
|
||||
video_ids << video.id
|
||||
|
||||
video_array = video.to_a
|
||||
args = arg_array(video_array)
|
||||
db.exec("INSERT INTO channel_videos VALUES (#{args}) ON CONFLICT (id) DO NOTHING", video_array)
|
||||
end
|
||||
|
||||
# When a video is deleted from a channel, we find and remove it here
|
||||
db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{video_ids.map { |a| %("#{a}") }.join(",")}}') AND ucid = $1", ucid)
|
||||
db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid)
|
||||
end
|
||||
|
||||
channel = InvidiousChannel.new(ucid, author, Time.now)
|
||||
@ -147,10 +145,16 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil)
|
||||
switch = "\x00"
|
||||
end
|
||||
|
||||
meta = "\x12\x06videos #{switch}\x30\x02\x38\x01\x60\x01\x6a\x00\x7a"
|
||||
meta = "\x12\x06videos"
|
||||
meta += "\x30\x02"
|
||||
meta += "\x38\x01"
|
||||
meta += "\x60\x01"
|
||||
meta += "\x6a\x00"
|
||||
meta += "\xb8\x01\x00"
|
||||
meta += "\x20#{switch}"
|
||||
meta += "\x7a"
|
||||
meta += page.size.to_u8.unsafe_chr
|
||||
meta += page
|
||||
meta += "\xb8\x01\x00"
|
||||
|
||||
meta = Base64.urlsafe_encode(meta)
|
||||
meta = URI.escape(meta)
|
||||
|
@ -100,10 +100,12 @@ def template_youtube_comments(comments)
|
||||
END_HTML
|
||||
end
|
||||
|
||||
author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).full_path}"
|
||||
|
||||
html += <<-END_HTML
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-2-24">
|
||||
<img style="width:90%; padding-right:1em; padding-top:1em;" src="#{child["authorThumbnails"][-1]["url"]}">
|
||||
<img style="width:90%; padding-right:1em; padding-top:1em;" src="#{author_thumbnail}">
|
||||
</div>
|
||||
<div class="pure-u-22-24">
|
||||
<p>
|
||||
|
@ -18,7 +18,7 @@ class Config
|
||||
end
|
||||
|
||||
class FilteredCompressHandler < Kemal::Handler
|
||||
exclude ["/videoplayback", "/videoplayback/*", "/api/*"]
|
||||
exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/api/*", "/ggpht/*"]
|
||||
|
||||
def call(env)
|
||||
return call_next env if exclude_match? env
|
||||
|
@ -141,8 +141,7 @@ end
|
||||
def update_decrypt_function
|
||||
loop do
|
||||
begin
|
||||
client = make_client(YT_URL)
|
||||
decrypt_function = fetch_decrypt_function(client)
|
||||
decrypt_function = fetch_decrypt_function
|
||||
rescue ex
|
||||
next
|
||||
end
|
||||
|
@ -88,28 +88,29 @@ def produce_playlist_url(id, index)
|
||||
end
|
||||
ucid = "VL" + id
|
||||
|
||||
continuation = [0x08_u8] + write_var_int(index)
|
||||
slice = continuation.to_unsafe.to_slice(continuation.size)
|
||||
slice = Base64.urlsafe_encode(slice, false)
|
||||
meta = "\x08#{write_var_int(index).join}"
|
||||
meta = Base64.urlsafe_encode(meta, false)
|
||||
meta = "PT:#{meta}"
|
||||
|
||||
# Inner Base64
|
||||
continuation = "PT:" + slice
|
||||
continuation = [0x7a_u8, continuation.bytes.size.to_u8] + continuation.bytes
|
||||
slice = continuation.to_unsafe.to_slice(continuation.size)
|
||||
slice = Base64.urlsafe_encode(slice)
|
||||
slice = URI.escape(slice)
|
||||
wrapped = "\x7a"
|
||||
wrapped += meta.bytes.size.unsafe_chr
|
||||
wrapped += meta
|
||||
|
||||
# Outer Base64
|
||||
continuation = [0x1a_u8, slice.bytes.size.to_u8] + slice.bytes
|
||||
continuation = ucid.bytes + continuation
|
||||
continuation = [0x12_u8, ucid.size.to_u8] + continuation
|
||||
continuation = [0xe2_u8, 0xa9_u8, 0x85_u8, 0xb2_u8, 2_u8, continuation.size.to_u8] + continuation
|
||||
wrapped = Base64.urlsafe_encode(wrapped)
|
||||
meta = URI.escape(wrapped)
|
||||
|
||||
# Wrap bytes
|
||||
slice = continuation.to_unsafe.to_slice(continuation.size)
|
||||
slice = Base64.urlsafe_encode(slice)
|
||||
slice = URI.escape(slice)
|
||||
continuation = slice
|
||||
continuation = "\x12"
|
||||
continuation += ucid.size.unsafe_chr
|
||||
continuation += ucid
|
||||
continuation += "\x1a"
|
||||
continuation += meta.bytes.size.unsafe_chr
|
||||
continuation += meta
|
||||
|
||||
continuation = continuation.size.to_u8.unsafe_chr + continuation
|
||||
continuation = "\xe2\xa9\x85\xb2\x02" + continuation
|
||||
|
||||
continuation = Base64.urlsafe_encode(continuation)
|
||||
continuation = URI.escape(continuation)
|
||||
|
||||
url = "/browse_ajax?action_continuation=1&continuation=#{continuation}"
|
||||
|
||||
@ -119,13 +120,18 @@ end
|
||||
def fetch_playlist(plid)
|
||||
client = make_client(YT_URL)
|
||||
response = client.get("/playlist?list=#{plid}&disable_polymer=1")
|
||||
document = XML.parse_html(response.body)
|
||||
body = response.body.gsub(<<-END_BUTTON
|
||||
<button class="yt-uix-button yt-uix-button-size-default yt-uix-button-link yt-uix-expander-head playlist-description-expander yt-uix-inlineedit-ignore-edit" type="button" onclick=";return false;"><span class="yt-uix-button-content"> less <img alt="" src="/yts/img/pixel-vfl3z5WfW.gif">
|
||||
</span></button>
|
||||
END_BUTTON
|
||||
, "")
|
||||
document = XML.parse_html(body)
|
||||
|
||||
title = document.xpath_node(%q(//h1[@class="pl-header-title"])).not_nil!.content
|
||||
title = title.strip(" \n")
|
||||
|
||||
description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1]))
|
||||
description, description_html = html_to_content(description_html)
|
||||
description_html, description = html_to_content(description_html)
|
||||
|
||||
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"])).not_nil!
|
||||
author = anchor.xpath_node(%q(.//li[1]/a)).not_nil!.content
|
||||
|
@ -12,7 +12,44 @@ class SearchVideo
|
||||
})
|
||||
end
|
||||
|
||||
def search(query, page = 1, search_params = build_search_params(content_type: "video"))
|
||||
def channel_search(query, page, channel)
|
||||
client = make_client(YT_URL)
|
||||
|
||||
response = client.get("/user/#{channel}")
|
||||
document = XML.parse_html(response.body)
|
||||
canonical = document.xpath_node(%q(//link[@rel="canonical"]))
|
||||
|
||||
if !canonical
|
||||
response = client.get("/channel/#{channel}")
|
||||
document = XML.parse_html(response.body)
|
||||
canonical = document.xpath_node(%q(//link[@rel="canonical"]))
|
||||
end
|
||||
|
||||
if !canonical
|
||||
return 0, [] of SearchVideo
|
||||
end
|
||||
|
||||
ucid = canonical["href"].split("/")[-1]
|
||||
|
||||
url = produce_channel_search_url(ucid, query, page)
|
||||
response = client.get(url)
|
||||
json = JSON.parse(response.body)
|
||||
|
||||
if json["content_html"]? && !json["content_html"].as_s.empty?
|
||||
document = XML.parse_html(json["content_html"].as_s)
|
||||
nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
|
||||
|
||||
count = nodeset.size
|
||||
videos = extract_videos(nodeset)
|
||||
else
|
||||
count = 0
|
||||
videos = [] of SearchVideo
|
||||
end
|
||||
|
||||
return count, videos
|
||||
end
|
||||
|
||||
def search(query, page = 1, search_params = produce_search_params(content_type: "video"))
|
||||
client = make_client(YT_URL)
|
||||
if query.empty?
|
||||
return {0, [] of SearchVideo}
|
||||
@ -30,8 +67,8 @@ def search(query, page = 1, search_params = build_search_params(content_type: "v
|
||||
return {nodeset.size, videos}
|
||||
end
|
||||
|
||||
def build_search_params(sort : String = "relevance", date : String = "", content_type : String = "",
|
||||
duration : String = "", features : Array(String) = [] of String)
|
||||
def produce_search_params(sort : String = "relevance", date : String = "", content_type : String = "",
|
||||
duration : String = "", features : Array(String) = [] of String)
|
||||
head = "\x08"
|
||||
head += case sort
|
||||
when "relevance"
|
||||
@ -114,7 +151,7 @@ def build_search_params(sort : String = "relevance", date : String = "", content
|
||||
end
|
||||
|
||||
if body.size > 0
|
||||
token = head + "\x12" + body.size.to_u8.unsafe_chr + body
|
||||
token = head + "\x12" + body.size.unsafe_chr + body
|
||||
else
|
||||
token = head
|
||||
end
|
||||
@ -124,3 +161,40 @@ def build_search_params(sort : String = "relevance", date : String = "", content
|
||||
|
||||
return token
|
||||
end
|
||||
|
||||
def produce_channel_search_url(ucid, query, page)
|
||||
page = "#{page}"
|
||||
|
||||
meta = "\x12\x06search"
|
||||
meta += "\x30\x02"
|
||||
meta += "\x38\x01"
|
||||
meta += "\x60\x01"
|
||||
meta += "\x6a\x00"
|
||||
meta += "\xb8\x01\x00"
|
||||
meta += "\x7a"
|
||||
meta += page.size.unsafe_chr
|
||||
meta += page
|
||||
|
||||
meta = Base64.urlsafe_encode(meta)
|
||||
meta = URI.escape(meta)
|
||||
|
||||
continuation = "\x12"
|
||||
continuation += ucid.size.unsafe_chr
|
||||
continuation += ucid
|
||||
continuation += "\x1a"
|
||||
continuation += meta.size.unsafe_chr
|
||||
continuation += meta
|
||||
continuation += "\x5a"
|
||||
continuation += query.size.unsafe_chr
|
||||
continuation += query
|
||||
|
||||
continuation = continuation.size.unsafe_chr + continuation
|
||||
continuation = "\xe2\xa9\x85\xb2\x02" + continuation
|
||||
|
||||
continuation = Base64.urlsafe_encode(continuation)
|
||||
continuation = URI.escape(continuation)
|
||||
|
||||
url = "/browse_ajax?continuation=#{continuation}"
|
||||
|
||||
return url
|
||||
end
|
||||
|
@ -1,4 +1,5 @@
|
||||
def fetch_decrypt_function(client, id = "CvFH_6DNRCY")
|
||||
def fetch_decrypt_function(id = "CvFH_6DNRCY")
|
||||
client = make_client(YT_URL)
|
||||
document = client.get("/watch?v=#{id}").body
|
||||
url = document.match(/src="(?<url>\/yts\/jsbin\/player-.{9}\/en_US\/base.js)"/).not_nil!["url"]
|
||||
player = client.get(url).body
|
||||
|
@ -112,11 +112,11 @@ REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "A
|
||||
BYPASS_REGIONS = {"CA", "DE", "FR", "JP", "RU", "UK"}
|
||||
|
||||
VIDEO_THUMBNAILS = {
|
||||
{name: "default", url: "default", height: 90, width: 120},
|
||||
{name: "medium", url: "mqdefault", height: 180, width: 320},
|
||||
{name: "high", url: "hqdefault", height: 360, width: 480},
|
||||
{name: "maxresdefault", url: "maxresdefault", height: 720, width: 1280},
|
||||
{name: "sddefault", url: "sddefault", height: 480, width: 640},
|
||||
{name: "maxresdefault", url: "maxresdefault", height: 1280, width: 720},
|
||||
{name: "high", url: "hqdefault", height: 360, width: 480},
|
||||
{name: "medium", url: "mqdefault", height: 180, width: 320},
|
||||
{name: "default", url: "default", height: 90, width: 120},
|
||||
{name: "start", url: "1", height: 90, width: 120},
|
||||
{name: "middle", url: "2", height: 90, width: 120},
|
||||
{name: "end", url: "3", height: 90, width: 120},
|
||||
@ -258,10 +258,82 @@ class Video
|
||||
|
||||
def adaptive_fmts(decrypt_function)
|
||||
adaptive_fmts = [] of HTTP::Params
|
||||
|
||||
if self.info.has_key?("adaptive_fmts")
|
||||
self.info["adaptive_fmts"].split(",") do |string|
|
||||
adaptive_fmts << HTTP::Params.parse(string)
|
||||
end
|
||||
elsif self.info.has_key?("dashmpd")
|
||||
client = make_client(YT_URL)
|
||||
response = client.get(self.info["dashmpd"])
|
||||
document = XML.parse_html(response.body)
|
||||
|
||||
document.xpath_nodes(%q(//adaptationset)).each do |adaptation_set|
|
||||
mime_type = adaptation_set["mimetype"]
|
||||
|
||||
document.xpath_nodes(%q(.//representation)).each do |representation|
|
||||
codecs = representation["codecs"]
|
||||
itag = representation["id"]
|
||||
bandwidth = representation["bandwidth"]
|
||||
url = representation.xpath_node(%q(.//baseurl)).not_nil!.content
|
||||
|
||||
clen = url.match(/clen\/(?<clen>\d+)/).try &.["clen"]
|
||||
clen ||= "0"
|
||||
lmt = url.match(/lmt\/(?<lmt>\d+)/).try &.["lmt"]
|
||||
lmt ||= "#{((Time.now + 1.hour).epoch_f.to_f64 * 1000000).to_i64}"
|
||||
|
||||
segment_list = representation.xpath_node(%q(.//segmentlist)).not_nil!
|
||||
init = segment_list.xpath_node(%q(.//initialization))
|
||||
|
||||
# TODO: Replace with sane defaults when byteranges are absent
|
||||
if init && !init["sourceurl"].starts_with? "sq"
|
||||
init = init["sourceurl"].lchop("range/")
|
||||
|
||||
index = segment_list.xpath_node(%q(.//segmenturl)).not_nil!["media"]
|
||||
index = index.lchop("range/")
|
||||
index = "#{init.split("-")[1].to_i + 1}-#{index.split("-")[0].to_i}"
|
||||
else
|
||||
init = "0-0"
|
||||
index = "1-1"
|
||||
end
|
||||
|
||||
params = {
|
||||
"type" => ["#{mime_type}; codecs=\"#{codecs}\""],
|
||||
"url" => [url],
|
||||
"projection_type" => ["1"],
|
||||
"index" => [index],
|
||||
"init" => [init],
|
||||
"xtags" => [] of String,
|
||||
"lmt" => [lmt],
|
||||
"clen" => [clen],
|
||||
"bitrate" => [bandwidth],
|
||||
"itag" => [itag],
|
||||
}
|
||||
|
||||
if mime_type == "video/mp4"
|
||||
width = representation["width"]?
|
||||
height = representation["height"]?
|
||||
fps = representation["framerate"]?
|
||||
|
||||
metadata = itag_to_metadata?(itag)
|
||||
if metadata
|
||||
width ||= metadata["width"]?
|
||||
height ||= metadata["height"]?
|
||||
fps ||= metadata["fps"]?
|
||||
end
|
||||
|
||||
if width && height
|
||||
params["size"] = ["#{width}x#{height}"]
|
||||
end
|
||||
|
||||
if width
|
||||
params["quality_label"] = ["#{height}p"]
|
||||
end
|
||||
end
|
||||
|
||||
adaptive_fmts << HTTP::Params.new(params)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if adaptive_fmts[0]? && adaptive_fmts[0]["s"]?
|
||||
|
@ -63,8 +63,8 @@ var shareOptions = {
|
||||
title: "<%= video.title.dump_unquoted %>",
|
||||
description: "<%= description %>",
|
||||
image: "<%= thumbnail %>",
|
||||
embedCode: `<iframe id='ivplayer' type='text/html' width='640' height='360'
|
||||
src='<%= host_url %>/embed/<%= video.id %>?<%= host_params %>' frameborder='0'></iframe>`
|
||||
embedCode: "<iframe id='ivplayer' type='text/html' width='640' height='360' \
|
||||
src='<%= host_url %>/embed/<%= video.id %>?<%= host_params %>' frameborder='0'></iframe>"
|
||||
};
|
||||
|
||||
var player = videojs("player", options, function() {
|
||||
|
@ -8,7 +8,7 @@
|
||||
<a style="width:100%;" href="/watch?v=<%= video.id %><%= params %>">
|
||||
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
|
||||
<% else %>
|
||||
<img style="width:100%;" src="https://i.ytimg.com/vi/<%= video.id %>/mqdefault.jpg"/>
|
||||
<img style="width:100%;" src="/vi/<%= video.id %>/mqdefault.jpg"/>
|
||||
<% end %>
|
||||
<p><%= video.title %></p>
|
||||
</a>
|
||||
|
@ -6,6 +6,11 @@
|
||||
<div class="pure-u-2-3">
|
||||
<h3><%= playlist.title %></h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3" style="text-align:right;">
|
||||
<h3>
|
||||
<a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1 pure-u-md-1-4">
|
||||
@ -16,7 +21,7 @@
|
||||
</div>
|
||||
|
||||
<div class="h-box">
|
||||
<p><%= playlist.description %></p>
|
||||
<p><%= playlist.description_html %></p>
|
||||
</div>
|
||||
|
||||
<% videos.each_slice(4) do |slice| %>
|
||||
|
@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-3-5"></div>
|
||||
<div style="text-align:right;" class="pure-u-1 pure-u-md-1-5">
|
||||
<% if count == 20 %>
|
||||
<% if count >= 20 %>
|
||||
<a href="/search?q=<%= query %>&page=<%= page + 1 %>">Next page</a>
|
||||
<% end %>
|
||||
</div>
|
||||
|
@ -28,7 +28,7 @@
|
||||
<div class="pure-u-1 pure-u-md-12-24 searchbar">
|
||||
<form class="pure-form" action="/search" method="get">
|
||||
<fieldset>
|
||||
<input type="search" style="width:100%;" name="q" placeholder="search" value="<%= env.params.query["q"]? %>">
|
||||
<input type="search" style="width:100%;" name="q" placeholder="search" value="<%= env.params.query["q"]? || env.get? "search" %>">
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@
|
||||
<meta property="og:site_name" content="Invidious">
|
||||
<meta property="og:url" content="<%= host_url %>/watch?v=<%= video.id %>">
|
||||
<meta property="og:title" content="<%= HTML.escape(video.title) %>">
|
||||
<meta property="og:image" content="https://i.ytimg.com/vi/<%= video.id %>/hqdefault.jpg">
|
||||
<meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg">
|
||||
<meta property="og:description" content="<%= description %>">
|
||||
<meta property="og:type" content="video.other">
|
||||
<meta property="og:video:url" content="<%= host_url %>/embed/<%= video.id %>">
|
||||
@ -26,7 +26,7 @@
|
||||
<title><%= HTML.escape(video.title) %> - Invidious</title>
|
||||
<% end %>
|
||||
|
||||
<div class="h-box">
|
||||
<div id="player-container" class="h-box">
|
||||
<%= rendered "components/player" %>
|
||||
</div>
|
||||
|
||||
@ -116,14 +116,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-5">
|
||||
<% if preferences && preferences.related_videos %>
|
||||
<% if !preferences || preferences && preferences.related_videos %>
|
||||
<div class="h-box">
|
||||
<% rvs.each do |rv| %>
|
||||
<% if rv.has_key?("id") %>
|
||||
<a href="/watch?v=<%= rv["id"] %>">
|
||||
<% if preferences && preferences.thin_mode %>
|
||||
<% else %>
|
||||
<img style="width:100%;" src="<%= rv["iurlmq"] %>">
|
||||
<img style="width:100%;" src="/vi/<%= rv["id"] %>/mqdefault.jpg">
|
||||
<% end %>
|
||||
<p style="width:100%"><%= rv["title"] %></p>
|
||||
<p>
|
||||
@ -205,19 +205,18 @@ function get_reddit_comments() {
|
||||
if (xhr.readyState == 4)
|
||||
if (xhr.status == 200) {
|
||||
comments = document.getElementById("comments");
|
||||
comments.innerHTML = `
|
||||
<div>
|
||||
<h3>
|
||||
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a>
|
||||
{title}
|
||||
</h3>
|
||||
<b>
|
||||
<a target="_blank" href="https://reddit.com{permalink}">View more comments on Reddit</a>
|
||||
</b>
|
||||
</div>
|
||||
<div>{contentHtml}</div>
|
||||
|
||||
<hr>`.supplant({
|
||||
comments.innerHTML = ' \
|
||||
<div> \
|
||||
<h3> \
|
||||
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a> \
|
||||
{title} \
|
||||
</h3> \
|
||||
<b> \
|
||||
<a target="_blank" href="https://reddit.com{permalink}">View more comments on Reddit</a> \
|
||||
</b> \
|
||||
</div> \
|
||||
<div>{contentHtml}</div> \
|
||||
<hr>'.supplant({
|
||||
title: xhr.response.title,
|
||||
permalink: xhr.response.permalink,
|
||||
contentHtml: xhr.response.contentHtml
|
||||
@ -252,15 +251,15 @@ function get_youtube_comments() {
|
||||
if (xhr.status == 200) {
|
||||
comments = document.getElementById("comments");
|
||||
if (xhr.response.commentCount > 0) {
|
||||
comments.innerHTML = `
|
||||
<div>
|
||||
<h3>
|
||||
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a>
|
||||
View {commentCount} comments
|
||||
</h3>
|
||||
</div>
|
||||
<div>{contentHtml}</div>
|
||||
<hr>`.supplant({
|
||||
comments.innerHTML = ' \
|
||||
<div> \
|
||||
<h3> \
|
||||
<a href="javascript:void(0)" onclick="toggle_comments(this)">[ - ]</a> \
|
||||
View {commentCount} comments \
|
||||
</h3> \
|
||||
</div> \
|
||||
<div>{contentHtml}</div> \
|
||||
<hr>'.supplant({
|
||||
contentHtml: xhr.response.contentHtml,
|
||||
commentCount: commaSeparateNumber(xhr.response.commentCount)
|
||||
});
|
||||
|
Reference in New Issue
Block a user