mirror of
https://github.com/iv-org/invidious
synced 2024-12-01 07:23:28 +01:00
Add playlist page and endpoint
This commit is contained in:
parent
fa2ba807a3
commit
bb0b60e575
110
src/invidious.cr
110
src/invidious.cr
@ -355,6 +355,31 @@ get "/embed/:id" do |env|
|
||||
rendered "embed"
|
||||
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
|
||||
|
||||
get "/results" do |env|
|
||||
@ -1522,31 +1547,13 @@ get "/channel/:ucid" do |env|
|
||||
rss = XML.parse_html(rss.body)
|
||||
author = rss.xpath_node("//feed/author/name").not_nil!.content
|
||||
|
||||
url = produce_playlist_url(ucid, (page - 1) * 100)
|
||||
response = client.get(url)
|
||||
response = JSON.parse(response.body)
|
||||
|
||||
if !response["content_html"]?
|
||||
error_message = "This channel does not exist."
|
||||
begin
|
||||
videos = extract_playlist(ucid, page)
|
||||
rescue ex
|
||||
error_message = ex.message
|
||||
next templated "error"
|
||||
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"
|
||||
end
|
||||
|
||||
@ -2350,6 +2357,65 @@ get "/api/v1/search" do |env|
|
||||
response
|
||||
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|
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
env.redirect "/videoplayback?#{env.params.query}"
|
||||
|
@ -262,7 +262,7 @@ def fill_links(html, scheme, host)
|
||||
end
|
||||
|
||||
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
|
||||
html = html.to_xml(options: XML::SaveOptions::NO_DECL)
|
||||
end
|
||||
|
@ -116,40 +116,6 @@ def login_req(login_form, f_req)
|
||||
return HTTP::Params.encode(data)
|
||||
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)
|
||||
page = "#{page}"
|
||||
|
||||
|
@ -64,10 +64,23 @@ end
|
||||
|
||||
def decode_date(string : String)
|
||||
# String matches 'YYYY'
|
||||
if string.match(/\d{4}/)
|
||||
if string.match(/^\d{4}/)
|
||||
return Time.new(string.to_i, 1, 1)
|
||||
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"...
|
||||
date = string.split(" ")[-3, 3]
|
||||
delta = date[0].to_i
|
||||
|
160
src/invidious/playlists.cr
Normal file
160
src/invidious/playlists.cr
Normal 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
|
@ -1,6 +1,11 @@
|
||||
<div class="pure-u-1 pure-u-md-1-4">
|
||||
<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 %>
|
||||
<% else %>
|
||||
<img style="width:100%;" src="https://i.ytimg.com/vi/<%= video.id %>/mqdefault.jpg"/>
|
||||
|
42
src/invidious/views/playlist.ecr
Normal file
42
src/invidious/views/playlist.ecr
Normal 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>
|
Loading…
Reference in New Issue
Block a user