Merge pull request #116 from omarroth/add-playlists

Add playlist page and endpoint
This commit is contained in:
Omar Roth 2018-08-17 11:01:36 -05:00 committed by GitHub
commit 4760b3c6e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 311 additions and 59 deletions

View file

@ -368,6 +368,31 @@ get "/embed/:id" do |env|
rendered "embed" rendered "embed"
end end
# Playlists
get "/playlist" do |env|
plid = env.params.query["list"]?
if !plid
next env.redirect "/"
end
page = env.params.query["page"]?.try &.to_i?
page ||= 1
if plid
begin
videos = extract_playlist(plid, page)
rescue ex
error_message = ex.message
next templated "error"
end
playlist = fetch_playlist(plid)
else
next env.redirect "/"
end
templated "playlist"
end
# Search # Search
get "/results" do |env| get "/results" do |env|
@ -1534,31 +1559,13 @@ get "/channel/:ucid" do |env|
rss = XML.parse_html(rss.body) rss = XML.parse_html(rss.body)
author = rss.xpath_node("//feed/author/name").not_nil!.content author = rss.xpath_node("//feed/author/name").not_nil!.content
url = produce_playlist_url(ucid, (page - 1) * 100) begin
response = client.get(url) videos = extract_playlist(ucid, page)
response = JSON.parse(response.body) rescue ex
error_message = ex.message
if !response["content_html"]?
error_message = "This channel does not exist."
next templated "error" next templated "error"
end end
document = XML.parse_html(response["content_html"].as_s)
anchor = document.xpath_node(%q(//div[@class="pl-video-owner"]/a))
if !anchor
videos = [] of ChannelVideo
next templated "channel"
end
videos = [] of ChannelVideo
document.xpath_nodes(%q(//a[contains(@class,"pl-video-title-link")])).each do |node|
href = URI.parse(node["href"])
id = HTTP::Params.parse(href.query.not_nil!)["v"]
title = node.content
videos << ChannelVideo.new(id, title, Time.now, Time.now, "", "")
end
templated "channel" templated "channel"
end end
@ -2363,6 +2370,65 @@ get "/api/v1/search" do |env|
response response
end end
get "/api/v1/playlists/:plid" do |env|
plid = env.params.url["plid"]
page = env.params.query["page"]?.try &.to_i?
page ||= 1
begin
videos = extract_playlist(plid, page)
rescue ex
env.response.content_type = "application/json"
response = {"error" => "Playlist is empty"}.to_json
halt env, status_code: 404, response: response
end
playlist = fetch_playlist(plid)
response = JSON.build do |json|
json.object do
json.field "title", playlist.title
json.field "id", playlist.id
json.field "author", playlist.author
json.field "authorId", playlist.ucid
json.field "authorUrl", "/channel/#{playlist.ucid}"
json.field "description", playlist.description
json.field "videoCount", playlist.video_count
json.field "viewCount", playlist.views
json.field "updated", playlist.updated.epoch
json.field "videos" do
json.array do
videos.each do |video|
json.object do
json.field "title", video.title
json.field "id", video.id
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "videoThumbnails" do
generate_thumbnails(json, video.id)
end
json.field "index", video.index
json.field "lengthSeconds", video.length_seconds
end
end
end
end
end
end
env.response.content_type = "application/json"
response
end
get "/api/manifest/dash/id/videoplayback" do |env| get "/api/manifest/dash/id/videoplayback" do |env|
env.response.headers["Access-Control-Allow-Origin"] = "*" env.response.headers["Access-Control-Allow-Origin"] = "*"
env.redirect "/videoplayback?#{env.params.query}" env.redirect "/videoplayback?#{env.params.query}"

View file

@ -262,7 +262,7 @@ def fill_links(html, scheme, host)
end end
if host == "www.youtube.com" if host == "www.youtube.com"
html = html.xpath_node(%q(//p[@id="eow-description"])).not_nil!.to_xml html = html.xpath_node(%q(//body)).not_nil!.to_xml
else else
html = html.to_xml(options: XML::SaveOptions::NO_DECL) html = html.to_xml(options: XML::SaveOptions::NO_DECL)
end end

View file

@ -116,40 +116,6 @@ def login_req(login_form, f_req)
return HTTP::Params.encode(data) return HTTP::Params.encode(data)
end end
def produce_playlist_url(id, index)
if id.starts_with? "UC"
id = "UU" + id.lchop("UC")
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)
# 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)
# Outer Base64
continuation = [0x1a.to_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
# Wrap bytes
slice = continuation.to_unsafe.to_slice(continuation.size)
slice = Base64.urlsafe_encode(slice)
slice = URI.escape(slice)
continuation = slice
url = "/browse_ajax?action_continuation=1&continuation=#{continuation}"
return url
end
def produce_videos_url(ucid, page = 1) def produce_videos_url(ucid, page = 1)
page = "#{page}" page = "#{page}"

View file

@ -64,10 +64,23 @@ end
def decode_date(string : String) def decode_date(string : String)
# String matches 'YYYY' # String matches 'YYYY'
if string.match(/\d{4}/) if string.match(/^\d{4}/)
return Time.new(string.to_i, 1, 1) return Time.new(string.to_i, 1, 1)
end end
# Try to parse as format Jul 10, 2000
begin
return Time.parse(string, "%b %-d, %Y", Time::Location.local)
rescue ex
end
case string
when "today"
return Time.now
when "yesterday"
return Time.now - 1.day
end
# String matches format "20 hours ago", "4 months ago"... # String matches format "20 hours ago", "4 months ago"...
date = string.split(" ")[-3, 3] date = string.split(" ")[-3, 3]
delta = date[0].to_i delta = date[0].to_i

160
src/invidious/playlists.cr Normal file
View file

@ -0,0 +1,160 @@
class Playlist
add_mapping({
title: String,
id: String,
author: String,
ucid: String,
description: String,
video_count: Int32,
views: Int64,
updated: Time,
})
end
class PlaylistVideo
add_mapping({
title: String,
id: String,
author: String,
ucid: String,
length_seconds: Int32,
published: Time,
playlists: Array(String),
index: Int32,
})
end
def extract_playlist(plid, page)
index = (page - 1) * 100
url = produce_playlist_url(plid, index)
client = make_client(YT_URL)
response = client.get(url)
response = JSON.parse(response.body)
if !response["content_html"]? || response["content_html"].as_s.empty?
raise "Playlist does not exist"
end
videos = [] of PlaylistVideo
document = XML.parse_html(response["content_html"].as_s)
anchor = document.xpath_node(%q(//div[@class="pl-video-owner"]/a))
if anchor
document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])).each_with_index do |video, offset|
anchor = video.xpath_node(%q(.//td[@class="pl-video-title"]))
if !anchor
next
end
title = anchor.xpath_node(%q(.//a)).not_nil!.content.strip(" \n")
id = anchor.xpath_node(%q(.//a)).not_nil!["href"].lchop("/watch?v=")[0, 11]
anchor = anchor.xpath_node(%q(.//div[@class="pl-video-owner"]/a))
if anchor
author = anchor.content
ucid = anchor["href"].split("/")[2]
else
author = ""
ucid = ""
end
anchor = video.xpath_node(%q(.//td[@class="pl-video-time"]/div/div[1]))
if anchor && !anchor.content.empty?
length_seconds = decode_length_seconds(anchor.content)
else
length_seconds = 0
end
videos << PlaylistVideo.new(
title,
id,
author,
ucid,
length_seconds,
Time.now,
[plid],
index + offset,
)
end
end
return videos
end
def produce_playlist_url(id, index)
if id.starts_with? "UC"
id = "UU" + id.lchop("UC")
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)
# 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)
# Outer Base64
continuation = [0x1a.to_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
# Wrap bytes
slice = continuation.to_unsafe.to_slice(continuation.size)
slice = Base64.urlsafe_encode(slice)
slice = URI.escape(slice)
continuation = slice
url = "/browse_ajax?action_continuation=1&continuation=#{continuation}"
return url
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)
title = document.xpath_node(%q(//h1[@class="pl-header-title"])).not_nil!.content
title = title.strip(" \n")
description = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1]))
description ||= document.xpath_node(%q(//span[@class="pl-header-description-text"]))
if description
description = description.to_xml.strip(" \n")
description = description.split("<button ")[0]
description = fill_links(description, "https", "www.youtube.com")
description = add_alt_links(description)
else
description = ""
end
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"])).not_nil!
author = anchor.xpath_node(%q(.//li[1]/a)).not_nil!.content
ucid = anchor.xpath_node(%q(.//li[1]/a)).not_nil!["href"].split("/")[2]
video_count = anchor.xpath_node(%q(.//li[2])).not_nil!.content.delete("videos").to_i
views = anchor.xpath_node(%q(.//li[3])).not_nil!.content.delete("views,").to_i64
updated = anchor.xpath_node(%q(.//li[4])).not_nil!.content.lchop("Last updated on ").lchop("Updated ")
updated = decode_date(updated)
playlist = Playlist.new(
title,
plid,
author,
ucid,
description,
video_count,
views,
updated
)
return playlist
end

View file

@ -1,6 +1,11 @@
<div class="pure-u-1 pure-u-md-1-4"> <div class="pure-u-1 pure-u-md-1-4">
<div class="h-box"> <div class="h-box">
<a style="width:100%;" href="/watch?v=<%= video.id %>"> <% if video.responds_to?(:playlists) %>
<% params = "&list=#{video.playlists[0]}" %>
<% else %>
<% params = nil %>
<% end %>
<a style="width:100%;" href="/watch?v=<%= video.id %><%= params %>">
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %> <% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
<% else %> <% else %>
<img style="width:100%;" src="https://i.ytimg.com/vi/<%= video.id %>/mqdefault.jpg"/> <img style="width:100%;" src="https://i.ytimg.com/vi/<%= video.id %>/mqdefault.jpg"/>

View file

@ -0,0 +1,42 @@
<% content_for "header" do %>
<title><%= playlist.title %> - Invidious</title>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-2-3">
<h3><%= playlist.title %></h3>
</div>
</div>
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-4">
<a href="/channel/<%= playlist.ucid %>">
<b><%= playlist.author %></b>
</a>
</div>
</div>
<div class="h-box">
<p><%= playlist.description %></p>
</div>
<% videos.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
<%= rendered "components/video" %>
<% end %>
</div>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-1 pure-u-md-1-5">
<% if page >= 2 %>
<a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>">Next page</a>
<% end %>
</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 videos.size == 100 %>
<a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>">Next page</a>
<% end %>
</div>
</div>