mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-27 12:05:27 -05:00
chore: Encapsulate more functions into their own modules and improve specs
This commit is contained in:
parent
e4ad826f1f
commit
7e106030e6
23
spec/invidious/channel/community_spec.cr
Normal file
23
spec/invidious/channel/community_spec.cr
Normal file
File diff suppressed because one or more lines are too long
26
spec/invidious/helpers/tokens_spec.cr
Normal file
26
spec/invidious/helpers/tokens_spec.cr
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
require "../../spec_helper"
|
||||||
|
|
||||||
|
Spectator.describe Invidious::Helpers::Tokens do
|
||||||
|
describe ".sign_token" do
|
||||||
|
it "correctly signs a given hash" do
|
||||||
|
token = {
|
||||||
|
"session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
"expires" => 1554680038,
|
||||||
|
"scopes" => [
|
||||||
|
":notifications",
|
||||||
|
":subscriptions/*",
|
||||||
|
"GET:tokens*",
|
||||||
|
],
|
||||||
|
"signature" => "f__2hS20th8pALF305PJFK-D2aVtvefNnQheILHD2vU=",
|
||||||
|
}
|
||||||
|
expect(Invidious::Helpers::Tokens.sign_token("SECRET_KEY", token)).to eq(token["signature"])
|
||||||
|
|
||||||
|
token = {
|
||||||
|
"session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
"scopes" => [":notifications", "POST:subscriptions/*"],
|
||||||
|
"signature" => "fNvXoT0MRAL9eE6lTE33CEg8HitYJDOL9a22rSN2Ihg=",
|
||||||
|
}
|
||||||
|
expect(Invidious::Helpers::Tokens.sign_token("SECRET_KEY", token)).to eq(token["signature"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
File diff suppressed because one or more lines are too long
11
spec/invidious/search/ctoken_spec.cr
Normal file
11
spec/invidious/search/ctoken_spec.cr
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
require "../../spec_helper"
|
||||||
|
|
||||||
|
Spectator.describe Invidious::Search::CToken do
|
||||||
|
describe ".produce_channel_search_continuation" do
|
||||||
|
it "correctly produces token for searching a specific channel" do
|
||||||
|
expect(Invidious::Search::CToken.produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100)).to eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
|
||||||
|
|
||||||
|
expect(Invidious::Search::CToken.produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0)).to eq("4qmFsgKoARIYVUNYdXFTQmxIQUU2WHcteWVKQTBUdW53GiBFZ1p6WldGeVkyZ3dBVGdCWUFGNkJFZEJRVDI0QVFBPVo-0J_QviDQvtC20LjgpLbgpYHgpKrgpKTgpL_gpLDgpKrgpL_lrZDogIzmmYLgrrjgr43grrHgr4Dgrqngrr-aAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -9,7 +9,6 @@ require "../src/invidious/videos/caption"
|
|||||||
require "../src/invidious/videos"
|
require "../src/invidious/videos"
|
||||||
require "../src/invidious/playlists"
|
require "../src/invidious/playlists"
|
||||||
require "../src/invidious/search/ctoken"
|
require "../src/invidious/search/ctoken"
|
||||||
require "../src/invidious/trending"
|
|
||||||
require "spectator"
|
require "spectator"
|
||||||
|
|
||||||
Spectator.configure do |config|
|
Spectator.configure do |config|
|
||||||
|
|||||||
@ -44,6 +44,8 @@ require "./invidious/jsonify/**"
|
|||||||
require "./invidious/*"
|
require "./invidious/*"
|
||||||
require "./invidious/comments/*"
|
require "./invidious/comments/*"
|
||||||
require "./invidious/channels/*"
|
require "./invidious/channels/*"
|
||||||
|
require "./invidious/playlists/*"
|
||||||
|
require "./invidious/feeds/*"
|
||||||
require "./invidious/user/*"
|
require "./invidious/user/*"
|
||||||
require "./invidious/search/*"
|
require "./invidious/search/*"
|
||||||
require "./invidious/routes/**"
|
require "./invidious/routes/**"
|
||||||
@ -230,7 +232,7 @@ error 404 do |env|
|
|||||||
end
|
end
|
||||||
|
|
||||||
error 500 do |env, exception|
|
error 500 do |env, exception|
|
||||||
error_template(500, exception)
|
Errors.error_template(500, exception)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Init Kemal
|
# Init Kemal
|
||||||
|
|||||||
@ -19,6 +19,9 @@ record AboutChannel,
|
|||||||
verified : Bool,
|
verified : Bool,
|
||||||
is_age_gated : Bool
|
is_age_gated : Bool
|
||||||
|
|
||||||
|
module Invidious::Channels::About
|
||||||
|
extend self
|
||||||
|
|
||||||
def get_about_info(ucid) : AboutChannel
|
def get_about_info(ucid) : AboutChannel
|
||||||
begin
|
begin
|
||||||
# Fetch channel information from channel home page
|
# Fetch channel information from channel home page
|
||||||
@ -218,3 +221,4 @@ def fetch_related_channels(about_channel : AboutChannel, continuation : String?
|
|||||||
|
|
||||||
return items.select(SearchChannel), continuation
|
return items.select(SearchChannel), continuation
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@ -123,7 +123,7 @@ def get_batch_channels(channels)
|
|||||||
active_threads += 1
|
active_threads += 1
|
||||||
spawn do
|
spawn do
|
||||||
begin
|
begin
|
||||||
get_channel(ucid)
|
Invidious::Channels::Channels.get_channel(ucid)
|
||||||
finished_channel.send(ucid)
|
finished_channel.send(ucid)
|
||||||
rescue ex
|
rescue ex
|
||||||
finished_channel.send(nil)
|
finished_channel.send(nil)
|
||||||
@ -144,11 +144,14 @@ def get_batch_channels(channels)
|
|||||||
return final
|
return final
|
||||||
end
|
end
|
||||||
|
|
||||||
|
module Invidious::Channels::Channels
|
||||||
|
extend self
|
||||||
|
|
||||||
def get_channel(id) : InvidiousChannel
|
def get_channel(id) : InvidiousChannel
|
||||||
channel = Invidious::Database::Channels.select(id)
|
channel = Invidious::Database::Channels.select(id)
|
||||||
|
|
||||||
if channel.nil? || (Time.utc - channel.updated) > 2.days
|
if channel.nil? || (Time.utc - channel.updated) > 2.days
|
||||||
channel = fetch_channel(id, pull_all_videos: false)
|
channel = self.fetch_channel(id, pull_all_videos: false)
|
||||||
Invidious::Database::Channels.insert(channel, update_on_conflict: true)
|
Invidious::Database::Channels.insert(channel, update_on_conflict: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -294,3 +297,4 @@ def fetch_channel(ucid, pull_all_videos : Bool)
|
|||||||
channel.updated = Time.utc
|
channel.updated = Time.utc
|
||||||
return channel
|
return channel
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
module Invidious::Channels::Community
|
||||||
|
extend self
|
||||||
private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000}
|
private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000}
|
||||||
|
|
||||||
# TODO: Add "sort_by"
|
# TODO: Add "sort_by"
|
||||||
@ -11,7 +13,7 @@ def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
|
|||||||
items << item
|
items << item
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
continuation = produce_channel_community_continuation(ucid, cursor)
|
continuation = self.produce_channel_community_continuation(ucid, cursor)
|
||||||
initial_data = YoutubeAPI.browse(continuation: continuation)
|
initial_data = YoutubeAPI.browse(continuation: continuation)
|
||||||
|
|
||||||
container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents")
|
container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents")
|
||||||
@ -127,7 +129,7 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing
|
|||||||
|
|
||||||
reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0")
|
reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0")
|
||||||
|
|
||||||
json.field "content", Helpers.html_to_content(content_html)
|
json.field "content", Invidious::Helpers.html_to_content(content_html)
|
||||||
json.field "contentHtml", content_html
|
json.field "contentHtml", content_html
|
||||||
|
|
||||||
json.field "published", published.to_unix
|
json.field "published", published.to_unix
|
||||||
@ -336,3 +338,4 @@ def extract_channel_community_cursor(continuation)
|
|||||||
|
|
||||||
cursor
|
cursor
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
module Invidious::Channels::Playlists
|
||||||
|
extend self
|
||||||
|
|
||||||
def fetch_channel_playlists(ucid, author, continuation, sort_by)
|
def fetch_channel_playlists(ucid, author, continuation, sort_by)
|
||||||
if continuation
|
if continuation
|
||||||
initial_data = YoutubeAPI.browse(continuation)
|
initial_data = YoutubeAPI.browse(continuation)
|
||||||
@ -53,3 +56,4 @@ def fetch_channel_courses(ucid, author, continuation)
|
|||||||
end
|
end
|
||||||
return extract_items(initial_data, author, ucid)
|
return extract_items(initial_data, author, ucid)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@ -246,7 +246,7 @@ module Invidious::Comments
|
|||||||
end
|
end
|
||||||
|
|
||||||
content_html = html_content || ""
|
content_html = html_content || ""
|
||||||
json.field "content", Helpers.html_to_content(content_html)
|
json.field "content", Invidious::Helpers.html_to_content(content_html)
|
||||||
json.field "contentHtml", content_html
|
json.field "contentHtml", content_html
|
||||||
|
|
||||||
if published_text != nil
|
if published_text != nil
|
||||||
|
|||||||
216
src/invidious/ext/kemal_static_file_handler.cr
Normal file
216
src/invidious/ext/kemal_static_file_handler.cr
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
{% if compare_versions(Crystal::VERSION, "1.17.0-dev") >= 0 %}
|
||||||
|
# Strip StaticFileHandler from the binary
|
||||||
|
#
|
||||||
|
# This allows us to compile on 1.17.0 as the compiler won't try to
|
||||||
|
# semantically check the outdated upstream code.
|
||||||
|
class Kemal::Config
|
||||||
|
private def setup_static_file_handler
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Nullify `Kemal::StaticFileHandler`
|
||||||
|
#
|
||||||
|
# Needed until the next release of Kemal after 1.7
|
||||||
|
class Kemal::StaticFileHandler < HTTP::StaticFileHandler
|
||||||
|
def call(context : HTTP::Server::Context)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{% skip_file %}
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
# Since systems have a limit on number of open files (`ulimit -a`),
|
||||||
|
# we serve them from memory to avoid 'Too many open files' without needing
|
||||||
|
# to modify ulimit.
|
||||||
|
#
|
||||||
|
# Very heavily re-used:
|
||||||
|
# https://github.com/kemalcr/kemal/blob/master/src/kemal/helpers/helpers.cr
|
||||||
|
# https://github.com/kemalcr/kemal/blob/master/src/kemal/static_file_handler.cr
|
||||||
|
#
|
||||||
|
# Changes:
|
||||||
|
# - A `send_file` overload is added which supports sending a Slice, file_path, filestat
|
||||||
|
# - `StaticFileHandler` is patched to cache to and serve from @cached_files
|
||||||
|
|
||||||
|
private def multipart(file, env : HTTP::Server::Context)
|
||||||
|
# See http://httpwg.org/specs/rfc7233.html
|
||||||
|
fileb = file.size
|
||||||
|
startb = endb = 0
|
||||||
|
|
||||||
|
if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/
|
||||||
|
startb = match[1].to_i { 0 } if match.size >= 2
|
||||||
|
endb = match[2].to_i { 0 } if match.size >= 3
|
||||||
|
end
|
||||||
|
|
||||||
|
endb = fileb - 1 if endb == 0
|
||||||
|
|
||||||
|
if startb < endb < fileb
|
||||||
|
content_length = 1 + endb - startb
|
||||||
|
env.response.status_code = 206
|
||||||
|
env.response.content_length = content_length
|
||||||
|
env.response.headers["Accept-Ranges"] = "bytes"
|
||||||
|
env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST
|
||||||
|
|
||||||
|
if startb > 1024
|
||||||
|
skipped = 0
|
||||||
|
# file.skip only accepts values less or equal to 1024 (buffer size, undocumented)
|
||||||
|
until (increase_skipped = skipped + 1024) > startb
|
||||||
|
file.skip(1024)
|
||||||
|
skipped = increase_skipped
|
||||||
|
end
|
||||||
|
if (skipped_minus_startb = skipped - startb) > 0
|
||||||
|
file.skip skipped_minus_startb
|
||||||
|
end
|
||||||
|
else
|
||||||
|
file.skip(startb)
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.copy(file, env.response, content_length)
|
||||||
|
else
|
||||||
|
env.response.content_length = fileb
|
||||||
|
env.response.status_code = 200 # Range not satisfable, see 4.4 Note
|
||||||
|
IO.copy(file, env.response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the Content-Disposition to "attachment" with the specified filename,
|
||||||
|
# instructing the user agents to prompt to save.
|
||||||
|
private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil)
|
||||||
|
disposition = "attachment" if disposition.nil? && filename
|
||||||
|
if disposition && filename
|
||||||
|
env.response.headers["Content-Disposition"] = "#{disposition}; filename=\"#{File.basename(filename)}\""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt8), filestat : File::Info, filename : String? = nil, disposition : String? = nil)
|
||||||
|
config = Kemal.config.serve_static
|
||||||
|
mime_type = MIME.from_filename(file_path, "application/octet-stream")
|
||||||
|
env.response.content_type = mime_type
|
||||||
|
env.response.headers["Accept-Ranges"] = "bytes"
|
||||||
|
env.response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
minsize = 860 # http://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits ??
|
||||||
|
request_headers = env.request.headers
|
||||||
|
filesize = data.bytesize
|
||||||
|
attachment(env, filename, disposition)
|
||||||
|
|
||||||
|
Kemal.config.static_headers.try(&.call(env, file_path, filestat))
|
||||||
|
|
||||||
|
file = IO::Memory.new(data)
|
||||||
|
if env.request.method == "GET" && env.request.headers.has_key?("Range")
|
||||||
|
return multipart(file, env)
|
||||||
|
end
|
||||||
|
|
||||||
|
condition = config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && Kemal::Utils.zip_types(file_path)
|
||||||
|
if condition && request_headers.includes_word?("Accept-Encoding", "gzip")
|
||||||
|
env.response.headers["Content-Encoding"] = "gzip"
|
||||||
|
Compress::Gzip::Writer.open(env.response) do |deflate|
|
||||||
|
IO.copy(file, deflate)
|
||||||
|
end
|
||||||
|
elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate")
|
||||||
|
env.response.headers["Content-Encoding"] = "deflate"
|
||||||
|
Compress::Deflate::Writer.open(env.response) do |deflate|
|
||||||
|
IO.copy(file, deflate)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
env.response.content_length = filesize
|
||||||
|
IO.copy(file, env.response)
|
||||||
|
end
|
||||||
|
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
module Kemal
|
||||||
|
class StaticFileHandler < HTTP::StaticFileHandler
|
||||||
|
CACHE_LIMIT = 5_000_000 # 5MB
|
||||||
|
@cached_files = {} of String => {data: Bytes, filestat: File::Info}
|
||||||
|
|
||||||
|
def call(context : HTTP::Server::Context)
|
||||||
|
return call_next(context) if context.request.path.not_nil! == "/"
|
||||||
|
|
||||||
|
case context.request.method
|
||||||
|
when "GET", "HEAD"
|
||||||
|
else
|
||||||
|
if @fallthrough
|
||||||
|
call_next(context)
|
||||||
|
else
|
||||||
|
context.response.status = HTTP::Status::METHOD_NOT_ALLOWED
|
||||||
|
context.response.headers.add("Allow", "GET, HEAD")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
config = Kemal.config.serve_static
|
||||||
|
original_path = context.request.path.not_nil!
|
||||||
|
request_path = URI.decode_www_form(original_path)
|
||||||
|
|
||||||
|
# File path cannot contains '\0' (NUL) because all filesystem I know
|
||||||
|
# don't accept '\0' character as file name.
|
||||||
|
if request_path.includes? '\0'
|
||||||
|
context.response.status = HTTP::Status::BAD_REQUEST
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
expanded_path = File.expand_path(request_path, "/")
|
||||||
|
is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/'
|
||||||
|
expanded_path = expanded_path + '/'
|
||||||
|
true
|
||||||
|
else
|
||||||
|
expanded_path.ends_with? '/'
|
||||||
|
end
|
||||||
|
|
||||||
|
file_path = File.join(@public_dir, expanded_path)
|
||||||
|
|
||||||
|
if file = @cached_files[file_path]?
|
||||||
|
last_modified = file[:filestat].modification_time
|
||||||
|
add_cache_headers(context.response.headers, last_modified)
|
||||||
|
|
||||||
|
if cache_request?(context, last_modified)
|
||||||
|
context.response.status = HTTP::Status::NOT_MODIFIED
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
send_file(context, file_path, file[:data], file[:filestat])
|
||||||
|
else
|
||||||
|
file_info = File.info?(file_path)
|
||||||
|
is_dir = file_info.try &.directory? || false
|
||||||
|
is_file = file_info.try &.file? || false
|
||||||
|
|
||||||
|
if request_path != expanded_path
|
||||||
|
redirect_to context, expanded_path
|
||||||
|
elsif is_dir && !is_dir_path
|
||||||
|
redirect_to context, expanded_path + '/'
|
||||||
|
end
|
||||||
|
|
||||||
|
return call_next(context) if file_info.nil?
|
||||||
|
|
||||||
|
if is_dir
|
||||||
|
if config.is_a?(Hash) && config["dir_listing"] == true
|
||||||
|
context.response.content_type = "text/html"
|
||||||
|
directory_listing(context.response, request_path, file_path)
|
||||||
|
else
|
||||||
|
call_next(context)
|
||||||
|
end
|
||||||
|
elsif is_file
|
||||||
|
last_modified = file_info.modification_time
|
||||||
|
add_cache_headers(context.response.headers, last_modified)
|
||||||
|
|
||||||
|
if cache_request?(context, last_modified)
|
||||||
|
context.response.status = HTTP::Status::NOT_MODIFIED
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT
|
||||||
|
data = Bytes.new(size)
|
||||||
|
File.open(file_path, &.read(data))
|
||||||
|
|
||||||
|
@cached_files[file_path] = {data: data, filestat: file_info}
|
||||||
|
send_file(context, file_path, data, file_info)
|
||||||
|
else
|
||||||
|
send_file(context, file_path)
|
||||||
|
end
|
||||||
|
else # Not a normal file (FIFO/device/socket)
|
||||||
|
call_next(context)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
52
src/invidious/feeds/trending.cr
Normal file
52
src/invidious/feeds/trending.cr
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
module Invidious::Feeds::Trending
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def fetch(trending_type, region, locale)
|
||||||
|
region ||= "US"
|
||||||
|
region = region.upcase
|
||||||
|
|
||||||
|
plid = nil
|
||||||
|
|
||||||
|
browse_id = ""
|
||||||
|
|
||||||
|
case trending_type.try &.downcase
|
||||||
|
when "gaming"
|
||||||
|
browse_id = "UCOpNcN46UbXVtpKMrmU4Abg"
|
||||||
|
params = "Egh0cmVuZGluZw%3D%3D"
|
||||||
|
when "livestreams"
|
||||||
|
browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
|
||||||
|
params = "EgdsaXZldGFikgEDCKEK"
|
||||||
|
else
|
||||||
|
# Livestreams is the default one as Youtube removed
|
||||||
|
# the aggregated trending page
|
||||||
|
# https://github.com/iv-org/invidious/issues/5397#issuecomment-3218928458
|
||||||
|
browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
|
||||||
|
params = "EgdsaXZldGFikgEDCKEK"
|
||||||
|
end
|
||||||
|
|
||||||
|
client_config = YoutubeAPI::ClientConfig.new(region: region)
|
||||||
|
initial_data = YoutubeAPI.browse(browse_id, params: params, client_config: client_config)
|
||||||
|
|
||||||
|
items, _ = extract_items(initial_data)
|
||||||
|
|
||||||
|
extracted = [] of SearchItem
|
||||||
|
|
||||||
|
deduplicate = items.size > 1
|
||||||
|
|
||||||
|
items.each do |itm|
|
||||||
|
if itm.is_a?(Category)
|
||||||
|
# Ignore the smaller categories, as they generally contain a sponsored
|
||||||
|
# channel, which brings a lot of noise on the trending page.
|
||||||
|
# See: https://github.com/iv-org/invidious/issues/2989
|
||||||
|
next if (itm.contents.size < 24 && deduplicate)
|
||||||
|
|
||||||
|
extracted.concat itm.contents.select(SearchItem)
|
||||||
|
else
|
||||||
|
extracted << itm
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deduplicate items before returning results
|
||||||
|
return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -2,8 +2,11 @@
|
|||||||
# Issue template
|
# Issue template
|
||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
|
module Errors
|
||||||
|
extend self
|
||||||
|
|
||||||
macro error_template(*args)
|
macro error_template(*args)
|
||||||
error_template_helper(env, {{args.splat}})
|
Errors.error_template_helper(env, {{args.splat}})
|
||||||
end
|
end
|
||||||
|
|
||||||
def github_details(summary : String, content : String)
|
def github_details(summary : String, content : String)
|
||||||
@ -46,7 +49,7 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
|
|||||||
|
|
||||||
# Unpacking into issue_title, issue_template directly causes a compiler error
|
# Unpacking into issue_title, issue_template directly causes a compiler error
|
||||||
# I have no idea why.
|
# I have no idea why.
|
||||||
issue_template_components = get_issue_template(env, exception)
|
issue_template_components = self.get_issue_template(env, exception)
|
||||||
issue_title, issue_template = issue_template_components
|
issue_title, issue_template = issue_template_components
|
||||||
|
|
||||||
# URLs for the error message below
|
# URLs for the error message below
|
||||||
@ -106,7 +109,7 @@ end
|
|||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
macro error_atom(*args)
|
macro error_atom(*args)
|
||||||
error_atom_helper(env, {{args.splat}})
|
Errors.error_atom_helper(env, {{args.splat}})
|
||||||
end
|
end
|
||||||
|
|
||||||
def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
|
def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
|
||||||
@ -132,7 +135,7 @@ end
|
|||||||
# -------------------
|
# -------------------
|
||||||
|
|
||||||
macro error_json(*args)
|
macro error_json(*args)
|
||||||
error_json_helper(env, {{args.splat}})
|
Errors.error_json_helper(env, {{args.splat}})
|
||||||
end
|
end
|
||||||
|
|
||||||
def error_json_helper(
|
def error_json_helper(
|
||||||
@ -209,3 +212,4 @@ def error_redirect_helper(env : HTTP::Server::Context)
|
|||||||
return ""
|
return ""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@ -83,7 +83,7 @@ class AuthHandler < Kemal::Handler
|
|||||||
if token = env.request.headers["Authorization"]?
|
if token = env.request.headers["Authorization"]?
|
||||||
token = JSON.parse(URI.decode_www_form(token.lchop("Bearer ")))
|
token = JSON.parse(URI.decode_www_form(token.lchop("Bearer ")))
|
||||||
session = URI.decode_www_form(token["session"].as_s)
|
session = URI.decode_www_form(token["session"].as_s)
|
||||||
scopes, _, _ = validate_request(token, session, env.request, HMAC_KEY, nil)
|
scopes, _, _ = Invidious::Helpers::Tokens.validate_request(token, session, env.request, HMAC_KEY, nil)
|
||||||
|
|
||||||
if email = Invidious::Database::SessionIDs.select_email(session)
|
if email = Invidious::Database::SessionIDs.select_email(session)
|
||||||
user = Invidious::Database::Users.select!(email: email)
|
user = Invidious::Database::Users.select!(email: email)
|
||||||
|
|||||||
@ -22,7 +22,7 @@ struct Annotation
|
|||||||
property annotations : String
|
property annotations : String
|
||||||
end
|
end
|
||||||
|
|
||||||
module Helpers
|
module Invidious::Helpers
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
private TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
|
private TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
|
||||||
@ -62,7 +62,7 @@ module Helpers
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_notification_stream(env, topics, connection_channel)
|
def create_notification_stream(env, topics, connection_channel)
|
||||||
connection = Channel(PQ::Notification).new(8)
|
connection = ::Channel(PQ::Notification).new(8)
|
||||||
connection_channel.send({true, connection})
|
connection_channel.send({true, connection})
|
||||||
|
|
||||||
locale = env.get("preferences").as(Preferences).locale
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
|
|||||||
@ -53,7 +53,7 @@ struct SearchVideo
|
|||||||
xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
|
xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
|
||||||
end
|
end
|
||||||
|
|
||||||
xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text Helpers.html_to_content(self.description_html) }
|
xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text Invidious::Helpers.html_to_content(self.description_html) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ struct SearchVideo
|
|||||||
xml.element("media:title") { xml.text self.title }
|
xml.element("media:title") { xml.text self.title }
|
||||||
xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
|
xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
|
||||||
width: "320", height: "180")
|
width: "320", height: "180")
|
||||||
xml.element("media:description") { xml.text Helpers.html_to_content(self.description_html) }
|
xml.element("media:description") { xml.text Invidious::Helpers.html_to_content(self.description_html) }
|
||||||
end
|
end
|
||||||
|
|
||||||
xml.element("media:community") do
|
xml.element("media:community") do
|
||||||
@ -111,7 +111,7 @@ struct SearchVideo
|
|||||||
Invidious::JSONify::APIv1.thumbnails(json, self.id)
|
Invidious::JSONify::APIv1.thumbnails(json, self.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
json.field "description", Helpers.html_to_content(self.description_html)
|
json.field "description", Invidious::Helpers.html_to_content(self.description_html)
|
||||||
json.field "descriptionHtml", self.description_html
|
json.field "descriptionHtml", self.description_html
|
||||||
|
|
||||||
json.field "viewCount", self.views
|
json.field "viewCount", self.views
|
||||||
@ -255,7 +255,7 @@ struct SearchChannel
|
|||||||
json.field "videoCount", self.video_count
|
json.field "videoCount", self.video_count
|
||||||
json.field "channelHandle", self.channel_handle
|
json.field "channelHandle", self.channel_handle
|
||||||
|
|
||||||
json.field "description", Helpers.html_to_content(self.description_html)
|
json.field "description", Invidious::Helpers.html_to_content(self.description_html)
|
||||||
json.field "descriptionHtml", self.description_html
|
json.field "descriptionHtml", self.description_html
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -332,7 +332,7 @@ struct ProblematicTimelineItem
|
|||||||
end
|
end
|
||||||
|
|
||||||
xml.element("pre") do
|
xml.element("pre") do
|
||||||
get_issue_template(env, @parse_exception)
|
Errors.get_issue_template(env, @parse_exception)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
require "crypto/subtle"
|
require "crypto/subtle"
|
||||||
|
|
||||||
|
module Invidious::Helpers::Tokens
|
||||||
|
extend self
|
||||||
|
|
||||||
def generate_token(email, scopes, expire, key)
|
def generate_token(email, scopes, expire, key)
|
||||||
session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
|
session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
|
||||||
Invidious::Database::SessionIDs.insert(session, email)
|
Invidious::Database::SessionIDs.insert(session, email)
|
||||||
@ -136,10 +139,11 @@ end
|
|||||||
|
|
||||||
def scopes_include_scope(scopes, subset)
|
def scopes_include_scope(scopes, subset)
|
||||||
scopes.each do |scope|
|
scopes.each do |scope|
|
||||||
if scope_includes_scope(scope, subset)
|
if self.scope_includes_scope(scope, subset)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@ -30,7 +30,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
|
|||||||
spawn do
|
spawn do
|
||||||
begin
|
begin
|
||||||
LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel")
|
LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel")
|
||||||
channel = fetch_channel(id, pull_all_videos: CONFIG.full_refresh)
|
channel = Invidious::Channels::Channels.fetch_channel(id, pull_all_videos: CONFIG.full_refresh)
|
||||||
|
|
||||||
lim_fibers = max_fibers
|
lim_fibers = max_fibers
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
|
|||||||
# While iterating through, we may have an email stored from a deleted account
|
# While iterating through, we may have an email stored from a deleted account
|
||||||
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
|
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
|
||||||
LOGGER.info("RefreshFeedsJob: CREATE #{view_name}")
|
LOGGER.info("RefreshFeedsJob: CREATE #{view_name}")
|
||||||
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
|
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{Invidious::User::Users::MATERIALIZED_VIEW_SQL.call(email)}")
|
||||||
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
|
|||||||
@ -1,579 +0,0 @@
|
|||||||
struct PlaylistVideo
|
|
||||||
include DB::Serializable
|
|
||||||
|
|
||||||
property title : String
|
|
||||||
property id : String
|
|
||||||
property author : String
|
|
||||||
property ucid : String
|
|
||||||
property length_seconds : Int32
|
|
||||||
property published : Time
|
|
||||||
property plid : String
|
|
||||||
property index : Int64
|
|
||||||
property live_now : Bool
|
|
||||||
|
|
||||||
def to_xml(xml : XML::Builder)
|
|
||||||
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?v=#{self.id}")
|
|
||||||
|
|
||||||
xml.element("author") do
|
|
||||||
xml.element("name") { xml.text self.author }
|
|
||||||
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
|
|
||||||
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?v=#{self.id}") do
|
|
||||||
xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
|
|
||||||
end
|
|
||||||
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")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_xml(_xml : Nil = nil)
|
|
||||||
XML.build { |xml| to_xml(xml) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(locale : String?, json : JSON::Builder)
|
|
||||||
to_json(json)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(json : JSON::Builder, index : Int32? = nil)
|
|
||||||
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
|
|
||||||
Invidious::JSONify::APIv1.thumbnails(json, self.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
if index
|
|
||||||
json.field "index", index
|
|
||||||
json.field "indexId", self.index.to_u64.to_s(16).upcase
|
|
||||||
else
|
|
||||||
json.field "index", self.index
|
|
||||||
end
|
|
||||||
|
|
||||||
json.field "lengthSeconds", self.length_seconds
|
|
||||||
json.field "liveNow", self.live_now
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(_json : Nil, index : Int32? = nil)
|
|
||||||
JSON.build { |json| to_json(json, index: index) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Playlist
|
|
||||||
include DB::Serializable
|
|
||||||
|
|
||||||
property title : String
|
|
||||||
property id : String
|
|
||||||
property author : String
|
|
||||||
property author_thumbnail : String
|
|
||||||
property ucid : String
|
|
||||||
property description : String
|
|
||||||
property description_html : String
|
|
||||||
property video_count : Int32
|
|
||||||
property views : Int64
|
|
||||||
property updated : Time
|
|
||||||
property thumbnail : String?
|
|
||||||
property subtitle : String?
|
|
||||||
|
|
||||||
def to_json(offset, json : JSON::Builder, video_id : String? = nil)
|
|
||||||
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
|
|
||||||
if !self.ucid.empty?
|
|
||||||
json.field "authorUrl", "/channel/#{self.ucid}"
|
|
||||||
else
|
|
||||||
json.field "authorUrl", ""
|
|
||||||
end
|
|
||||||
json.field "subtitle", self.subtitle
|
|
||||||
|
|
||||||
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.not_nil!.gsub(/=\d+/, "=s#{quality}")
|
|
||||||
json.field "width", quality
|
|
||||||
json.field "height", quality
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
json.field "description", self.description
|
|
||||||
json.field "descriptionHtml", self.description_html
|
|
||||||
json.field "videoCount", self.video_count
|
|
||||||
|
|
||||||
json.field "viewCount", self.views
|
|
||||||
json.field "updated", self.updated.to_unix
|
|
||||||
json.field "isListed", self.privacy.public?
|
|
||||||
|
|
||||||
json.field "videos" do
|
|
||||||
json.array do
|
|
||||||
videos = get_playlist_videos(self, offset: offset, video_id: video_id)
|
|
||||||
videos.each do |video|
|
|
||||||
video.to_json(json)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(offset, _json : Nil = nil, video_id : String? = nil)
|
|
||||||
JSON.build do |json|
|
|
||||||
to_json(offset, json, video_id: video_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def privacy
|
|
||||||
PlaylistPrivacy::Public
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
enum PlaylistPrivacy
|
|
||||||
Public = 0
|
|
||||||
Unlisted = 1
|
|
||||||
Private = 2
|
|
||||||
end
|
|
||||||
|
|
||||||
struct InvidiousPlaylist
|
|
||||||
include DB::Serializable
|
|
||||||
|
|
||||||
property title : String
|
|
||||||
property id : String
|
|
||||||
property author : String
|
|
||||||
property description : String = ""
|
|
||||||
property video_count : Int32
|
|
||||||
property created : Time
|
|
||||||
property updated : Time
|
|
||||||
|
|
||||||
@[DB::Field(converter: InvidiousPlaylist::PlaylistPrivacyConverter)]
|
|
||||||
property privacy : PlaylistPrivacy = PlaylistPrivacy::Private
|
|
||||||
property index : Array(Int64)
|
|
||||||
|
|
||||||
@[DB::Field(ignore: true)]
|
|
||||||
property thumbnail_id : String?
|
|
||||||
|
|
||||||
module PlaylistPrivacyConverter
|
|
||||||
def self.from_rs(rs)
|
|
||||||
return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8))))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(offset, json : JSON::Builder, video_id : String? = nil)
|
|
||||||
json.object do
|
|
||||||
json.field "type", "invidiousPlaylist"
|
|
||||||
json.field "title", self.title
|
|
||||||
json.field "playlistId", self.id
|
|
||||||
|
|
||||||
json.field "author", self.author
|
|
||||||
json.field "authorId", self.ucid
|
|
||||||
json.field "authorUrl", nil
|
|
||||||
json.field "authorThumbnails", [] of String
|
|
||||||
|
|
||||||
json.field "description", Helpers.html_to_content(self.description_html)
|
|
||||||
json.field "descriptionHtml", self.description_html
|
|
||||||
json.field "videoCount", self.video_count
|
|
||||||
|
|
||||||
json.field "viewCount", self.views
|
|
||||||
json.field "updated", self.updated.to_unix
|
|
||||||
json.field "isListed", self.privacy.public?
|
|
||||||
|
|
||||||
json.field "videos" do
|
|
||||||
json.array do
|
|
||||||
if (!offset || offset == 0) && !video_id.nil?
|
|
||||||
index = Invidious::Database::PlaylistVideos.select_index(self.id, video_id)
|
|
||||||
offset = self.index.index(index) || 0
|
|
||||||
end
|
|
||||||
|
|
||||||
videos = get_playlist_videos(self, offset: offset, video_id: video_id)
|
|
||||||
videos.each_with_index do |video, idx|
|
|
||||||
video.to_json(json, offset + idx)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(offset, _json : Nil = nil, video_id : String? = nil)
|
|
||||||
JSON.build do |json|
|
|
||||||
to_json(offset, json, video_id: video_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def thumbnail
|
|
||||||
# TODO: Get playlist thumbnail from playlist data rather than first video
|
|
||||||
@thumbnail_id ||= Invidious::Database::PlaylistVideos.select_one_id(self.id, self.index) || "-----------"
|
|
||||||
"/vi/#{@thumbnail_id}/mqdefault.jpg"
|
|
||||||
end
|
|
||||||
|
|
||||||
def author_thumbnail
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def ucid
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def views
|
|
||||||
0_i64
|
|
||||||
end
|
|
||||||
|
|
||||||
def description_html
|
|
||||||
HTML.escape(self.description)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_playlist(title, privacy, user)
|
|
||||||
plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}"
|
|
||||||
|
|
||||||
playlist = InvidiousPlaylist.new({
|
|
||||||
title: title.byte_slice(0, 150),
|
|
||||||
id: plid,
|
|
||||||
author: user.email,
|
|
||||||
description: "", # Max 5000 characters
|
|
||||||
video_count: 0,
|
|
||||||
created: Time.utc,
|
|
||||||
updated: Time.utc,
|
|
||||||
privacy: privacy,
|
|
||||||
index: [] of Int64,
|
|
||||||
})
|
|
||||||
|
|
||||||
Invidious::Database::Playlists.insert(playlist)
|
|
||||||
|
|
||||||
return playlist
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribe_playlist(user, playlist)
|
|
||||||
playlist = InvidiousPlaylist.new({
|
|
||||||
title: playlist.title[..150],
|
|
||||||
id: playlist.id,
|
|
||||||
author: user.email,
|
|
||||||
description: "", # Max 5000 characters
|
|
||||||
video_count: playlist.video_count,
|
|
||||||
created: Time.utc,
|
|
||||||
updated: playlist.updated,
|
|
||||||
privacy: PlaylistPrivacy::Private,
|
|
||||||
index: [] of Int64,
|
|
||||||
})
|
|
||||||
|
|
||||||
Invidious::Database::Playlists.insert(playlist)
|
|
||||||
|
|
||||||
return playlist
|
|
||||||
end
|
|
||||||
|
|
||||||
def produce_playlist_continuation(id, index)
|
|
||||||
if id.starts_with? "UC"
|
|
||||||
id = "UU" + id.lchop("UC")
|
|
||||||
end
|
|
||||||
plid = "VL" + id
|
|
||||||
|
|
||||||
# Emulate a "request counter" increment, to make perfectly valid
|
|
||||||
# ctokens, even if at the time of writing, it's ignored by youtube.
|
|
||||||
request_count = (index / 100).to_i64 || 1_i64
|
|
||||||
|
|
||||||
data = {"1:varint" => index.to_i64}
|
|
||||||
.try { |i| Protodec::Any.cast_json(i) }
|
|
||||||
.try { |i| Protodec::Any.from_json(i) }
|
|
||||||
.try { |i| Base64.urlsafe_encode(i, padding: false) }
|
|
||||||
|
|
||||||
object = {
|
|
||||||
"80226972:embedded" => {
|
|
||||||
"2:string" => plid,
|
|
||||||
"3:base64" => {
|
|
||||||
"1:varint" => request_count,
|
|
||||||
"15:string" => "PT:#{data}",
|
|
||||||
"104:embedded" => {"1:0:varint" => 0_i64},
|
|
||||||
},
|
|
||||||
"35:string" => id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation = object.try { |i| Protodec::Any.cast_json(i) }
|
|
||||||
.try { |i| Protodec::Any.from_json(i) }
|
|
||||||
.try { |i| Base64.urlsafe_encode(i) }
|
|
||||||
.try { |i| URI.encode_www_form(i) }
|
|
||||||
|
|
||||||
return continuation
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_playlist(plid : String)
|
|
||||||
if plid.starts_with? "IV"
|
|
||||||
if playlist = Invidious::Database::Playlists.select(id: plid)
|
|
||||||
return playlist
|
|
||||||
else
|
|
||||||
raise NotFoundException.new("Playlist does not exist.")
|
|
||||||
end
|
|
||||||
else
|
|
||||||
return fetch_playlist(plid)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_playlist(plid : String)
|
|
||||||
if plid.starts_with? "UC"
|
|
||||||
plid = "UU#{plid.lchop("UC")}"
|
|
||||||
end
|
|
||||||
|
|
||||||
initial_data = YoutubeAPI.browse("VL" + plid, params: "")
|
|
||||||
|
|
||||||
playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items")
|
|
||||||
raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer
|
|
||||||
|
|
||||||
playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer")
|
|
||||||
raise InfoException.new("Could not extract playlist info") if !playlist_info
|
|
||||||
|
|
||||||
title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
|
|
||||||
|
|
||||||
desc_item = playlist_info["description"]?
|
|
||||||
|
|
||||||
description_txt = desc_item.try &.["runs"]?.try &.as_a
|
|
||||||
.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
|
|
||||||
|
|
||||||
description_html = desc_item.try &.["runs"]?.try &.as_a
|
|
||||||
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
|
|
||||||
|
|
||||||
thumbnail = playlist_info.dig?(
|
|
||||||
"thumbnailRenderer", "playlistVideoThumbnailRenderer",
|
|
||||||
"thumbnail", "thumbnails", 0, "url"
|
|
||||||
).try &.as_s || playlist_info.dig?(
|
|
||||||
"thumbnailRenderer", "playlistCustomThumbnailRenderer",
|
|
||||||
"thumbnail", "thumbnails", 0, "url"
|
|
||||||
).try &.as_s
|
|
||||||
|
|
||||||
views = 0_i64
|
|
||||||
updated = Time.utc
|
|
||||||
video_count = 0
|
|
||||||
|
|
||||||
subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle"))
|
|
||||||
|
|
||||||
playlist_info["stats"]?.try &.as_a.each do |stat|
|
|
||||||
text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
|
|
||||||
next if !text
|
|
||||||
|
|
||||||
if text.includes? "video"
|
|
||||||
video_count = text.gsub(/\D/, "").to_i? || 0
|
|
||||||
elsif text.includes? "episode"
|
|
||||||
video_count = text.gsub(/\D/, "").to_i? || 0
|
|
||||||
elsif text.includes? "view"
|
|
||||||
views = text.gsub(/\D/, "").to_i64? || 0_i64
|
|
||||||
elsif !text.includes? "Pay to watch"
|
|
||||||
updated = decode_date(text.lchop("Last updated on ").lchop("Updated "))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if playlist_sidebar_renderer.size < 2
|
|
||||||
author = ""
|
|
||||||
author_thumbnail = ""
|
|
||||||
ucid = ""
|
|
||||||
else
|
|
||||||
author_info = playlist_sidebar_renderer[1].dig?(
|
|
||||||
"playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise InfoException.new("Could not extract author info") if !author_info
|
|
||||||
|
|
||||||
author = author_info.dig?("title", "runs", 0, "text").try &.as_s || ""
|
|
||||||
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || ""
|
|
||||||
ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
|
|
||||||
end
|
|
||||||
|
|
||||||
return Playlist.new({
|
|
||||||
title: title,
|
|
||||||
id: plid,
|
|
||||||
author: author,
|
|
||||||
author_thumbnail: author_thumbnail,
|
|
||||||
ucid: ucid,
|
|
||||||
description: description_txt,
|
|
||||||
description_html: description_html,
|
|
||||||
video_count: video_count,
|
|
||||||
views: views,
|
|
||||||
updated: updated,
|
|
||||||
thumbnail: thumbnail,
|
|
||||||
subtitle: subtitle,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil)
|
|
||||||
# Show empty playlist if requested page is out of range
|
|
||||||
# (e.g, when a new playlist has been created, offset will be negative)
|
|
||||||
if offset >= playlist.video_count || offset < 0
|
|
||||||
return [] of PlaylistVideo
|
|
||||||
end
|
|
||||||
|
|
||||||
if playlist.is_a? InvidiousPlaylist
|
|
||||||
Invidious::Database::PlaylistVideos.select(playlist.id, playlist.index, offset, limit: 100)
|
|
||||||
else
|
|
||||||
if video_id
|
|
||||||
initial_data = YoutubeAPI.next({
|
|
||||||
"videoId" => video_id,
|
|
||||||
"playlistId" => playlist.id,
|
|
||||||
})
|
|
||||||
offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset
|
|
||||||
end
|
|
||||||
|
|
||||||
videos = [] of PlaylistVideo | ProblematicTimelineItem
|
|
||||||
|
|
||||||
until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count
|
|
||||||
# 100 videos per request
|
|
||||||
ctoken = produce_playlist_continuation(playlist.id, offset)
|
|
||||||
initial_data = YoutubeAPI.browse(ctoken)
|
|
||||||
videos += extract_playlist_videos(playlist.id, initial_data)
|
|
||||||
|
|
||||||
offset += 100
|
|
||||||
end
|
|
||||||
|
|
||||||
return videos
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO (2026-06-24): Migrate this function to use parsers instead, as it uses,
|
|
||||||
# the same LockupViewModel used in Channel videos and Youtube playlists that
|
|
||||||
# appears on searches (Invidious /search endpoint).
|
|
||||||
# Related to https://github.com/iv-org/invidious/pull/5736
|
|
||||||
def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JSON::Any))
|
|
||||||
videos = [] of PlaylistVideo | ProblematicTimelineItem
|
|
||||||
|
|
||||||
if initial_data["contents"]?
|
|
||||||
tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]
|
|
||||||
tabs_renderer = tabs.as_a.select(&.["tabRenderer"]["selected"]?.try &.as_bool)[0]["tabRenderer"]
|
|
||||||
|
|
||||||
# Watch out the two versions, with and without "s"
|
|
||||||
if tabs_renderer["contents"]? || tabs_renderer["content"]?
|
|
||||||
# Initial playlist data
|
|
||||||
tabs_contents = tabs_renderer.["contents"]? || tabs_renderer.["content"]
|
|
||||||
|
|
||||||
list_renderer = tabs_contents.["sectionListRenderer"]["contents"][0]
|
|
||||||
contents = list_renderer.["itemSectionRenderer"]["contents"].as_a
|
|
||||||
else
|
|
||||||
# Continuation data
|
|
||||||
contents = initial_data["onResponseReceivedActions"][0]?
|
|
||||||
.try &.["appendContinuationItemsAction"]["continuationItems"].as_a
|
|
||||||
end
|
|
||||||
else
|
|
||||||
contents = initial_data["response"]?.try &.["continuationContents"]["playlistVideoListContinuation"]["contents"].as_a
|
|
||||||
end
|
|
||||||
|
|
||||||
contents.try &.each do |item|
|
|
||||||
if i = item["lockupViewModel"]?
|
|
||||||
thumbnail_view_model = i.dig?(
|
|
||||||
"contentImage", "thumbnailViewModel"
|
|
||||||
)
|
|
||||||
|
|
||||||
watch_endpoint = i.dig?("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
|
|
||||||
video_id = watch_endpoint.try &.["videoId"]?.try &.as_s
|
|
||||||
plid = watch_endpoint.try &.["playlistId"]?.try &.as_s || playlist_id
|
|
||||||
index = watch_endpoint.try &.["index"]?.try &.as_i64
|
|
||||||
|
|
||||||
metadata = i["metadata"]?
|
|
||||||
lockup_metadata_view_model = metadata.try &.dig?("lockupMetadataViewModel")
|
|
||||||
title = lockup_metadata_view_model.try &.dig?("title", "content").try &.as_s
|
|
||||||
lockup_metadata = lockup_metadata_view_model.try &.dig?("metadata")
|
|
||||||
metadata_rows = lockup_metadata.try &.dig?("contentMetadataViewModel", "metadataRows").try &.as_a
|
|
||||||
|
|
||||||
# Find the metadataParts with commandRuns inside, which contains author
|
|
||||||
# information.
|
|
||||||
metadata_parts = metadata_rows.try &.find { |row|
|
|
||||||
parts = row["metadataParts"]?.try &.as_a
|
|
||||||
parts && parts.any? { |item2| item2.dig?("text", "commandRuns").try &.as_a }
|
|
||||||
}.try &.["metadataParts"].as_a
|
|
||||||
|
|
||||||
if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns"))
|
|
||||||
.try &.["text"]
|
|
||||||
author = author_info["content"].as_s
|
|
||||||
ucid = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
|
|
||||||
.try &.as_s
|
|
||||||
end
|
|
||||||
|
|
||||||
length = thumbnail_view_model.try &.dig?("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s
|
|
||||||
length_seconds = decode_length_seconds(length) if length
|
|
||||||
|
|
||||||
live = false
|
|
||||||
|
|
||||||
if !length_seconds
|
|
||||||
live = true
|
|
||||||
length_seconds = 0
|
|
||||||
end
|
|
||||||
|
|
||||||
videos << PlaylistVideo.new({
|
|
||||||
title: title || "",
|
|
||||||
id: video_id || "",
|
|
||||||
author: author || "",
|
|
||||||
ucid: ucid || "",
|
|
||||||
length_seconds: length_seconds,
|
|
||||||
published: Time.utc,
|
|
||||||
plid: plid,
|
|
||||||
live_now: live,
|
|
||||||
index: index || -1_i64,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
rescue ex
|
|
||||||
videos << ProblematicTimelineItem.new(parse_exception: ex)
|
|
||||||
end
|
|
||||||
|
|
||||||
return videos
|
|
||||||
end
|
|
||||||
|
|
||||||
def template_playlist(playlist, listen)
|
|
||||||
html = <<-END_HTML
|
|
||||||
<h3>
|
|
||||||
<a href="/playlist?list=#{playlist["playlistId"]}">
|
|
||||||
#{playlist["title"]}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
<div class="pure-menu pure-menu-scrollable playlist-restricted">
|
|
||||||
<ol class="pure-menu-list">
|
|
||||||
END_HTML
|
|
||||||
|
|
||||||
playlist["videos"].as_a.each do |video|
|
|
||||||
html += <<-END_HTML
|
|
||||||
<li class="pure-menu-item" id="#{video["videoId"]}">
|
|
||||||
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}">
|
|
||||||
<div class="thumbnail">
|
|
||||||
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
|
|
||||||
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
|
||||||
</div>
|
|
||||||
<p style="width:100%">#{video["title"]}</p>
|
|
||||||
<p>
|
|
||||||
<b style="width:100%">#{video["author"]}</b>
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
END_HTML
|
|
||||||
end
|
|
||||||
|
|
||||||
html += <<-END_HTML
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
END_HTML
|
|
||||||
|
|
||||||
html
|
|
||||||
end
|
|
||||||
@ -27,7 +27,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
|
|||||||
|
|
||||||
video_id = "CvFH_6DNRCY" if rdid.starts_with? "OLAK5uy_"
|
video_id = "CvFH_6DNRCY" if rdid.starts_with? "OLAK5uy_"
|
||||||
response = YT_POOL.client &.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
|
response = YT_POOL.client &.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
|
||||||
initial_data = Helpers.extract_initial_data(response.body)
|
initial_data = Invidious::Helpers.extract_initial_data(response.body)
|
||||||
|
|
||||||
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
|
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
|
||||||
raise InfoException.new("Could not create mix.")
|
raise InfoException.new("Could not create mix.")
|
||||||
583
src/invidious/playlists/playlists.cr
Normal file
583
src/invidious/playlists/playlists.cr
Normal file
@ -0,0 +1,583 @@
|
|||||||
|
struct PlaylistVideo
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property title : String
|
||||||
|
property id : String
|
||||||
|
property author : String
|
||||||
|
property ucid : String
|
||||||
|
property length_seconds : Int32
|
||||||
|
property published : Time
|
||||||
|
property plid : String
|
||||||
|
property index : Int64
|
||||||
|
property live_now : Bool
|
||||||
|
|
||||||
|
def to_xml(xml : XML::Builder)
|
||||||
|
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?v=#{self.id}")
|
||||||
|
|
||||||
|
xml.element("author") do
|
||||||
|
xml.element("name") { xml.text self.author }
|
||||||
|
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
|
||||||
|
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?v=#{self.id}") do
|
||||||
|
xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
|
||||||
|
end
|
||||||
|
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")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_xml(_xml : Nil = nil)
|
||||||
|
XML.build { |xml| to_xml(xml) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(locale : String?, json : JSON::Builder)
|
||||||
|
to_json(json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(json : JSON::Builder, index : Int32? = nil)
|
||||||
|
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
|
||||||
|
Invidious::JSONify::APIv1.thumbnails(json, self.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
if index
|
||||||
|
json.field "index", index
|
||||||
|
json.field "indexId", self.index.to_u64.to_s(16).upcase
|
||||||
|
else
|
||||||
|
json.field "index", self.index
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "lengthSeconds", self.length_seconds
|
||||||
|
json.field "liveNow", self.live_now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(_json : Nil, index : Int32? = nil)
|
||||||
|
JSON.build { |json| to_json(json, index: index) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Playlist
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property title : String
|
||||||
|
property id : String
|
||||||
|
property author : String
|
||||||
|
property author_thumbnail : String
|
||||||
|
property ucid : String
|
||||||
|
property description : String
|
||||||
|
property description_html : String
|
||||||
|
property video_count : Int32
|
||||||
|
property views : Int64
|
||||||
|
property updated : Time
|
||||||
|
property thumbnail : String?
|
||||||
|
property subtitle : String?
|
||||||
|
|
||||||
|
def to_json(offset, json : JSON::Builder, video_id : String? = nil)
|
||||||
|
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
|
||||||
|
if !self.ucid.empty?
|
||||||
|
json.field "authorUrl", "/channel/#{self.ucid}"
|
||||||
|
else
|
||||||
|
json.field "authorUrl", ""
|
||||||
|
end
|
||||||
|
json.field "subtitle", self.subtitle
|
||||||
|
|
||||||
|
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.not_nil!.gsub(/=\d+/, "=s#{quality}")
|
||||||
|
json.field "width", quality
|
||||||
|
json.field "height", quality
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.field "description", self.description
|
||||||
|
json.field "descriptionHtml", self.description_html
|
||||||
|
json.field "videoCount", self.video_count
|
||||||
|
|
||||||
|
json.field "viewCount", self.views
|
||||||
|
json.field "updated", self.updated.to_unix
|
||||||
|
json.field "isListed", self.privacy.public?
|
||||||
|
|
||||||
|
json.field "videos" do
|
||||||
|
json.array do
|
||||||
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(self, offset: offset, video_id: video_id)
|
||||||
|
videos.each do |video|
|
||||||
|
video.to_json(json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(offset, _json : Nil = nil, video_id : String? = nil)
|
||||||
|
JSON.build do |json|
|
||||||
|
to_json(offset, json, video_id: video_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def privacy
|
||||||
|
PlaylistPrivacy::Public
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
enum PlaylistPrivacy
|
||||||
|
Public = 0
|
||||||
|
Unlisted = 1
|
||||||
|
Private = 2
|
||||||
|
end
|
||||||
|
|
||||||
|
struct InvidiousPlaylist
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property title : String
|
||||||
|
property id : String
|
||||||
|
property author : String
|
||||||
|
property description : String = ""
|
||||||
|
property video_count : Int32
|
||||||
|
property created : Time
|
||||||
|
property updated : Time
|
||||||
|
|
||||||
|
@[DB::Field(converter: InvidiousPlaylist::PlaylistPrivacyConverter)]
|
||||||
|
property privacy : PlaylistPrivacy = PlaylistPrivacy::Private
|
||||||
|
property index : Array(Int64)
|
||||||
|
|
||||||
|
@[DB::Field(ignore: true)]
|
||||||
|
property thumbnail_id : String?
|
||||||
|
|
||||||
|
module PlaylistPrivacyConverter
|
||||||
|
def self.from_rs(rs)
|
||||||
|
return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8))))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(offset, json : JSON::Builder, video_id : String? = nil)
|
||||||
|
json.object do
|
||||||
|
json.field "type", "invidiousPlaylist"
|
||||||
|
json.field "title", self.title
|
||||||
|
json.field "playlistId", self.id
|
||||||
|
|
||||||
|
json.field "author", self.author
|
||||||
|
json.field "authorId", self.ucid
|
||||||
|
json.field "authorUrl", nil
|
||||||
|
json.field "authorThumbnails", [] of String
|
||||||
|
|
||||||
|
json.field "description", Invidious::Helpers.html_to_content(self.description_html)
|
||||||
|
json.field "descriptionHtml", self.description_html
|
||||||
|
json.field "videoCount", self.video_count
|
||||||
|
|
||||||
|
json.field "viewCount", self.views
|
||||||
|
json.field "updated", self.updated.to_unix
|
||||||
|
json.field "isListed", self.privacy.public?
|
||||||
|
|
||||||
|
json.field "videos" do
|
||||||
|
json.array do
|
||||||
|
if (!offset || offset == 0) && !video_id.nil?
|
||||||
|
index = Invidious::Database::PlaylistVideos.select_index(self.id, video_id)
|
||||||
|
offset = self.index.index(index) || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(self, offset: offset, video_id: video_id)
|
||||||
|
videos.each_with_index do |video, idx|
|
||||||
|
video.to_json(json, offset + idx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(offset, _json : Nil = nil, video_id : String? = nil)
|
||||||
|
JSON.build do |json|
|
||||||
|
to_json(offset, json, video_id: video_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail
|
||||||
|
# TODO: Get playlist thumbnail from playlist data rather than first video
|
||||||
|
@thumbnail_id ||= Invidious::Database::PlaylistVideos.select_one_id(self.id, self.index) || "-----------"
|
||||||
|
"/vi/#{@thumbnail_id}/mqdefault.jpg"
|
||||||
|
end
|
||||||
|
|
||||||
|
def author_thumbnail
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def ucid
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def views
|
||||||
|
0_i64
|
||||||
|
end
|
||||||
|
|
||||||
|
def description_html
|
||||||
|
HTML.escape(self.description)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module Invidious::Playlists::Playlists
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def create_playlist(title, privacy, user)
|
||||||
|
plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}"
|
||||||
|
|
||||||
|
playlist = InvidiousPlaylist.new({
|
||||||
|
title: title.byte_slice(0, 150),
|
||||||
|
id: plid,
|
||||||
|
author: user.email,
|
||||||
|
description: "", # Max 5000 characters
|
||||||
|
video_count: 0,
|
||||||
|
created: Time.utc,
|
||||||
|
updated: Time.utc,
|
||||||
|
privacy: privacy,
|
||||||
|
index: [] of Int64,
|
||||||
|
})
|
||||||
|
|
||||||
|
Invidious::Database::Playlists.insert(playlist)
|
||||||
|
|
||||||
|
return playlist
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscribe_playlist(user, playlist)
|
||||||
|
playlist = InvidiousPlaylist.new({
|
||||||
|
title: playlist.title[..150],
|
||||||
|
id: playlist.id,
|
||||||
|
author: user.email,
|
||||||
|
description: "", # Max 5000 characters
|
||||||
|
video_count: playlist.video_count,
|
||||||
|
created: Time.utc,
|
||||||
|
updated: playlist.updated,
|
||||||
|
privacy: PlaylistPrivacy::Private,
|
||||||
|
index: [] of Int64,
|
||||||
|
})
|
||||||
|
|
||||||
|
Invidious::Database::Playlists.insert(playlist)
|
||||||
|
|
||||||
|
return playlist
|
||||||
|
end
|
||||||
|
|
||||||
|
def produce_playlist_continuation(id, index)
|
||||||
|
if id.starts_with? "UC"
|
||||||
|
id = "UU" + id.lchop("UC")
|
||||||
|
end
|
||||||
|
plid = "VL" + id
|
||||||
|
|
||||||
|
# Emulate a "request counter" increment, to make perfectly valid
|
||||||
|
# ctokens, even if at the time of writing, it's ignored by youtube.
|
||||||
|
request_count = (index / 100).to_i64 || 1_i64
|
||||||
|
|
||||||
|
data = {"1:varint" => index.to_i64}
|
||||||
|
.try { |i| Protodec::Any.cast_json(i) }
|
||||||
|
.try { |i| Protodec::Any.from_json(i) }
|
||||||
|
.try { |i| Base64.urlsafe_encode(i, padding: false) }
|
||||||
|
|
||||||
|
object = {
|
||||||
|
"80226972:embedded" => {
|
||||||
|
"2:string" => plid,
|
||||||
|
"3:base64" => {
|
||||||
|
"1:varint" => request_count,
|
||||||
|
"15:string" => "PT:#{data}",
|
||||||
|
"104:embedded" => {"1:0:varint" => 0_i64},
|
||||||
|
},
|
||||||
|
"35:string" => id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation = object.try { |i| Protodec::Any.cast_json(i) }
|
||||||
|
.try { |i| Protodec::Any.from_json(i) }
|
||||||
|
.try { |i| Base64.urlsafe_encode(i) }
|
||||||
|
.try { |i| URI.encode_www_form(i) }
|
||||||
|
|
||||||
|
return continuation
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_playlist(plid : String)
|
||||||
|
if plid.starts_with? "IV"
|
||||||
|
if playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
|
return playlist
|
||||||
|
else
|
||||||
|
raise NotFoundException.new("Playlist does not exist.")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return fetch_playlist(plid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_playlist(plid : String)
|
||||||
|
if plid.starts_with? "UC"
|
||||||
|
plid = "UU#{plid.lchop("UC")}"
|
||||||
|
end
|
||||||
|
|
||||||
|
initial_data = YoutubeAPI.browse("VL" + plid, params: "")
|
||||||
|
|
||||||
|
playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items")
|
||||||
|
raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer
|
||||||
|
|
||||||
|
playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer")
|
||||||
|
raise InfoException.new("Could not extract playlist info") if !playlist_info
|
||||||
|
|
||||||
|
title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
|
||||||
|
|
||||||
|
desc_item = playlist_info["description"]?
|
||||||
|
|
||||||
|
description_txt = desc_item.try &.["runs"]?.try &.as_a
|
||||||
|
.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
|
||||||
|
|
||||||
|
description_html = desc_item.try &.["runs"]?.try &.as_a
|
||||||
|
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
|
||||||
|
|
||||||
|
thumbnail = playlist_info.dig?(
|
||||||
|
"thumbnailRenderer", "playlistVideoThumbnailRenderer",
|
||||||
|
"thumbnail", "thumbnails", 0, "url"
|
||||||
|
).try &.as_s || playlist_info.dig?(
|
||||||
|
"thumbnailRenderer", "playlistCustomThumbnailRenderer",
|
||||||
|
"thumbnail", "thumbnails", 0, "url"
|
||||||
|
).try &.as_s
|
||||||
|
|
||||||
|
views = 0_i64
|
||||||
|
updated = Time.utc
|
||||||
|
video_count = 0
|
||||||
|
|
||||||
|
subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle"))
|
||||||
|
|
||||||
|
playlist_info["stats"]?.try &.as_a.each do |stat|
|
||||||
|
text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
|
||||||
|
next if !text
|
||||||
|
|
||||||
|
if text.includes? "video"
|
||||||
|
video_count = text.gsub(/\D/, "").to_i? || 0
|
||||||
|
elsif text.includes? "episode"
|
||||||
|
video_count = text.gsub(/\D/, "").to_i? || 0
|
||||||
|
elsif text.includes? "view"
|
||||||
|
views = text.gsub(/\D/, "").to_i64? || 0_i64
|
||||||
|
elsif !text.includes? "Pay to watch"
|
||||||
|
updated = decode_date(text.lchop("Last updated on ").lchop("Updated "))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if playlist_sidebar_renderer.size < 2
|
||||||
|
author = ""
|
||||||
|
author_thumbnail = ""
|
||||||
|
ucid = ""
|
||||||
|
else
|
||||||
|
author_info = playlist_sidebar_renderer[1].dig?(
|
||||||
|
"playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise InfoException.new("Could not extract author info") if !author_info
|
||||||
|
|
||||||
|
author = author_info.dig?("title", "runs", 0, "text").try &.as_s || ""
|
||||||
|
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || ""
|
||||||
|
ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
|
||||||
|
end
|
||||||
|
|
||||||
|
return Playlist.new({
|
||||||
|
title: title,
|
||||||
|
id: plid,
|
||||||
|
author: author,
|
||||||
|
author_thumbnail: author_thumbnail,
|
||||||
|
ucid: ucid,
|
||||||
|
description: description_txt,
|
||||||
|
description_html: description_html,
|
||||||
|
video_count: video_count,
|
||||||
|
views: views,
|
||||||
|
updated: updated,
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
subtitle: subtitle,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil)
|
||||||
|
# Show empty playlist if requested page is out of range
|
||||||
|
# (e.g, when a new playlist has been created, offset will be negative)
|
||||||
|
if offset >= playlist.video_count || offset < 0
|
||||||
|
return [] of PlaylistVideo
|
||||||
|
end
|
||||||
|
|
||||||
|
if playlist.is_a? InvidiousPlaylist
|
||||||
|
Invidious::Database::PlaylistVideos.select(playlist.id, playlist.index, offset, limit: 100)
|
||||||
|
else
|
||||||
|
if video_id
|
||||||
|
initial_data = YoutubeAPI.next({
|
||||||
|
"videoId" => video_id,
|
||||||
|
"playlistId" => playlist.id,
|
||||||
|
})
|
||||||
|
offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset
|
||||||
|
end
|
||||||
|
|
||||||
|
videos = [] of PlaylistVideo | ProblematicTimelineItem
|
||||||
|
|
||||||
|
until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count
|
||||||
|
# 100 videos per request
|
||||||
|
ctoken = produce_playlist_continuation(playlist.id, offset)
|
||||||
|
initial_data = YoutubeAPI.browse(ctoken)
|
||||||
|
videos += extract_playlist_videos(playlist.id, initial_data)
|
||||||
|
|
||||||
|
offset += 100
|
||||||
|
end
|
||||||
|
|
||||||
|
return videos
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO (2026-06-24): Migrate this function to use parsers instead, as it uses,
|
||||||
|
# the same LockupViewModel used in Channel videos and Youtube playlists that
|
||||||
|
# appears on searches (Invidious /search endpoint).
|
||||||
|
# Related to https://github.com/iv-org/invidious/pull/5736
|
||||||
|
def extract_playlist_videos(playlist_id : String, initial_data : Hash(String, JSON::Any))
|
||||||
|
videos = [] of PlaylistVideo | ProblematicTimelineItem
|
||||||
|
|
||||||
|
if initial_data["contents"]?
|
||||||
|
tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]
|
||||||
|
tabs_renderer = tabs.as_a.select(&.["tabRenderer"]["selected"]?.try &.as_bool)[0]["tabRenderer"]
|
||||||
|
|
||||||
|
# Watch out the two versions, with and without "s"
|
||||||
|
if tabs_renderer["contents"]? || tabs_renderer["content"]?
|
||||||
|
# Initial playlist data
|
||||||
|
tabs_contents = tabs_renderer.["contents"]? || tabs_renderer.["content"]
|
||||||
|
|
||||||
|
list_renderer = tabs_contents.["sectionListRenderer"]["contents"][0]
|
||||||
|
contents = list_renderer.["itemSectionRenderer"]["contents"].as_a
|
||||||
|
else
|
||||||
|
# Continuation data
|
||||||
|
contents = initial_data["onResponseReceivedActions"][0]?
|
||||||
|
.try &.["appendContinuationItemsAction"]["continuationItems"].as_a
|
||||||
|
end
|
||||||
|
else
|
||||||
|
contents = initial_data["response"]?.try &.["continuationContents"]["playlistVideoListContinuation"]["contents"].as_a
|
||||||
|
end
|
||||||
|
|
||||||
|
contents.try &.each do |item|
|
||||||
|
if i = item["lockupViewModel"]?
|
||||||
|
thumbnail_view_model = i.dig?(
|
||||||
|
"contentImage", "thumbnailViewModel"
|
||||||
|
)
|
||||||
|
|
||||||
|
watch_endpoint = i.dig?("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint")
|
||||||
|
video_id = watch_endpoint.try &.["videoId"]?.try &.as_s
|
||||||
|
plid = watch_endpoint.try &.["playlistId"]?.try &.as_s || playlist_id
|
||||||
|
index = watch_endpoint.try &.["index"]?.try &.as_i64
|
||||||
|
|
||||||
|
metadata = i["metadata"]?
|
||||||
|
lockup_metadata_view_model = metadata.try &.dig?("lockupMetadataViewModel")
|
||||||
|
title = lockup_metadata_view_model.try &.dig?("title", "content").try &.as_s
|
||||||
|
lockup_metadata = lockup_metadata_view_model.try &.dig?("metadata")
|
||||||
|
metadata_rows = lockup_metadata.try &.dig?("contentMetadataViewModel", "metadataRows").try &.as_a
|
||||||
|
|
||||||
|
# Find the metadataParts with commandRuns inside, which contains author
|
||||||
|
# information.
|
||||||
|
metadata_parts = metadata_rows.try &.find { |row|
|
||||||
|
parts = row["metadataParts"]?.try &.as_a
|
||||||
|
parts && parts.any? { |item2| item2.dig?("text", "commandRuns").try &.as_a }
|
||||||
|
}.try &.["metadataParts"].as_a
|
||||||
|
|
||||||
|
if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns"))
|
||||||
|
.try &.["text"]
|
||||||
|
author = author_info["content"].as_s
|
||||||
|
ucid = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
|
||||||
|
.try &.as_s
|
||||||
|
end
|
||||||
|
|
||||||
|
length = thumbnail_view_model.try &.dig?("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s
|
||||||
|
length_seconds = decode_length_seconds(length) if length
|
||||||
|
|
||||||
|
live = false
|
||||||
|
|
||||||
|
if !length_seconds
|
||||||
|
live = true
|
||||||
|
length_seconds = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
videos << PlaylistVideo.new({
|
||||||
|
title: title || "",
|
||||||
|
id: video_id || "",
|
||||||
|
author: author || "",
|
||||||
|
ucid: ucid || "",
|
||||||
|
length_seconds: length_seconds,
|
||||||
|
published: Time.utc,
|
||||||
|
plid: plid,
|
||||||
|
live_now: live,
|
||||||
|
index: index || -1_i64,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
rescue ex
|
||||||
|
videos << ProblematicTimelineItem.new(parse_exception: ex)
|
||||||
|
end
|
||||||
|
|
||||||
|
return videos
|
||||||
|
end
|
||||||
|
|
||||||
|
def template_playlist(playlist, listen)
|
||||||
|
html = <<-END_HTML
|
||||||
|
<h3>
|
||||||
|
<a href="/playlist?list=#{playlist["playlistId"]}">
|
||||||
|
#{playlist["title"]}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<div class="pure-menu pure-menu-scrollable playlist-restricted">
|
||||||
|
<ol class="pure-menu-list">
|
||||||
|
END_HTML
|
||||||
|
|
||||||
|
playlist["videos"].as_a.each do |video|
|
||||||
|
html += <<-END_HTML
|
||||||
|
<li class="pure-menu-item" id="#{video["videoId"]}">
|
||||||
|
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}#{listen ? "&listen=1" : ""}">
|
||||||
|
<div class="thumbnail">
|
||||||
|
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" />
|
||||||
|
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
||||||
|
</div>
|
||||||
|
<p style="width:100%">#{video["title"]}</p>
|
||||||
|
<p>
|
||||||
|
<b style="width:100%">#{video["author"]}</b>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
END_HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
html += <<-END_HTML
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
END_HTML
|
||||||
|
|
||||||
|
html
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -21,7 +21,7 @@ module Invidious::Routes::Account
|
|||||||
|
|
||||||
user = user.as(User)
|
user = user.as(User)
|
||||||
sid = sid.as(String)
|
sid = sid.as(String)
|
||||||
csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":change_password"}, HMAC_KEY)
|
||||||
|
|
||||||
templated "user/change_password"
|
templated "user/change_password"
|
||||||
end
|
end
|
||||||
@ -43,33 +43,33 @@ module Invidious::Routes::Account
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
password = env.params.body["password"]?
|
password = env.params.body["password"]?
|
||||||
if password.nil? || password.empty?
|
if password.nil? || password.empty?
|
||||||
return error_template(401, "Password is a required field")
|
return Errors.error_template(401, "Password is a required field")
|
||||||
end
|
end
|
||||||
|
|
||||||
new_passwords = env.params.body.select { |k, _| k.match(/^new_password\[\d+\]$/) }.map { |_, v| v }
|
new_passwords = env.params.body.select { |k, _| k.match(/^new_password\[\d+\]$/) }.map { |_, v| v }
|
||||||
|
|
||||||
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
|
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
|
||||||
return error_template(400, "New passwords must match")
|
return Errors.error_template(400, "New passwords must match")
|
||||||
end
|
end
|
||||||
|
|
||||||
new_password = new_passwords.uniq[0]
|
new_password = new_passwords.uniq[0]
|
||||||
if new_password.empty?
|
if new_password.empty?
|
||||||
return error_template(401, "Password cannot be empty")
|
return Errors.error_template(401, "Password cannot be empty")
|
||||||
end
|
end
|
||||||
|
|
||||||
if new_password.bytesize > 55
|
if new_password.bytesize > 55
|
||||||
return error_template(400, "Password cannot be longer than 55 characters")
|
return Errors.error_template(400, "Password cannot be longer than 55 characters")
|
||||||
end
|
end
|
||||||
|
|
||||||
if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
|
if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
|
||||||
return error_template(401, "Incorrect password")
|
return Errors.error_template(401, "Incorrect password")
|
||||||
end
|
end
|
||||||
|
|
||||||
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
|
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
|
||||||
@ -96,7 +96,7 @@ module Invidious::Routes::Account
|
|||||||
|
|
||||||
user = user.as(User)
|
user = user.as(User)
|
||||||
sid = sid.as(String)
|
sid = sid.as(String)
|
||||||
csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":delete_account"}, HMAC_KEY)
|
||||||
|
|
||||||
templated "user/delete_account"
|
templated "user/delete_account"
|
||||||
end
|
end
|
||||||
@ -118,9 +118,9 @@ module Invidious::Routes::Account
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
view_name = "subscriptions_#{sha256(user.email)}"
|
view_name = "subscriptions_#{sha256(user.email)}"
|
||||||
@ -154,7 +154,7 @@ module Invidious::Routes::Account
|
|||||||
|
|
||||||
user = user.as(User)
|
user = user.as(User)
|
||||||
sid = sid.as(String)
|
sid = sid.as(String)
|
||||||
csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
|
||||||
|
|
||||||
templated "user/clear_watch_history"
|
templated "user/clear_watch_history"
|
||||||
end
|
end
|
||||||
@ -176,9 +176,9 @@ module Invidious::Routes::Account
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Database::Users.clear_watch_history(user)
|
Invidious::Database::Users.clear_watch_history(user)
|
||||||
@ -203,7 +203,7 @@ module Invidious::Routes::Account
|
|||||||
|
|
||||||
user = user.as(User)
|
user = user.as(User)
|
||||||
sid = sid.as(String)
|
sid = sid.as(String)
|
||||||
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":authorize_token"}, HMAC_KEY)
|
||||||
|
|
||||||
scopes = env.params.query["scopes"]?.try &.split(",")
|
scopes = env.params.query["scopes"]?.try &.split(",")
|
||||||
scopes ||= [] of String
|
scopes ||= [] of String
|
||||||
@ -235,16 +235,16 @@ module Invidious::Routes::Account
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v }
|
scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v }
|
||||||
callback_url = env.params.body["callbackUrl"]?
|
callback_url = env.params.body["callbackUrl"]?
|
||||||
expire = env.params.body["expire"]?.try &.to_i?
|
expire = env.params.body["expire"]?.try &.to_i?
|
||||||
|
|
||||||
access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
|
access_token = Invidious::Helpers::Tokens.generate_token(user.email, scopes, expire, HMAC_KEY)
|
||||||
|
|
||||||
if callback_url
|
if callback_url
|
||||||
access_token = URI.encode_www_form(access_token)
|
access_token = URI.encode_www_form(access_token)
|
||||||
@ -310,7 +310,7 @@ module Invidious::Routes::Account
|
|||||||
if redirect
|
if redirect
|
||||||
return env.redirect referer
|
return env.redirect referer
|
||||||
else
|
else
|
||||||
return error_json(403, "No such user")
|
return Errors.error_json(403, "No such user")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -319,12 +319,12 @@ module Invidious::Routes::Account
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
if redirect
|
if redirect
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
else
|
else
|
||||||
return error_json(400, ex)
|
return Errors.error_json(400, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -333,7 +333,7 @@ module Invidious::Routes::Account
|
|||||||
session = env.params.query["session"]
|
session = env.params.query["session"]
|
||||||
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
|
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
|
||||||
else
|
else
|
||||||
return error_json(400, "Unsupported action #{action}")
|
return Errors.error_json(400, "Unsupported action #{action}")
|
||||||
end
|
end
|
||||||
|
|
||||||
if redirect
|
if redirect
|
||||||
|
|||||||
@ -8,7 +8,7 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
# topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
|
# topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
|
||||||
# topics ||= [] of String
|
# topics ||= [] of String
|
||||||
|
|
||||||
# Helpers.create_notification_stream(env, topics, connection_channel)
|
# Invidious::Helpers.create_notification_stream(env, topics, connection_channel)
|
||||||
# end
|
# end
|
||||||
|
|
||||||
def self.get_preferences(env)
|
def self.get_preferences(env)
|
||||||
@ -78,13 +78,13 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
user = env.get("user").as(User)
|
user = env.get("user").as(User)
|
||||||
|
|
||||||
if !user.preferences.watch_history
|
if !user.preferences.watch_history
|
||||||
return error_json(409, "Watch history is disabled in preferences.")
|
return Errors.error_json(409, "Watch history is disabled in preferences.")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sanity checks
|
# Sanity checks
|
||||||
id = env.params.url["id"]
|
id = env.params.url["id"]
|
||||||
unless validate_video_id(id)
|
unless validate_video_id(id)
|
||||||
return error_json(400, InvalidVideoID.new(id))
|
return Errors.error_json(400, InvalidVideoID.new(id))
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Database::Users.mark_watched(user, id)
|
Invidious::Database::Users.mark_watched(user, id)
|
||||||
@ -95,12 +95,12 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
user = env.get("user").as(User)
|
user = env.get("user").as(User)
|
||||||
|
|
||||||
if !user.preferences.watch_history
|
if !user.preferences.watch_history
|
||||||
return error_json(409, "Watch history is disabled in preferences.")
|
return Errors.error_json(409, "Watch history is disabled in preferences.")
|
||||||
end
|
end
|
||||||
|
|
||||||
video_id = env.params.url["id"]
|
video_id = env.params.url["id"]
|
||||||
unless video_id && validate_video_id(video_id)
|
unless video_id && validate_video_id(video_id)
|
||||||
return error_json(400, InvalidVideoID.new(video_id))
|
return Errors.error_json(400, InvalidVideoID.new(video_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Database::Users.mark_unwatched(user, video_id)
|
Invidious::Database::Users.mark_unwatched(user, video_id)
|
||||||
@ -127,7 +127,7 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
page = env.params.query["page"]?.try &.to_i?
|
page = env.params.query["page"]?.try &.to_i?
|
||||||
page ||= 1
|
page ||= 1
|
||||||
|
|
||||||
videos, notifications = get_subscription_feed(user, max_results, page)
|
videos, notifications = Invidious::User::Users.get_subscription_feed(user, max_results, page)
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
@ -175,7 +175,7 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
ucid = env.params.url["ucid"]
|
ucid = env.params.url["ucid"]
|
||||||
|
|
||||||
if !user.subscriptions.includes? ucid
|
if !user.subscriptions.includes? ucid
|
||||||
get_channel(ucid)
|
Invidious::Channels::Channels.get_channel(ucid)
|
||||||
Invidious::Database::Users.subscribe_channel(user, ucid)
|
Invidious::Database::Users.subscribe_channel(user, ucid)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -214,19 +214,19 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
|
|
||||||
title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150)
|
title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150)
|
||||||
if !title
|
if !title
|
||||||
return error_json(400, "Invalid title.")
|
return Errors.error_json(400, "Invalid title.")
|
||||||
end
|
end
|
||||||
|
|
||||||
privacy = env.params.json["privacy"]?.try { |p| PlaylistPrivacy.parse(p.as(String).downcase) }
|
privacy = env.params.json["privacy"]?.try { |p| PlaylistPrivacy.parse(p.as(String).downcase) }
|
||||||
if !privacy
|
if !privacy
|
||||||
return error_json(400, "Invalid privacy setting.")
|
return Errors.error_json(400, "Invalid privacy setting.")
|
||||||
end
|
end
|
||||||
|
|
||||||
if Invidious::Database::Playlists.count_owned_by(user.email) >= 100
|
if Invidious::Database::Playlists.count_owned_by(user.email) >= 100
|
||||||
return error_json(400, "User cannot have more than 100 playlists.")
|
return Errors.error_json(400, "User cannot have more than 100 playlists.")
|
||||||
end
|
end
|
||||||
|
|
||||||
playlist = create_playlist(title, privacy, user)
|
playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
|
||||||
env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}"
|
env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}"
|
||||||
env.response.status_code = 201
|
env.response.status_code = 201
|
||||||
{
|
{
|
||||||
@ -241,16 +241,16 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
|
|
||||||
plid = env.params.url["plid"]?
|
plid = env.params.url["plid"]?
|
||||||
if !plid || plid.empty?
|
if !plid || plid.empty?
|
||||||
return error_json(400, "A playlist ID is required")
|
return Errors.error_json(400, "A playlist ID is required")
|
||||||
end
|
end
|
||||||
|
|
||||||
playlist = Invidious::Database::Playlists.select(id: plid)
|
playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
if !playlist || playlist.author != user.email && playlist.privacy.private?
|
if !playlist || playlist.author != user.email && playlist.privacy.private?
|
||||||
return error_json(404, "Playlist does not exist.")
|
return Errors.error_json(404, "Playlist does not exist.")
|
||||||
end
|
end
|
||||||
|
|
||||||
if playlist.author != user.email
|
if playlist.author != user.email
|
||||||
return error_json(403, "Invalid user")
|
return Errors.error_json(403, "Invalid user")
|
||||||
end
|
end
|
||||||
|
|
||||||
title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title
|
title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title
|
||||||
@ -278,11 +278,11 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
|
|
||||||
playlist = Invidious::Database::Playlists.select(id: plid)
|
playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
if !playlist || playlist.author != user.email && playlist.privacy.private?
|
if !playlist || playlist.author != user.email && playlist.privacy.private?
|
||||||
return error_json(404, "Playlist does not exist.")
|
return Errors.error_json(404, "Playlist does not exist.")
|
||||||
end
|
end
|
||||||
|
|
||||||
if playlist.author != user.email
|
if playlist.author != user.email
|
||||||
return error_json(403, "Invalid user")
|
return Errors.error_json(403, "Invalid user")
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Database::Playlists.delete(plid)
|
Invidious::Database::Playlists.delete(plid)
|
||||||
@ -298,29 +298,29 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
|
|
||||||
playlist = Invidious::Database::Playlists.select(id: plid)
|
playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
if !playlist || playlist.author != user.email && playlist.privacy.private?
|
if !playlist || playlist.author != user.email && playlist.privacy.private?
|
||||||
return error_json(404, "Playlist does not exist.")
|
return Errors.error_json(404, "Playlist does not exist.")
|
||||||
end
|
end
|
||||||
|
|
||||||
if playlist.author != user.email
|
if playlist.author != user.email
|
||||||
return error_json(403, "Invalid user")
|
return Errors.error_json(403, "Invalid user")
|
||||||
end
|
end
|
||||||
|
|
||||||
if playlist.index.size >= CONFIG.playlist_length_limit
|
if playlist.index.size >= CONFIG.playlist_length_limit
|
||||||
return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
return Errors.error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
||||||
end
|
end
|
||||||
|
|
||||||
video_id = env.params.json["videoId"].try &.as(String)
|
video_id = env.params.json["videoId"].try &.as(String)
|
||||||
# Sanity checks
|
# Sanity checks
|
||||||
unless video_id && validate_video_id(video_id)
|
unless video_id && validate_video_id(video_id)
|
||||||
return error_json(400, InvalidVideoID.new(video_id))
|
return Errors.error_json(400, InvalidVideoID.new(video_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
video = get_video(video_id)
|
video = get_video(video_id)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
playlist_video = PlaylistVideo.new({
|
playlist_video = PlaylistVideo.new({
|
||||||
@ -355,15 +355,15 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
|
|
||||||
playlist = Invidious::Database::Playlists.select(id: plid)
|
playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
if !playlist || playlist.author != user.email && playlist.privacy.private?
|
if !playlist || playlist.author != user.email && playlist.privacy.private?
|
||||||
return error_json(404, "Playlist does not exist.")
|
return Errors.error_json(404, "Playlist does not exist.")
|
||||||
end
|
end
|
||||||
|
|
||||||
if playlist.author != user.email
|
if playlist.author != user.email
|
||||||
return error_json(403, "Invalid user")
|
return Errors.error_json(403, "Invalid user")
|
||||||
end
|
end
|
||||||
|
|
||||||
if !playlist.index.includes? index
|
if !playlist.index.includes? index
|
||||||
return error_json(404, "Playlist does not contain index")
|
return Errors.error_json(404, "Playlist does not contain index")
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Database::PlaylistVideos.delete(index, plid)
|
Invidious::Database::PlaylistVideos.delete(index, plid)
|
||||||
@ -411,7 +411,7 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
callback_url = env.params.json["callbackUrl"]?.try &.as(String)
|
callback_url = env.params.json["callbackUrl"]?.try &.as(String)
|
||||||
expire = env.params.json["expire"]?.try &.as(Int64)
|
expire = env.params.json["expire"]?.try &.as(Int64)
|
||||||
else
|
else
|
||||||
return error_json(400, "Invalid or missing header 'Content-Type'")
|
return Errors.error_json(400, "Invalid or missing header 'Content-Type'")
|
||||||
end
|
end
|
||||||
|
|
||||||
if callback_url && callback_url.empty?
|
if callback_url && callback_url.empty?
|
||||||
@ -427,7 +427,7 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
|
|
||||||
# Used by template bellow.
|
# Used by template bellow.
|
||||||
# ameba:disable Lint/UselessAssign
|
# ameba:disable Lint/UselessAssign
|
||||||
csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
|
||||||
return templated "user/authorize_token"
|
return templated "user/authorize_token"
|
||||||
else
|
else
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
@ -436,12 +436,12 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
|
|
||||||
authorized_scopes = [] of String
|
authorized_scopes = [] of String
|
||||||
scopes.each do |scope|
|
scopes.each do |scope|
|
||||||
if scopes_include_scope(superset_scopes, scope)
|
if Invidious::Helpers::Tokens.scopes_include_scope(superset_scopes, scope)
|
||||||
authorized_scopes << scope
|
authorized_scopes << scope
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY)
|
access_token = Invidious::Helpers::Tokens.generate_token(user.email, authorized_scopes, expire, HMAC_KEY)
|
||||||
|
|
||||||
if callback_url
|
if callback_url
|
||||||
access_token = URI.encode_www_form(access_token)
|
access_token = URI.encode_www_form(access_token)
|
||||||
@ -473,10 +473,10 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
# Allow tokens to revoke other tokens with correct scope
|
# Allow tokens to revoke other tokens with correct scope
|
||||||
if session == env.get("session").as(String)
|
if session == env.get("session").as(String)
|
||||||
Invidious::Database::SessionIDs.delete(sid: session)
|
Invidious::Database::SessionIDs.delete(sid: session)
|
||||||
elsif scopes_include_scope(scopes, "GET:tokens")
|
elsif Invidious::Helpers::Tokens.scopes_include_scope(scopes, "GET:tokens")
|
||||||
Invidious::Database::SessionIDs.delete(sid: session)
|
Invidious::Database::SessionIDs.delete(sid: session)
|
||||||
else
|
else
|
||||||
return error_json(400, "Cannot revoke session #{session}")
|
return Errors.error_json(400, "Cannot revoke session #{session}")
|
||||||
end
|
end
|
||||||
|
|
||||||
env.response.status_code = 204
|
env.response.status_code = 204
|
||||||
@ -489,6 +489,6 @@ module Invidious::Routes::API::V1::Authenticated
|
|||||||
topics = raw_topics.try &.split(",").uniq!.first(1000)
|
topics = raw_topics.try &.split(",").uniq!.first(1000)
|
||||||
topics ||= [] of String
|
topics ||= [] of String
|
||||||
|
|
||||||
Helpers.create_notification_stream(env, topics, CONNECTION_CHANNEL)
|
Invidious::Helpers.create_notification_stream(env, topics, CONNECTION_CHANNEL)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -3,14 +3,14 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
# This sets the `channel` variable, or handles Exceptions.
|
# This sets the `channel` variable, or handles Exceptions.
|
||||||
private macro get_channel
|
private macro get_channel
|
||||||
begin
|
begin
|
||||||
channel = get_about_info(ucid)
|
channel = Invidious::Channels::About.get_about_info(ucid)
|
||||||
rescue ex : ChannelRedirect
|
rescue ex : ChannelRedirect
|
||||||
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
|
||||||
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
return Errors.error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -29,8 +29,8 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
|
|
||||||
if channel.is_age_gated
|
if channel.is_age_gated
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
|
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
|
||||||
videos = get_playlist_videos(playlist, offset: 0)
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
# playlist doesnt exist.
|
# playlist doesnt exist.
|
||||||
videos = [] of PlaylistVideo
|
videos = [] of PlaylistVideo
|
||||||
@ -39,7 +39,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
begin
|
begin
|
||||||
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
|
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -96,7 +96,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
json.field "autoGenerated", channel.auto_generated
|
json.field "autoGenerated", channel.auto_generated
|
||||||
json.field "ageGated", channel.is_age_gated
|
json.field "ageGated", channel.is_age_gated
|
||||||
json.field "isFamilyFriendly", channel.is_family_friendly
|
json.field "isFamilyFriendly", channel.is_family_friendly
|
||||||
json.field "description", Helpers.html_to_content(channel.description_html)
|
json.field "description", Invidious::Helpers.html_to_content(channel.description_html)
|
||||||
json.field "descriptionHtml", channel.description_html
|
json.field "descriptionHtml", channel.description_html
|
||||||
|
|
||||||
json.field "allowedRegions", channel.allowed_regions
|
json.field "allowedRegions", channel.allowed_regions
|
||||||
@ -117,7 +117,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
json.array do
|
json.array do
|
||||||
# Fetch related channels
|
# Fetch related channels
|
||||||
begin
|
begin
|
||||||
related_channels, _ = fetch_related_channels(channel)
|
related_channels, _ = Invidious::Channels::About.fetch_related_channels(channel)
|
||||||
rescue ex
|
rescue ex
|
||||||
related_channels = [] of SearchChannel
|
related_channels = [] of SearchChannel
|
||||||
end
|
end
|
||||||
@ -155,8 +155,8 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
|
|
||||||
if channel.is_age_gated
|
if channel.is_age_gated
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
|
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
|
||||||
videos = get_playlist_videos(playlist, offset: 0)
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
# playlist doesnt exist.
|
# playlist doesnt exist.
|
||||||
videos = [] of PlaylistVideo
|
videos = [] of PlaylistVideo
|
||||||
@ -168,7 +168,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
channel, continuation: continuation, sort_by: sort_by
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
)
|
)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -201,8 +201,8 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
|
|
||||||
if channel.is_age_gated
|
if channel.is_age_gated
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
|
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UUSH"))
|
||||||
videos = get_playlist_videos(playlist, offset: 0)
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
# playlist doesnt exist.
|
# playlist doesnt exist.
|
||||||
videos = [] of PlaylistVideo
|
videos = [] of PlaylistVideo
|
||||||
@ -214,7 +214,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
channel, continuation: continuation, sort_by: sort_by
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
)
|
)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -247,8 +247,8 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
|
|
||||||
if channel.is_age_gated
|
if channel.is_age_gated
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
|
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULV"))
|
||||||
videos = get_playlist_videos(playlist, offset: 0)
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
# playlist doesnt exist.
|
# playlist doesnt exist.
|
||||||
videos = [] of PlaylistVideo
|
videos = [] of PlaylistVideo
|
||||||
@ -260,7 +260,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
channel, continuation: continuation, sort_by: sort_by
|
channel, continuation: continuation, sort_by: sort_by
|
||||||
)
|
)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -292,7 +292,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
channel = nil # Make the compiler happy
|
channel = nil # Make the compiler happy
|
||||||
get_channel()
|
get_channel()
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
@ -321,7 +321,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
channel = nil # Make the compiler happy
|
channel = nil # Make the compiler happy
|
||||||
get_channel()
|
get_channel()
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_podcasts(channel.ucid, channel.author, continuation)
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_podcasts(channel.ucid, channel.author, continuation)
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
@ -350,7 +350,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
channel = nil # Make the compiler happy
|
channel = nil # Make the compiler happy
|
||||||
get_channel()
|
get_channel()
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_releases(channel.ucid, channel.author, continuation)
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_releases(channel.ucid, channel.author, continuation)
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
@ -379,7 +379,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
channel = nil # Make the compiler happy
|
channel = nil # Make the compiler happy
|
||||||
get_channel()
|
get_channel()
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation)
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_courses(channel.ucid, channel.author, continuation)
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
@ -413,9 +413,9 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
# sort_by = env.params.query["sort_by"]?.try &.downcase
|
# sort_by = env.params.query["sort_by"]?.try &.downcase
|
||||||
|
|
||||||
begin
|
begin
|
||||||
fetch_channel_community(ucid, continuation, locale, format, thin_mode)
|
Invidious::Channels::Community.fetch_channel_community(ucid, continuation, locale, format, thin_mode)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -434,16 +434,16 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
|
|
||||||
if ucid.nil?
|
if ucid.nil?
|
||||||
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
|
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
|
||||||
return error_json(400, "Invalid post ID") if response["error"]?
|
return Errors.error_json(400, "Invalid post ID") if response["error"]?
|
||||||
ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
|
ucid = Invidious::Channels::Community.decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
|
||||||
else
|
else
|
||||||
ucid = ucid.to_s
|
ucid = ucid.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
fetch_channel_community_post(ucid, id, locale, format, thin_mode)
|
Invidious::Channels::Community.fetch_channel_community_post(ucid, id, locale, format, thin_mode)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -487,9 +487,9 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
continuation = env.params.query["continuation"]?
|
continuation = env.params.query["continuation"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
items, next_continuation = fetch_related_channels(channel, continuation)
|
items, next_continuation = Invidious::Channels::About.fetch_related_channels(channel, continuation)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
@ -520,7 +520,7 @@ module Invidious::Routes::API::V1::Channels
|
|||||||
begin
|
begin
|
||||||
search_results = query.process
|
search_results = query.process
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(400, ex)
|
return Errors.error_json(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
|
|||||||
@ -8,9 +8,9 @@ module Invidious::Routes::API::V1::Feeds
|
|||||||
trending_type = env.params.query["type"]?
|
trending_type = env.params.query["type"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
trending, _ = fetch_trending(trending_type, region, locale)
|
trending, _ = Invidious::Feeds::Trending.fetch(trending_type, region, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
videos = JSON.build do |json|
|
videos = JSON.build do |json|
|
||||||
|
|||||||
@ -50,16 +50,16 @@ module Invidious::Routes::API::V1::Misc
|
|||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(plid)
|
playlist = Invidious::Playlists::Playlists.get_playlist(plid)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(404, "Playlist does not exist.")
|
return Errors.error_json(404, "Playlist does not exist.")
|
||||||
end
|
end
|
||||||
|
|
||||||
user = env.get?("user").try &.as(User)
|
user = env.get?("user").try &.as(User)
|
||||||
if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
|
if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
|
||||||
return error_json(404, "Playlist does not exist.")
|
return Errors.error_json(404, "Playlist does not exist.")
|
||||||
end
|
end
|
||||||
|
|
||||||
# includes into the playlist a maximum of 20 videos, before the offset
|
# includes into the playlist a maximum of 20 videos, before the offset
|
||||||
@ -88,7 +88,7 @@ module Invidious::Routes::API::V1::Misc
|
|||||||
end
|
end
|
||||||
|
|
||||||
if format == "html"
|
if format == "html"
|
||||||
playlist_html = template_playlist(json_response, listen)
|
playlist_html = Invidious::Playlists::Playlists.template_playlist(json_response, listen)
|
||||||
index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
|
index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
@ -127,7 +127,7 @@ module Invidious::Routes::API::V1::Misc
|
|||||||
|
|
||||||
mix.videos = mix.videos[index..-1]
|
mix.videos = mix.videos[index..-1]
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
response = JSON.build do |json|
|
response = JSON.build do |json|
|
||||||
@ -178,14 +178,14 @@ module Invidious::Routes::API::V1::Misc
|
|||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
url = env.params.query["url"]?
|
url = env.params.query["url"]?
|
||||||
|
|
||||||
return error_json(400, "Missing URL to resolve") if !url
|
return Errors.error_json(400, "Missing URL to resolve") if !url
|
||||||
|
|
||||||
begin
|
begin
|
||||||
resolved_url = YoutubeAPI.resolve_url(url.as(String))
|
resolved_url = YoutubeAPI.resolve_url(url.as(String))
|
||||||
endpoint = resolved_url["endpoint"]
|
endpoint = resolved_url["endpoint"]
|
||||||
page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || ""
|
page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || ""
|
||||||
if page_type == "WEB_PAGE_TYPE_UNKNOWN"
|
if page_type == "WEB_PAGE_TYPE_UNKNOWN"
|
||||||
return error_json(400, "Unknown url")
|
return Errors.error_json(400, "Unknown url")
|
||||||
end
|
end
|
||||||
|
|
||||||
sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint
|
sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint
|
||||||
@ -204,7 +204,7 @@ module Invidious::Routes::API::V1::Misc
|
|||||||
post_id = nil
|
post_id = nil
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
|
|||||||
@ -10,7 +10,7 @@ module Invidious::Routes::API::V1::Search
|
|||||||
begin
|
begin
|
||||||
search_results = query.process
|
search_results = query.process
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(400, ex)
|
return Errors.error_json(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
@ -53,7 +53,7 @@ module Invidious::Routes::API::V1::Search
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -67,9 +67,9 @@ module Invidious::Routes::API::V1::Search
|
|||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
results = Invidious::Hashtag.fetch(hashtag, page, region)
|
results = Invidious::Search::Hashtag.fetch_hashtag(hashtag, page, region)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(400, ex)
|
return Errors.error_json(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
|
|||||||
@ -16,9 +16,9 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
begin
|
begin
|
||||||
video = get_video(id, region: region)
|
video = get_video(id, region: region)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
return JSON.build do |json|
|
return JSON.build do |json|
|
||||||
@ -34,7 +34,7 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
|
|
||||||
# Sanity checks
|
# Sanity checks
|
||||||
unless validate_video_id(id)
|
unless validate_video_id(id)
|
||||||
return error_json(400, InvalidVideoID.new(id))
|
return Errors.error_json(400, InvalidVideoID.new(id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354
|
# See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354
|
||||||
@ -301,7 +301,7 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
|
|
||||||
annotations = response.body
|
annotations = response.body
|
||||||
|
|
||||||
Helpers.cache_annotation(video_id, annotations)
|
Invidious::Helpers.cache_annotation(video_id, annotations)
|
||||||
end
|
end
|
||||||
else # "youtube"
|
else # "youtube"
|
||||||
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{video_id}")
|
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{video_id}")
|
||||||
@ -354,9 +354,9 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
begin
|
begin
|
||||||
comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
|
comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
return comments
|
return comments
|
||||||
@ -371,7 +371,7 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
end
|
end
|
||||||
|
|
||||||
if !reddit_thread || !comments
|
if !reddit_thread || !comments
|
||||||
return error_json(404, "No reddit threads found")
|
return Errors.error_json(404, "No reddit threads found")
|
||||||
end
|
end
|
||||||
|
|
||||||
if format == "json"
|
if format == "json"
|
||||||
@ -404,10 +404,10 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
proxy = {"1", "true"}.any? &.== env.params.query["local"]?
|
proxy = {"1", "true"}.any? &.== env.params.query["local"]?
|
||||||
|
|
||||||
response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}")
|
response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}")
|
||||||
return error_json(400, "Invalid clip ID") if response["error"]?
|
return Errors.error_json(400, "Invalid clip ID") if response["error"]?
|
||||||
|
|
||||||
video_id = response.dig?("endpoint", "watchEndpoint", "videoId").try &.as_s
|
video_id = response.dig?("endpoint", "watchEndpoint", "videoId").try &.as_s
|
||||||
return error_json(400, "Invalid clip ID") if video_id.nil?
|
return Errors.error_json(400, "Invalid clip ID") if video_id.nil?
|
||||||
|
|
||||||
start_time = nil
|
start_time = nil
|
||||||
end_time = nil
|
end_time = nil
|
||||||
@ -420,9 +420,9 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
begin
|
begin
|
||||||
video = get_video(video_id, region: region)
|
video = get_video(video_id, region: region)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
return JSON.build do |json|
|
return JSON.build do |json|
|
||||||
@ -454,9 +454,9 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
begin
|
begin
|
||||||
video = get_video(id)
|
video = get_video(id)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
response = JSON.build do |json|
|
response = JSON.build do |json|
|
||||||
@ -494,14 +494,14 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
begin
|
begin
|
||||||
video = get_video(id)
|
video = get_video(id)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
target_transcript = video.captions.select(&.name.== label)
|
target_transcript = video.captions.select(&.name.== label)
|
||||||
if target_transcript.empty?
|
if target_transcript.empty?
|
||||||
return error_json(404, NotFoundException.new("Requested transcript does not exist"))
|
return Errors.error_json(404, NotFoundException.new("Requested transcript does not exist"))
|
||||||
else
|
else
|
||||||
target_transcript = target_transcript[0]
|
target_transcript = target_transcript[0]
|
||||||
lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated
|
lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated
|
||||||
@ -515,9 +515,9 @@ module Invidious::Routes::API::V1::Videos
|
|||||||
YoutubeAPI.get_transcript(params), lang, auto_generated
|
YoutubeAPI.get_transcript(params), lang, auto_generated
|
||||||
)
|
)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
return transcript.to_json
|
return transcript.to_json
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
module Invidious::Routes::BeforeAll
|
module Invidious::Routes::BeforeAll
|
||||||
|
extend self
|
||||||
|
|
||||||
struct CompanionCSP
|
struct CompanionCSP
|
||||||
property companion_urls : String = ""
|
property companion_urls : String = ""
|
||||||
|
|
||||||
@ -89,7 +91,7 @@ module Invidious::Routes::BeforeAll
|
|||||||
|
|
||||||
if email = Database::SessionIDs.select_email(sid)
|
if email = Database::SessionIDs.select_email(sid)
|
||||||
user = Database::Users.select!(email: email)
|
user = Database::Users.select!(email: email)
|
||||||
csrf_token = generate_response(sid, {
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {
|
||||||
":authorize_token",
|
":authorize_token",
|
||||||
":playlist_ajax",
|
":playlist_ajax",
|
||||||
":signout",
|
":signout",
|
||||||
@ -107,7 +109,7 @@ module Invidious::Routes::BeforeAll
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
|
dark_mode = Invidious::User::Converters.convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
|
||||||
thin_mode = env.params.query["thin_mode"]?
|
thin_mode = env.params.query["thin_mode"]?
|
||||||
thin_mode = (thin_mode == "true") || preferences.thin_mode
|
thin_mode = (thin_mode == "true") || preferences.thin_mode
|
||||||
locale = env.params.query["hl"]? || preferences.locale
|
locale = env.params.query["hl"]? || preferences.locale
|
||||||
|
|||||||
@ -23,7 +23,7 @@ module Invidious::Routes::Channels
|
|||||||
sort_by ||= "last"
|
sort_by ||= "last"
|
||||||
sort_options = {"last", "oldest", "newest"}
|
sort_options = {"last", "oldest", "newest"}
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_playlists(
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_playlists(
|
||||||
channel.ucid, channel.author, continuation, sort_by
|
channel.ucid, channel.author, continuation, sort_by
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -42,8 +42,8 @@ module Invidious::Routes::Channels
|
|||||||
sort_by = ""
|
sort_by = ""
|
||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
|
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
|
||||||
items = get_playlist_videos(playlist, offset: 0)
|
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
# playlist doesnt exist.
|
# playlist doesnt exist.
|
||||||
items = [] of PlaylistVideo
|
items = [] of PlaylistVideo
|
||||||
@ -77,8 +77,8 @@ module Invidious::Routes::Channels
|
|||||||
sort_by = ""
|
sort_by = ""
|
||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
|
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UUSH"))
|
||||||
items = get_playlist_videos(playlist, offset: 0)
|
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
# playlist doesnt exist.
|
# playlist doesnt exist.
|
||||||
items = [] of PlaylistVideo
|
items = [] of PlaylistVideo
|
||||||
@ -112,8 +112,8 @@ module Invidious::Routes::Channels
|
|||||||
sort_by = ""
|
sort_by = ""
|
||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
|
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULV"))
|
||||||
items = get_playlist_videos(playlist, offset: 0)
|
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
# playlist doesnt exist.
|
# playlist doesnt exist.
|
||||||
items = [] of PlaylistVideo
|
items = [] of PlaylistVideo
|
||||||
@ -146,7 +146,7 @@ module Invidious::Routes::Channels
|
|||||||
return env.redirect "/channel/#{channel.ucid}"
|
return env.redirect "/channel/#{channel.ucid}"
|
||||||
end
|
end
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_playlists(
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_playlists(
|
||||||
channel.ucid, channel.author, continuation, (sort_by || "last")
|
channel.ucid, channel.author, continuation, (sort_by || "last")
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -166,7 +166,7 @@ module Invidious::Routes::Channels
|
|||||||
sort_by = ""
|
sort_by = ""
|
||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_podcasts(
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_podcasts(
|
||||||
channel.ucid, channel.author, continuation
|
channel.ucid, channel.author, continuation
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -186,7 +186,7 @@ module Invidious::Routes::Channels
|
|||||||
sort_by = ""
|
sort_by = ""
|
||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_releases(
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_releases(
|
||||||
channel.ucid, channel.author, continuation
|
channel.ucid, channel.author, continuation
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -206,7 +206,7 @@ module Invidious::Routes::Channels
|
|||||||
sort_by = ""
|
sort_by = ""
|
||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
|
|
||||||
items, next_continuation = fetch_channel_courses(
|
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_courses(
|
||||||
channel.ucid, channel.author, continuation
|
channel.ucid, channel.author, continuation
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -247,7 +247,7 @@ module Invidious::Routes::Channels
|
|||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
|
|
||||||
begin
|
begin
|
||||||
items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode))
|
items = JSON.parse(Invidious::Channels::Community.fetch_channel_community(ucid, continuation, locale, "json", thin_mode))
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
error_message = ex.message
|
error_message = ex.message
|
||||||
@ -255,7 +255,7 @@ module Invidious::Routes::Channels
|
|||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
error_message = ex.message
|
error_message = ex.message
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
templated "community"
|
templated "community"
|
||||||
@ -280,14 +280,14 @@ module Invidious::Routes::Channels
|
|||||||
|
|
||||||
if !ucid.nil?
|
if !ucid.nil?
|
||||||
ucid = ucid.to_s
|
ucid = ucid.to_s
|
||||||
post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
|
post_response = Invidious::Channels::Community.fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
|
||||||
else
|
else
|
||||||
# resolve the url to get the author's UCID
|
# resolve the url to get the author's UCID
|
||||||
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
|
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
|
||||||
return error_template(400, "Invalid post ID") if response["error"]?
|
return Errors.error_template(400, "Invalid post ID") if response["error"]?
|
||||||
|
|
||||||
ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
|
ucid = Invidious::Channels::Community.decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
|
||||||
post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
|
post_response = Invidious::Channels::Community.fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
|
||||||
end
|
end
|
||||||
|
|
||||||
post_response = JSON.parse(post_response)
|
post_response = JSON.parse(post_response)
|
||||||
@ -309,7 +309,7 @@ module Invidious::Routes::Channels
|
|||||||
return env.redirect "/channel/#{channel.ucid}"
|
return env.redirect "/channel/#{channel.ucid}"
|
||||||
end
|
end
|
||||||
|
|
||||||
items, next_continuation = fetch_related_channels(channel, continuation)
|
items, next_continuation = Invidious::Channels::About.fetch_related_channels(channel, continuation)
|
||||||
|
|
||||||
# Featured/related channels can't be sorted
|
# Featured/related channels can't be sorted
|
||||||
sort_options = [] of String
|
sort_options = [] of String
|
||||||
@ -354,7 +354,7 @@ module Invidious::Routes::Channels
|
|||||||
resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
|
resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
|
||||||
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
|
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
|
||||||
rescue ex : InfoException | KeyError
|
rescue ex : InfoException | KeyError
|
||||||
return error_template(404, I18n.translate(locale, "This channel does not exist."))
|
return Errors.error_template(404, I18n.translate(locale, "This channel does not exist."))
|
||||||
end
|
end
|
||||||
|
|
||||||
selected_tab = env.params.url["tab"]?
|
selected_tab = env.params.url["tab"]?
|
||||||
@ -380,7 +380,7 @@ module Invidious::Routes::Channels
|
|||||||
|
|
||||||
user = env.params.query["user"]?
|
user = env.params.query["user"]?
|
||||||
if !user
|
if !user
|
||||||
return error_template(404, "This channel does not exist.")
|
return Errors.error_template(404, "This channel does not exist.")
|
||||||
else
|
else
|
||||||
env.redirect "/user/#{user}#{uri_params}"
|
env.redirect "/user/#{user}#{uri_params}"
|
||||||
end
|
end
|
||||||
@ -433,13 +433,13 @@ module Invidious::Routes::Channels
|
|||||||
continuation = env.params.query["continuation"]?
|
continuation = env.params.query["continuation"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
channel = get_about_info(ucid)
|
channel = Invidious::Channels::About.get_about_info(ucid)
|
||||||
rescue ex : ChannelRedirect
|
rescue ex : ChannelRedirect
|
||||||
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
|
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_template(404, ex)
|
return Errors.error_template(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
env.set "search", "channel:#{ucid} "
|
env.set "search", "channel:#{ucid} "
|
||||||
|
|||||||
@ -5,9 +5,9 @@ module Invidious::Routes::Embed
|
|||||||
locale = env.get("preferences").as(Preferences).locale
|
locale = env.get("preferences").as(Preferences).locale
|
||||||
if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
|
if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(plid)
|
playlist = Invidious::Playlists::Playlists.get_playlist(plid)
|
||||||
offset = env.params.query["index"]?.try &.to_i? || 0
|
offset = env.params.query["index"]?.try &.to_i? || 0
|
||||||
videos = get_playlist_videos(playlist, offset: offset)
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: offset)
|
||||||
if videos.empty?
|
if videos.empty?
|
||||||
url = "/playlist?list=#{plid}"
|
url = "/playlist?list=#{plid}"
|
||||||
raise NotFoundException.new(I18n.translate(locale, "error_video_not_in_playlist", url))
|
raise NotFoundException.new(I18n.translate(locale, "error_video_not_in_playlist", url))
|
||||||
@ -15,9 +15,9 @@ module Invidious::Routes::Embed
|
|||||||
|
|
||||||
first_playlist_video = videos[0].as(PlaylistVideo)
|
first_playlist_video = videos[0].as(PlaylistVideo)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_template(404, ex)
|
return Errors.error_template(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
url = "/embed/#{first_playlist_video.id}?#{env.params.query}"
|
url = "/embed/#{first_playlist_video.id}?#{env.params.query}"
|
||||||
@ -66,9 +66,9 @@ module Invidious::Routes::Embed
|
|||||||
|
|
||||||
if plid
|
if plid
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(plid)
|
playlist = Invidious::Playlists::Playlists.get_playlist(plid)
|
||||||
offset = env.params.query["index"]?.try &.to_i? || 0
|
offset = env.params.query["index"]?.try &.to_i? || 0
|
||||||
videos = get_playlist_videos(playlist, offset: offset)
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: offset)
|
||||||
if videos.empty?
|
if videos.empty?
|
||||||
url = "/playlist?list=#{plid}"
|
url = "/playlist?list=#{plid}"
|
||||||
raise NotFoundException.new(I18n.translate(locale, "error_video_not_in_playlist", url))
|
raise NotFoundException.new(I18n.translate(locale, "error_video_not_in_playlist", url))
|
||||||
@ -76,9 +76,9 @@ module Invidious::Routes::Embed
|
|||||||
|
|
||||||
first_playlist_video = videos[0].as(PlaylistVideo)
|
first_playlist_video = videos[0].as(PlaylistVideo)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_template(404, ex)
|
return Errors.error_template(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
url = "/embed/#{first_playlist_video.id}"
|
url = "/embed/#{first_playlist_video.id}"
|
||||||
@ -101,7 +101,7 @@ module Invidious::Routes::Embed
|
|||||||
env.params.query.delete_all("channel")
|
env.params.query.delete_all("channel")
|
||||||
|
|
||||||
if !video_id || video_id == "live_stream"
|
if !video_id || video_id == "live_stream"
|
||||||
return error_template(500, "Video is unavailable.")
|
return Errors.error_template(500, "Video is unavailable.")
|
||||||
end
|
end
|
||||||
|
|
||||||
url = "/embed/#{video_id}"
|
url = "/embed/#{video_id}"
|
||||||
@ -135,9 +135,9 @@ module Invidious::Routes::Embed
|
|||||||
begin
|
begin
|
||||||
video = get_video(id, region: params.region)
|
video = get_video(id, region: params.region)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_template(404, ex)
|
return Errors.error_template(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
if preferences.annotations_subscribed &&
|
if preferences.annotations_subscribed &&
|
||||||
|
|||||||
@ -53,9 +53,9 @@ module Invidious::Routes::Feeds
|
|||||||
region ||= preferences.region
|
region ||= preferences.region
|
||||||
|
|
||||||
begin
|
begin
|
||||||
trending, plid = fetch_trending(trending_type, region, locale)
|
trending, plid = Invidious::Feeds::Trending.fetch(trending_type, region, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
templated "feeds/trending"
|
templated "feeds/trending"
|
||||||
@ -91,7 +91,7 @@ module Invidious::Routes::Feeds
|
|||||||
page = env.params.query["page"]?.try &.to_i?
|
page = env.params.query["page"]?.try &.to_i?
|
||||||
page ||= 1
|
page ||= 1
|
||||||
|
|
||||||
videos, notifications = get_subscription_feed(user, max_results, page)
|
videos, notifications = Invidious::User::Users.get_subscription_feed(user, max_results, page)
|
||||||
|
|
||||||
if CONFIG.enable_user_notifications
|
if CONFIG.enable_user_notifications
|
||||||
# "updated" here is used for delivering new notifications, so if
|
# "updated" here is used for delivering new notifications, so if
|
||||||
@ -150,7 +150,7 @@ module Invidious::Routes::Feeds
|
|||||||
if env.params.url["ucid"].matches?(/^[\w-]+$/)
|
if env.params.url["ucid"].matches?(/^[\w-]+$/)
|
||||||
ucid = env.params.url["ucid"]
|
ucid = env.params.url["ucid"]
|
||||||
else
|
else
|
||||||
return error_atom(400, InfoException.new("Invalid channel ucid provided."))
|
return Errors.error_atom(400, InfoException.new("Invalid channel ucid provided."))
|
||||||
end
|
end
|
||||||
|
|
||||||
params = HTTP::Params.parse(env.params.query["params"]? || "")
|
params = HTTP::Params.parse(env.params.query["params"]? || "")
|
||||||
@ -162,7 +162,7 @@ module Invidious::Routes::Feeds
|
|||||||
}
|
}
|
||||||
|
|
||||||
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}")
|
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}")
|
||||||
return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
|
return Errors.error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
|
||||||
rss = XML.parse(response.body)
|
rss = XML.parse(response.body)
|
||||||
|
|
||||||
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
|
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
|
||||||
@ -250,7 +250,7 @@ module Invidious::Routes::Feeds
|
|||||||
|
|
||||||
params = HTTP::Params.parse(env.params.query["params"]? || "")
|
params = HTTP::Params.parse(env.params.query["params"]? || "")
|
||||||
|
|
||||||
videos, notifications = get_subscription_feed(user, max_results, page)
|
videos, notifications = Invidious::User::Users.get_subscription_feed(user, max_results, page)
|
||||||
|
|
||||||
XML.build(indent: " ", encoding: "UTF-8") do |xml|
|
XML.build(indent: " ", encoding: "UTF-8") do |xml|
|
||||||
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
|
xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015",
|
||||||
@ -281,11 +281,11 @@ module Invidious::Routes::Feeds
|
|||||||
|
|
||||||
if plid.starts_with? "IV"
|
if plid.starts_with? "IV"
|
||||||
if playlist = Invidious::Database::Playlists.select(id: plid)
|
if playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
videos = get_playlist_videos(playlist, offset: 0)
|
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
|
||||||
|
|
||||||
user = env.get?("user").try &.as(User)
|
user = env.get?("user").try &.as(User)
|
||||||
if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
|
if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
|
||||||
return error_atom(404, "Playlist does not exist.")
|
return Errors.error_atom(404, "Playlist does not exist.")
|
||||||
end
|
end
|
||||||
|
|
||||||
return XML.build(indent: " ", encoding: "UTF-8") do |xml|
|
return XML.build(indent: " ", encoding: "UTF-8") do |xml|
|
||||||
@ -317,7 +317,7 @@ module Invidious::Routes::Feeds
|
|||||||
end
|
end
|
||||||
|
|
||||||
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
|
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
|
||||||
return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
|
return Errors.error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
|
||||||
|
|
||||||
document = XML.parse(response.body)
|
document = XML.parse(response.body)
|
||||||
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
|
document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|
|
||||||
|
|||||||
@ -106,7 +106,7 @@ module Invidious::Routes::Images
|
|||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
||||||
Helpers.proxy_file(response, env)
|
Invidious::Helpers.proxy_file(response, env)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
end
|
end
|
||||||
@ -158,6 +158,6 @@ module Invidious::Routes::Images
|
|||||||
return env.response.headers.delete("Transfer-Encoding")
|
return env.response.headers.delete("Transfer-Encoding")
|
||||||
end
|
end
|
||||||
|
|
||||||
return Helpers.proxy_file(response, env)
|
return Invidious::Helpers.proxy_file(response, env)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -11,7 +11,7 @@ module Invidious::Routes::Login
|
|||||||
return env.redirect referer if user
|
return env.redirect referer if user
|
||||||
|
|
||||||
if !CONFIG.login_enabled
|
if !CONFIG.login_enabled
|
||||||
return error_template(400, "Login has been disabled by administrator.")
|
return Errors.error_template(400, "Login has been disabled by administrator.")
|
||||||
end
|
end
|
||||||
|
|
||||||
email = nil
|
email = nil
|
||||||
@ -31,7 +31,7 @@ module Invidious::Routes::Login
|
|||||||
referer = get_referer(env, "/feed/subscriptions")
|
referer = get_referer(env, "/feed/subscriptions")
|
||||||
|
|
||||||
if !CONFIG.login_enabled
|
if !CONFIG.login_enabled
|
||||||
return error_template(403, "Login has been disabled by administrator.")
|
return Errors.error_template(403, "Login has been disabled by administrator.")
|
||||||
end
|
end
|
||||||
|
|
||||||
# https://stackoverflow.com/a/574698
|
# https://stackoverflow.com/a/574698
|
||||||
@ -44,11 +44,11 @@ module Invidious::Routes::Login
|
|||||||
case account_type
|
case account_type
|
||||||
when "invidious"
|
when "invidious"
|
||||||
if email.nil? || email.empty?
|
if email.nil? || email.empty?
|
||||||
return error_template(401, "User ID is a required field")
|
return Errors.error_template(401, "User ID is a required field")
|
||||||
end
|
end
|
||||||
|
|
||||||
if password.nil? || password.empty?
|
if password.nil? || password.empty?
|
||||||
return error_template(401, "Password is a required field")
|
return Errors.error_template(401, "Password is a required field")
|
||||||
end
|
end
|
||||||
|
|
||||||
user = Invidious::Database::Users.select(email: email)
|
user = Invidious::Database::Users.select(email: email)
|
||||||
@ -64,7 +64,7 @@ module Invidious::Routes::Login
|
|||||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
return error_template(401, "Wrong username or password")
|
return Errors.error_template(401, "Wrong username or password")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Since this user has already registered, we don't want to overwrite their preferences
|
# Since this user has already registered, we don't want to overwrite their preferences
|
||||||
@ -75,16 +75,16 @@ module Invidious::Routes::Login
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
if !CONFIG.registration_enabled
|
if !CONFIG.registration_enabled
|
||||||
return error_template(400, "Registration has been disabled by administrator.")
|
return Errors.error_template(400, "Registration has been disabled by administrator.")
|
||||||
end
|
end
|
||||||
|
|
||||||
if password.empty?
|
if password.empty?
|
||||||
return error_template(401, "Password cannot be empty")
|
return Errors.error_template(401, "Password cannot be empty")
|
||||||
end
|
end
|
||||||
|
|
||||||
# See https://security.stackexchange.com/a/39851
|
# See https://security.stackexchange.com/a/39851
|
||||||
if password.bytesize > 55
|
if password.bytesize > 55
|
||||||
return error_template(400, "Password cannot be longer than 55 characters")
|
return Errors.error_template(400, "Password cannot be longer than 55 characters")
|
||||||
end
|
end
|
||||||
|
|
||||||
password = password.byte_slice(0, 55)
|
password = password.byte_slice(0, 55)
|
||||||
@ -102,11 +102,11 @@ module Invidious::Routes::Login
|
|||||||
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
|
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(tokens[0], answer, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(tokens[0], answer, env.request, HMAC_KEY, locale)
|
||||||
rescue ex : InfoException
|
rescue ex : InfoException
|
||||||
return error_template(400, InfoException.new("Erroneous CAPTCHA"))
|
return Errors.error_template(400, InfoException.new("Erroneous CAPTCHA"))
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
return templated "user/login"
|
return templated "user/login"
|
||||||
@ -114,7 +114,7 @@ module Invidious::Routes::Login
|
|||||||
end
|
end
|
||||||
|
|
||||||
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||||
user, sid = create_user(sid, email, password)
|
user, sid = Invidious::User::Users.create_user(sid, email, password)
|
||||||
|
|
||||||
if language_header = env.request.headers["Accept-Language"]?
|
if language_header = env.request.headers["Accept-Language"]?
|
||||||
if language = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
|
if language = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
|
||||||
@ -126,7 +126,7 @@ module Invidious::Routes::Login
|
|||||||
Invidious::Database::SessionIDs.insert(sid, email)
|
Invidious::Database::SessionIDs.insert(sid, email)
|
||||||
|
|
||||||
view_name = "subscriptions_#{sha256(user.email)}"
|
view_name = "subscriptions_#{sha256(user.email)}"
|
||||||
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
|
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{Invidious::User::Users::MATERIALIZED_VIEW_SQL.call(user.email)}")
|
||||||
|
|
||||||
if alt = CONFIG.alternative_domains.index(host)
|
if alt = CONFIG.alternative_domains.index(host)
|
||||||
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
|
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
|
||||||
@ -166,9 +166,9 @@ module Invidious::Routes::Login
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Database::SessionIDs.delete(sid: sid)
|
Invidious::Database::SessionIDs.delete(sid: sid)
|
||||||
|
|||||||
@ -18,7 +18,7 @@ module Invidious::Routes::Notifications
|
|||||||
if redirect
|
if redirect
|
||||||
return env.redirect referer
|
return env.redirect referer
|
||||||
else
|
else
|
||||||
return error_json(403, "No such user")
|
return Errors.error_json(403, "No such user")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ module Invidious::Routes::Playlists
|
|||||||
|
|
||||||
user = user.as(User)
|
user = user.as(User)
|
||||||
sid = sid.as(String)
|
sid = sid.as(String)
|
||||||
csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY)
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":create_playlist"}, HMAC_KEY)
|
||||||
|
|
||||||
templated "create_playlist"
|
templated "create_playlist"
|
||||||
end
|
end
|
||||||
@ -31,26 +31,26 @@ module Invidious::Routes::Playlists
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
title = env.params.body["title"]?.try &.as(String)
|
title = env.params.body["title"]?.try &.as(String)
|
||||||
if !title || title.empty?
|
if !title || title.empty?
|
||||||
return error_template(400, "Title cannot be empty.")
|
return Errors.error_template(400, "Title cannot be empty.")
|
||||||
end
|
end
|
||||||
|
|
||||||
privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "")
|
privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "")
|
||||||
if !privacy
|
if !privacy
|
||||||
return error_template(400, "Invalid privacy setting.")
|
return Errors.error_template(400, "Invalid privacy setting.")
|
||||||
end
|
end
|
||||||
|
|
||||||
if Invidious::Database::Playlists.count_owned_by(user.email) >= 100
|
if Invidious::Database::Playlists.count_owned_by(user.email) >= 100
|
||||||
return error_template(400, "User cannot have more than 100 playlists.")
|
return Errors.error_template(400, "User cannot have more than 100 playlists.")
|
||||||
end
|
end
|
||||||
|
|
||||||
playlist = create_playlist(title, privacy, user)
|
playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
|
||||||
|
|
||||||
env.redirect "/playlist?list=#{playlist.id}"
|
env.redirect "/playlist?list=#{playlist.id}"
|
||||||
end
|
end
|
||||||
@ -67,13 +67,13 @@ module Invidious::Routes::Playlists
|
|||||||
|
|
||||||
playlist_id = env.params.query["list"]
|
playlist_id = env.params.query["list"]
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(playlist_id)
|
playlist = Invidious::Playlists::Playlists.get_playlist(playlist_id)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_template(404, ex)
|
return Errors.error_template(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
subscribe_playlist(user, playlist)
|
Invidious::Playlists::Playlists.subscribe_playlist(user, playlist)
|
||||||
|
|
||||||
env.redirect "/playlist?list=#{playlist.id}"
|
env.redirect "/playlist?list=#{playlist.id}"
|
||||||
end
|
end
|
||||||
@ -92,7 +92,7 @@ module Invidious::Routes::Playlists
|
|||||||
|
|
||||||
plid = env.params.query["list"]?
|
plid = env.params.query["list"]?
|
||||||
if !plid || plid.empty?
|
if !plid || plid.empty?
|
||||||
return error_template(400, "A playlist ID is required")
|
return Errors.error_template(400, "A playlist ID is required")
|
||||||
end
|
end
|
||||||
|
|
||||||
playlist = Invidious::Database::Playlists.select(id: plid)
|
playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
@ -100,7 +100,7 @@ module Invidious::Routes::Playlists
|
|||||||
return env.redirect referer
|
return env.redirect referer
|
||||||
end
|
end
|
||||||
|
|
||||||
csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY)
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":delete_playlist"}, HMAC_KEY)
|
||||||
|
|
||||||
templated "delete_playlist"
|
templated "delete_playlist"
|
||||||
end
|
end
|
||||||
@ -122,9 +122,9 @@ module Invidious::Routes::Playlists
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
playlist = Invidious::Database::Playlists.select(id: plid)
|
playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
@ -163,12 +163,12 @@ module Invidious::Routes::Playlists
|
|||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
items = get_playlist_videos(playlist, offset: (page - 1) * 100)
|
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: (page - 1) * 100)
|
||||||
rescue ex
|
rescue ex
|
||||||
items = [] of PlaylistVideo
|
items = [] of PlaylistVideo
|
||||||
end
|
end
|
||||||
|
|
||||||
csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY)
|
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":edit_playlist"}, HMAC_KEY)
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
page_nav_html = Frontend::Pagination.nav_numeric(locale,
|
page_nav_html = Frontend::Pagination.nav_numeric(locale,
|
||||||
@ -197,9 +197,9 @@ module Invidious::Routes::Playlists
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
playlist = Invidious::Database::Playlists.select(id: plid)
|
playlist = Invidious::Database::Playlists.select(id: plid)
|
||||||
@ -286,7 +286,7 @@ module Invidious::Routes::Playlists
|
|||||||
if redirect
|
if redirect
|
||||||
return env.redirect referer
|
return env.redirect referer
|
||||||
else
|
else
|
||||||
return error_json(403, "No such user")
|
return Errors.error_json(403, "No such user")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -295,26 +295,26 @@ module Invidious::Routes::Playlists
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
if redirect
|
if redirect
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
else
|
else
|
||||||
return error_json(400, ex)
|
return Errors.error_json(400, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
playlist_id = env.params.query["playlist_id"]
|
playlist_id = env.params.query["playlist_id"]
|
||||||
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
|
playlist = Invidious::Playlists::Playlists.get_playlist(playlist_id).as(InvidiousPlaylist)
|
||||||
raise "Invalid user" if playlist.author != user.email
|
raise "Invalid user" if playlist.author != user.email
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
if redirect
|
if redirect
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
else
|
else
|
||||||
return error_json(400, ex)
|
return Errors.error_json(400, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -322,9 +322,9 @@ module Invidious::Routes::Playlists
|
|||||||
when "add_video"
|
when "add_video"
|
||||||
if playlist.index.size >= CONFIG.playlist_length_limit
|
if playlist.index.size >= CONFIG.playlist_length_limit
|
||||||
if redirect
|
if redirect
|
||||||
return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
return Errors.error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
||||||
else
|
else
|
||||||
return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
return Errors.error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -333,12 +333,12 @@ module Invidious::Routes::Playlists
|
|||||||
begin
|
begin
|
||||||
video = get_video(video_id)
|
video = get_video(video_id)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_json(404, ex)
|
return Errors.error_json(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
if redirect
|
if redirect
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
else
|
else
|
||||||
return error_json(500, ex)
|
return Errors.error_json(500, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -359,7 +359,7 @@ module Invidious::Routes::Playlists
|
|||||||
when "remove_video"
|
when "remove_video"
|
||||||
index = env.params.query["set_video_id"].to_i64?
|
index = env.params.query["set_video_id"].to_i64?
|
||||||
if index.nil? || !playlist.index.includes? index
|
if index.nil? || !playlist.index.includes? index
|
||||||
return error_json(404, "Playlist does not contain index")
|
return Errors.error_json(404, "Playlist does not contain index")
|
||||||
end
|
end
|
||||||
|
|
||||||
Invidious::Database::PlaylistVideos.delete(index, playlist_id)
|
Invidious::Database::PlaylistVideos.delete(index, playlist_id)
|
||||||
@ -367,9 +367,9 @@ module Invidious::Routes::Playlists
|
|||||||
when "move_video_before"
|
when "move_video_before"
|
||||||
# TODO: Playlist stub
|
# TODO: Playlist stub
|
||||||
when nil
|
when nil
|
||||||
return error_json(400, "Missing action")
|
return Errors.error_json(400, "Missing action")
|
||||||
else
|
else
|
||||||
return error_json(400, "Unsupported action #{action}")
|
return Errors.error_json(400, "Unsupported action #{action}")
|
||||||
end
|
end
|
||||||
|
|
||||||
if redirect
|
if redirect
|
||||||
@ -399,11 +399,11 @@ module Invidious::Routes::Playlists
|
|||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
playlist = get_playlist(plid)
|
playlist = Invidious::Playlists::Playlists.get_playlist(plid)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_template(404, ex)
|
return Errors.error_template(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
if playlist.is_a? InvidiousPlaylist
|
if playlist.is_a? InvidiousPlaylist
|
||||||
@ -419,17 +419,17 @@ module Invidious::Routes::Playlists
|
|||||||
end
|
end
|
||||||
|
|
||||||
if playlist.privacy == PlaylistPrivacy::Private && playlist.author != user.try &.email
|
if playlist.privacy == PlaylistPrivacy::Private && playlist.author != user.try &.email
|
||||||
return error_template(403, "This playlist is private.")
|
return Errors.error_template(403, "This playlist is private.")
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
if playlist.is_a? InvidiousPlaylist
|
if playlist.is_a? InvidiousPlaylist
|
||||||
items = get_playlist_videos(playlist, offset: (page - 1) * 100)
|
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: (page - 1) * 100)
|
||||||
else
|
else
|
||||||
items = get_playlist_videos(playlist, offset: (page - 1) * 200)
|
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: (page - 1) * 200)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, "Error encountered while retrieving playlist videos.<br>#{ex.message}")
|
return Errors.error_template(500, "Error encountered while retrieving playlist videos.<br>#{ex.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
if playlist.author == user.try &.email
|
if playlist.author == user.try &.email
|
||||||
@ -460,7 +460,7 @@ module Invidious::Routes::Playlists
|
|||||||
begin
|
begin
|
||||||
mix = fetch_mix(rdid, continuation, locale: locale)
|
mix = fetch_mix(rdid, continuation, locale: locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
templated "mix"
|
templated "mix"
|
||||||
|
|||||||
@ -329,7 +329,7 @@ module Invidious::Routes::PreferencesRoute
|
|||||||
|
|
||||||
if !success
|
if !success
|
||||||
haltf(env, status_code: 415,
|
haltf(env, status_code: 415,
|
||||||
response: error_template(415, "Invalid subscription file uploaded")
|
response: Errors.error_template(415, "Invalid subscription file uploaded")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
when "import_youtube_pl"
|
when "import_youtube_pl"
|
||||||
@ -338,7 +338,7 @@ module Invidious::Routes::PreferencesRoute
|
|||||||
|
|
||||||
if !success
|
if !success
|
||||||
haltf(env, status_code: 415,
|
haltf(env, status_code: 415,
|
||||||
response: error_template(415, "Invalid playlist file uploaded")
|
response: Errors.error_template(415, "Invalid playlist file uploaded")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
when "import_youtube_wh"
|
when "import_youtube_wh"
|
||||||
@ -347,7 +347,7 @@ module Invidious::Routes::PreferencesRoute
|
|||||||
|
|
||||||
if !success
|
if !success
|
||||||
haltf(env, status_code: 415,
|
haltf(env, status_code: 415,
|
||||||
response: error_template(415, "Invalid watch history file uploaded")
|
response: Errors.error_template(415, "Invalid watch history file uploaded")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
when "import_freetube"
|
when "import_freetube"
|
||||||
@ -359,7 +359,7 @@ module Invidious::Routes::PreferencesRoute
|
|||||||
|
|
||||||
if !success
|
if !success
|
||||||
haltf(env, status_code: 415,
|
haltf(env, status_code: 415,
|
||||||
response: error_template(415, "Uploaded file is too large")
|
response: Errors.error_template(415, "Uploaded file is too large")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
else nil # Ignore
|
else nil # Ignore
|
||||||
|
|||||||
@ -71,9 +71,9 @@ module Invidious::Routes::Search
|
|||||||
items = query.process
|
items = query.process
|
||||||
end
|
end
|
||||||
rescue ex : ChannelSearchException
|
rescue ex : ChannelSearchException
|
||||||
return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
|
return Errors.error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
|
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
|
||||||
@ -100,7 +100,7 @@ module Invidious::Routes::Search
|
|||||||
|
|
||||||
hashtag = env.params.url["hashtag"]?
|
hashtag = env.params.url["hashtag"]?
|
||||||
if hashtag.nil? || hashtag.empty?
|
if hashtag.nil? || hashtag.empty?
|
||||||
return error_template(400, "Invalid request")
|
return Errors.error_template(400, "Invalid request")
|
||||||
end
|
end
|
||||||
|
|
||||||
page = env.params.query["page"]?
|
page = env.params.query["page"]?
|
||||||
@ -112,9 +112,9 @@ module Invidious::Routes::Search
|
|||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
items = Invidious::Hashtag.fetch(hashtag, page)
|
items = Invidious::Search::Hashtag.fetch_hashtag(hashtag, page)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
|
|||||||
@ -14,7 +14,7 @@ module Invidious::Routes::Subscriptions
|
|||||||
if redirect
|
if redirect
|
||||||
return env.redirect referer
|
return env.redirect referer
|
||||||
else
|
else
|
||||||
return error_json(403, "No such user")
|
return Errors.error_json(403, "No such user")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -23,12 +23,12 @@ module Invidious::Routes::Subscriptions
|
|||||||
token = env.params.body["csrf_token"]?
|
token = env.params.body["csrf_token"]?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
if redirect
|
if redirect
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
else
|
else
|
||||||
return error_json(400, ex)
|
return Errors.error_json(400, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -38,13 +38,13 @@ module Invidious::Routes::Subscriptions
|
|||||||
case action = env.params.query["action"]?
|
case action = env.params.query["action"]?
|
||||||
when "create_subscription_to_channel"
|
when "create_subscription_to_channel"
|
||||||
if !user.subscriptions.includes? channel_id
|
if !user.subscriptions.includes? channel_id
|
||||||
get_channel(channel_id)
|
Invidious::Channels::Channels.get_channel(channel_id)
|
||||||
Invidious::Database::Users.subscribe_channel(user, channel_id)
|
Invidious::Database::Users.subscribe_channel(user, channel_id)
|
||||||
end
|
end
|
||||||
when "remove_subscriptions"
|
when "remove_subscriptions"
|
||||||
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
|
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
|
||||||
else
|
else
|
||||||
return error_json(400, "Unsupported action #{action}")
|
return Errors.error_json(400, "Unsupported action #{action}")
|
||||||
end
|
end
|
||||||
|
|
||||||
if redirect
|
if redirect
|
||||||
|
|||||||
@ -24,7 +24,7 @@ module Invidious::Routes::VideoPlayback
|
|||||||
|
|
||||||
# Sanity check, to avoid being used as an open proxy
|
# Sanity check, to avoid being used as an open proxy
|
||||||
if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/)
|
if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/)
|
||||||
return error_template(400, "Invalid \"host\" parameter.")
|
return Errors.error_template(400, "Invalid \"host\" parameter.")
|
||||||
end
|
end
|
||||||
|
|
||||||
host = "https://#{host}"
|
host = "https://#{host}"
|
||||||
@ -83,7 +83,7 @@ module Invidious::Routes::VideoPlayback
|
|||||||
# Remove the Range header added previously.
|
# Remove the Range header added previously.
|
||||||
headers.delete("Range") if range_header.nil?
|
headers.delete("Range") if range_header.nil?
|
||||||
|
|
||||||
playback_statistics = Helpers.get_playback_statistic
|
playback_statistics = Invidious::Helpers.get_playback_statistic
|
||||||
playback_statistics["totalRequests"] += 1
|
playback_statistics["totalRequests"] += 1
|
||||||
|
|
||||||
if response.status_code >= 400
|
if response.status_code >= 400
|
||||||
@ -95,7 +95,7 @@ module Invidious::Routes::VideoPlayback
|
|||||||
|
|
||||||
if url.includes? "&file=seg.ts"
|
if url.includes? "&file=seg.ts"
|
||||||
if CONFIG.disabled?("livestreams")
|
if CONFIG.disabled?("livestreams")
|
||||||
return error_template(403, "Administrator has disabled this endpoint.")
|
return Errors.error_template(403, "Administrator has disabled this endpoint.")
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
@ -120,7 +120,7 @@ module Invidious::Routes::VideoPlayback
|
|||||||
else
|
else
|
||||||
if query_params["title"]? && CONFIG.disabled?("downloads") ||
|
if query_params["title"]? && CONFIG.disabled?("downloads") ||
|
||||||
CONFIG.disabled?("dash")
|
CONFIG.disabled?("dash")
|
||||||
return error_template(403, "Administrator has disabled this endpoint.")
|
return Errors.error_template(403, "Administrator has disabled this endpoint.")
|
||||||
end
|
end
|
||||||
|
|
||||||
content_length = nil
|
content_length = nil
|
||||||
@ -195,7 +195,7 @@ module Invidious::Routes::VideoPlayback
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Helpers.proxy_file(resp, env)
|
Invidious::Helpers.proxy_file(resp, env)
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
if ex.message != "Error reading socket: Connection reset by peer"
|
if ex.message != "Error reading socket: Connection reset by peer"
|
||||||
@ -269,11 +269,11 @@ module Invidious::Routes::VideoPlayback
|
|||||||
|
|
||||||
# Sanity checks
|
# Sanity checks
|
||||||
unless id && validate_video_id(id)
|
unless id && validate_video_id(id)
|
||||||
return error_template(400, InvalidVideoID.new(id))
|
return Errors.error_template(400, InvalidVideoID.new(id))
|
||||||
end
|
end
|
||||||
|
|
||||||
if !itag.nil? && (itag <= 0 || itag >= 1000)
|
if !itag.nil? && (itag <= 0 || itag >= 1000)
|
||||||
return error_template(400, "Invalid itag")
|
return Errors.error_template(400, "Invalid itag")
|
||||||
end
|
end
|
||||||
|
|
||||||
region = env.params.query["region"]?
|
region = env.params.query["region"]?
|
||||||
@ -282,15 +282,15 @@ module Invidious::Routes::VideoPlayback
|
|||||||
title = env.params.query["title"]?
|
title = env.params.query["title"]?
|
||||||
|
|
||||||
if title && CONFIG.disabled?("downloads")
|
if title && CONFIG.disabled?("downloads")
|
||||||
return error_template(403, "Administrator has disabled this endpoint.")
|
return Errors.error_template(403, "Administrator has disabled this endpoint.")
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
video = get_video(id, region: region)
|
video = get_video(id, region: region)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
return error_template(404, ex)
|
return Errors.error_template(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
if itag.nil?
|
if itag.nil?
|
||||||
|
|||||||
@ -15,11 +15,11 @@ module Invidious::Routes::Watch
|
|||||||
id = env.params.query["v"]
|
id = env.params.query["v"]
|
||||||
|
|
||||||
if env.params.query["v"].empty?
|
if env.params.query["v"].empty?
|
||||||
return error_template(400, "Invalid parameters.")
|
return Errors.error_template(400, "Invalid parameters.")
|
||||||
end
|
end
|
||||||
|
|
||||||
unless validate_video_id(id)
|
unless validate_video_id(id)
|
||||||
return error_template(400, InvalidVideoID.new(id))
|
return Errors.error_template(400, InvalidVideoID.new(id))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
return env.redirect "/"
|
return env.redirect "/"
|
||||||
@ -48,10 +48,10 @@ module Invidious::Routes::Watch
|
|||||||
video = get_video(id, region: params.region)
|
video = get_video(id, region: params.region)
|
||||||
rescue ex : NotFoundException
|
rescue ex : NotFoundException
|
||||||
LOGGER.error("get_video not found: #{id} : #{ex.message}")
|
LOGGER.error("get_video not found: #{id} : #{ex.message}")
|
||||||
return error_template(404, ex)
|
return Errors.error_template(404, ex)
|
||||||
rescue ex
|
rescue ex
|
||||||
LOGGER.error("get_video: #{id} : #{ex.message}")
|
LOGGER.error("get_video: #{id} : #{ex.message}")
|
||||||
return error_template(500, ex)
|
return Errors.error_template(500, ex)
|
||||||
end
|
end
|
||||||
|
|
||||||
if preferences.annotations_subscribed &&
|
if preferences.annotations_subscribed &&
|
||||||
@ -220,7 +220,7 @@ module Invidious::Routes::Watch
|
|||||||
if redirect
|
if redirect
|
||||||
return env.redirect referer
|
return env.redirect referer
|
||||||
else
|
else
|
||||||
return error_json(403, "No such user")
|
return Errors.error_json(403, "No such user")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -235,12 +235,12 @@ module Invidious::Routes::Watch
|
|||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
validate_request(token, sid, env.request, HMAC_KEY, locale)
|
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
|
||||||
rescue ex
|
rescue ex
|
||||||
if redirect
|
if redirect
|
||||||
return error_template(400, ex)
|
return Errors.error_template(400, ex)
|
||||||
else
|
else
|
||||||
return error_json(400, ex)
|
return Errors.error_json(400, ex)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -250,7 +250,7 @@ module Invidious::Routes::Watch
|
|||||||
when "mark_unwatched"
|
when "mark_unwatched"
|
||||||
Invidious::Database::Users.mark_unwatched(user, id)
|
Invidious::Database::Users.mark_unwatched(user, id)
|
||||||
else
|
else
|
||||||
return error_json(400, "Unsupported action #{action}")
|
return Errors.error_json(400, "Unsupported action #{action}")
|
||||||
end
|
end
|
||||||
|
|
||||||
if redirect
|
if redirect
|
||||||
@ -264,10 +264,10 @@ module Invidious::Routes::Watch
|
|||||||
def self.clip(env)
|
def self.clip(env)
|
||||||
clip_id = env.params.url["clip"]?
|
clip_id = env.params.url["clip"]?
|
||||||
|
|
||||||
return error_template(400, "A clip ID is required") if !clip_id
|
return Errors.error_template(400, "A clip ID is required") if !clip_id
|
||||||
|
|
||||||
response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}")
|
response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}")
|
||||||
return error_template(400, "Invalid clip ID") if response["error"]?
|
return Errors.error_template(400, "Invalid clip ID") if response["error"]?
|
||||||
|
|
||||||
if video_id = response.dig?("endpoint", "watchEndpoint", "videoId")
|
if video_id = response.dig?("endpoint", "watchEndpoint", "videoId")
|
||||||
if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
|
if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
|
||||||
@ -278,16 +278,16 @@ module Invidious::Routes::Watch
|
|||||||
|
|
||||||
return env.redirect "/watch?v=#{video_id}&#{env.params.query}"
|
return env.redirect "/watch?v=#{video_id}&#{env.params.query}"
|
||||||
else
|
else
|
||||||
return error_template(404, "The requested clip doesn't exist")
|
return Errors.error_template(404, "The requested clip doesn't exist")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.download(env)
|
def self.download(env)
|
||||||
if CONFIG.disabled?("downloads")
|
if CONFIG.disabled?("downloads")
|
||||||
return error_template(403, "Administrator has disabled this endpoint.")
|
return Errors.error_template(403, "Administrator has disabled this endpoint.")
|
||||||
end
|
end
|
||||||
if CONFIG.invidious_companion.present?
|
if CONFIG.invidious_companion.present?
|
||||||
return error_template(403, "Downloads should be routed through Companion when present")
|
return Errors.error_template(403, "Downloads should be routed through Companion when present")
|
||||||
end
|
end
|
||||||
|
|
||||||
title = env.params.body["title"]? || ""
|
title = env.params.body["title"]? || ""
|
||||||
@ -295,7 +295,7 @@ module Invidious::Routes::Watch
|
|||||||
selection = env.params.body["download_widget"]?
|
selection = env.params.body["download_widget"]?
|
||||||
|
|
||||||
if title.empty? || video_id.empty? || selection.nil?
|
if title.empty? || video_id.empty? || selection.nil?
|
||||||
return error_template(400, "Missing form data")
|
return Errors.error_template(400, "Missing form data")
|
||||||
end
|
end
|
||||||
|
|
||||||
download_widget = JSON.parse(selection)
|
download_widget = JSON.parse(selection)
|
||||||
@ -326,7 +326,7 @@ module Invidious::Routes::Watch
|
|||||||
|
|
||||||
return Invidious::Routes::VideoPlayback.latest_version(env)
|
return Invidious::Routes::VideoPlayback.latest_version(env)
|
||||||
else
|
else
|
||||||
return error_template(400, "Invalid label or itag")
|
return Errors.error_template(400, "Invalid label or itag")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
module Invidious::Search::CToken
|
||||||
|
extend self
|
||||||
|
|
||||||
def produce_channel_search_continuation(ucid, query, page)
|
def produce_channel_search_continuation(ucid, query, page)
|
||||||
if page <= 1
|
if page <= 1
|
||||||
idx = 0_i64
|
idx = 0_i64
|
||||||
@ -30,3 +33,4 @@ def produce_channel_search_continuation(ucid, query, page)
|
|||||||
|
|
||||||
return continuation
|
return continuation
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
module Invidious::Hashtag
|
module Invidious::Search::Hashtag
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
def fetch(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem)
|
def fetch_hashtag(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem)
|
||||||
cursor = (page - 1) * 60
|
cursor = (page - 1) * 60
|
||||||
ctoken = generate_continuation(hashtag, cursor)
|
ctoken = generate_continuation(hashtag, cursor)
|
||||||
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
module Invidious::Search
|
module Invidious::Search::Processors
|
||||||
module Processors
|
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
# Regular search (`/search` endpoint)
|
# Regular search (`/search` endpoint)
|
||||||
@ -21,14 +20,14 @@ module Invidious::Search
|
|||||||
if response.status_code == 404
|
if response.status_code == 404
|
||||||
response = YT_POOL.client &.get("/user/#{query.channel}")
|
response = YT_POOL.client &.get("/user/#{query.channel}")
|
||||||
response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404
|
response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404
|
||||||
initial_data = Helpers.extract_initial_data(response.body)
|
initial_data = Invidious::Helpers.extract_initial_data(response.body)
|
||||||
ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
|
ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
|
||||||
raise ChannelSearchException.new(query.channel) if !ucid
|
raise ChannelSearchException.new(query.channel) if !ucid
|
||||||
else
|
else
|
||||||
ucid = query.channel
|
ucid = query.channel
|
||||||
end
|
end
|
||||||
|
|
||||||
continuation = produce_channel_search_continuation(ucid, query.text, query.page)
|
continuation = Invidious::Search::CToken.produce_channel_search_continuation(ucid, query.text, query.page)
|
||||||
response_json = YoutubeAPI.browse(continuation)
|
response_json = YoutubeAPI.browse(continuation)
|
||||||
|
|
||||||
items, _ = extract_items(response_json, "", ucid)
|
items, _ = extract_items(response_json, "", ucid)
|
||||||
@ -53,4 +52,3 @@ module Invidious::Search
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
def fetch_trending(trending_type, region, locale)
|
|
||||||
region ||= "US"
|
|
||||||
region = region.upcase
|
|
||||||
|
|
||||||
plid = nil
|
|
||||||
|
|
||||||
browse_id = ""
|
|
||||||
|
|
||||||
case trending_type.try &.downcase
|
|
||||||
when "gaming"
|
|
||||||
browse_id = "UCOpNcN46UbXVtpKMrmU4Abg"
|
|
||||||
params = "Egh0cmVuZGluZw%3D%3D"
|
|
||||||
when "livestreams"
|
|
||||||
browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
|
|
||||||
params = "EgdsaXZldGFikgEDCKEK"
|
|
||||||
else
|
|
||||||
# Livestreams is the default one as Youtube removed
|
|
||||||
# the aggregated trending page
|
|
||||||
# https://github.com/iv-org/invidious/issues/5397#issuecomment-3218928458
|
|
||||||
browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
|
|
||||||
params = "EgdsaXZldGFikgEDCKEK"
|
|
||||||
end
|
|
||||||
|
|
||||||
client_config = YoutubeAPI::ClientConfig.new(region: region)
|
|
||||||
initial_data = YoutubeAPI.browse(browse_id, params: params, client_config: client_config)
|
|
||||||
|
|
||||||
items, _ = extract_items(initial_data)
|
|
||||||
|
|
||||||
extracted = [] of SearchItem
|
|
||||||
|
|
||||||
deduplicate = items.size > 1
|
|
||||||
|
|
||||||
items.each do |itm|
|
|
||||||
if itm.is_a?(Category)
|
|
||||||
# Ignore the smaller categories, as they generally contain a sponsored
|
|
||||||
# channel, which brings a lot of noise on the trending page.
|
|
||||||
# See: https://github.com/iv-org/invidious/issues/2989
|
|
||||||
next if (itm.contents.size < 24 && deduplicate)
|
|
||||||
|
|
||||||
extracted.concat itm.contents.select(SearchItem)
|
|
||||||
else
|
|
||||||
extracted << itm
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Deduplicate items before returning results
|
|
||||||
return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid
|
|
||||||
end
|
|
||||||
@ -55,7 +55,7 @@ struct Invidious::User
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
question: image,
|
question: image,
|
||||||
tokens: {generate_response(answer, {":login"}, key, use_nonce: true)},
|
tokens: {Invidious::Helpers::Tokens.generate_response(answer, {":login"}, key, use_nonce: true)},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
module Invidious::User::Converters
|
||||||
|
extend self
|
||||||
|
|
||||||
def convert_theme(theme)
|
def convert_theme(theme)
|
||||||
case theme
|
case theme
|
||||||
when "true"
|
when "true"
|
||||||
@ -10,3 +13,4 @@ def convert_theme(theme)
|
|||||||
theme
|
theme
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|||||||
@ -15,7 +15,7 @@ struct Invidious::User
|
|||||||
playlists.each do |playlist|
|
playlists.each do |playlist|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "title", playlist.title
|
json.field "title", playlist.title
|
||||||
json.field "description", Helpers.html_to_content(playlist.description_html)
|
json.field "description", Invidious::Helpers.html_to_content(playlist.description_html)
|
||||||
json.field "privacy", playlist.privacy.to_s
|
json.field "privacy", playlist.privacy.to_s
|
||||||
json.field "videos" do
|
json.field "videos" do
|
||||||
json.array do
|
json.array do
|
||||||
|
|||||||
@ -43,7 +43,7 @@ struct Invidious::User
|
|||||||
description = "This is the default description of an imported playlist. Feel Free to change it as you see fit."
|
description = "This is the default description of an imported playlist. Feel Free to change it as you see fit."
|
||||||
privacy = PlaylistPrivacy::Private
|
privacy = PlaylistPrivacy::Private
|
||||||
|
|
||||||
playlist = create_playlist(title, privacy, user)
|
playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
|
||||||
Invidious::Database::Playlists.update_description(playlist.id, description)
|
Invidious::Database::Playlists.update_description(playlist.id, description)
|
||||||
|
|
||||||
# Add each video to the playlist from the body content
|
# Add each video to the playlist from the body content
|
||||||
@ -117,7 +117,7 @@ struct Invidious::User
|
|||||||
next if !description
|
next if !description
|
||||||
next if !privacy
|
next if !privacy
|
||||||
|
|
||||||
playlist = create_playlist(title, privacy, user)
|
playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
|
||||||
Invidious::Database::Playlists.update_description(playlist.id, description)
|
Invidious::Database::Playlists.update_description(playlist.id, description)
|
||||||
|
|
||||||
item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
|
item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
|
||||||
|
|||||||
109
src/invidious/user/users.cr
Normal file
109
src/invidious/user/users.cr
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
require "crypto/bcrypt/password"
|
||||||
|
|
||||||
|
module Invidious::User::Users
|
||||||
|
extend self
|
||||||
|
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
|
||||||
|
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
|
||||||
|
|
||||||
|
def create_user(sid, email, password)
|
||||||
|
password = Crypto::Bcrypt::Password.create(password, cost: 10)
|
||||||
|
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
||||||
|
|
||||||
|
user = Invidious::User.new({
|
||||||
|
updated: Time.utc,
|
||||||
|
notifications: [] of String,
|
||||||
|
subscriptions: [] of String,
|
||||||
|
email: email,
|
||||||
|
preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple),
|
||||||
|
password: password.to_s,
|
||||||
|
token: token,
|
||||||
|
watched: [] of String,
|
||||||
|
feed_needs_update: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return user, sid
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_subscription_feed(user, max_results = 40, page = 1)
|
||||||
|
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
notifications = Invidious::Database::Users.select_notifications(user)
|
||||||
|
view_name = "subscriptions_#{sha256(user.email)}"
|
||||||
|
|
||||||
|
if user.preferences.notifications_only && !notifications.empty?
|
||||||
|
# Only show notifications
|
||||||
|
notifications = Invidious::Database::ChannelVideos.select(notifications)
|
||||||
|
videos = [] of ChannelVideo
|
||||||
|
|
||||||
|
notifications.sort_by!(&.published).reverse!
|
||||||
|
|
||||||
|
case user.preferences.sort
|
||||||
|
when "alphabetically"
|
||||||
|
notifications.sort_by!(&.title)
|
||||||
|
when "alphabetically - reverse"
|
||||||
|
notifications.sort_by!(&.title).reverse!
|
||||||
|
when "channel name"
|
||||||
|
notifications.sort_by!(&.author)
|
||||||
|
when "channel name - reverse"
|
||||||
|
notifications.sort_by!(&.author).reverse!
|
||||||
|
else nil # Ignore
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if user.preferences.latest_only
|
||||||
|
if user.preferences.unseen_only
|
||||||
|
# Show latest video from a channel that a user hasn't watched
|
||||||
|
# "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
|
||||||
|
|
||||||
|
if user.watched.empty?
|
||||||
|
values = "'{}'"
|
||||||
|
else
|
||||||
|
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
|
||||||
|
end
|
||||||
|
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo)
|
||||||
|
else
|
||||||
|
# Show latest video from each channel
|
||||||
|
|
||||||
|
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
|
||||||
|
end
|
||||||
|
|
||||||
|
videos.sort_by!(&.published).reverse!
|
||||||
|
else
|
||||||
|
if user.preferences.unseen_only
|
||||||
|
# Only show unwatched
|
||||||
|
|
||||||
|
if user.watched.empty?
|
||||||
|
values = "'{}'"
|
||||||
|
else
|
||||||
|
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
|
||||||
|
end
|
||||||
|
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
|
||||||
|
else
|
||||||
|
# Sort subscriptions as normal
|
||||||
|
|
||||||
|
videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
case user.preferences.sort
|
||||||
|
when "published - reverse"
|
||||||
|
videos.sort_by!(&.published)
|
||||||
|
when "alphabetically"
|
||||||
|
videos.sort_by!(&.title)
|
||||||
|
when "alphabetically - reverse"
|
||||||
|
videos.sort_by!(&.title).reverse!
|
||||||
|
when "channel name"
|
||||||
|
videos.sort_by!(&.author)
|
||||||
|
when "channel name - reverse"
|
||||||
|
videos.sort_by!(&.author).reverse!
|
||||||
|
else nil # Ignore
|
||||||
|
end
|
||||||
|
|
||||||
|
notifications = Invidious::Database::Users.select_notifications(user)
|
||||||
|
notifications = videos.select { |v| notifications.includes? v.id }
|
||||||
|
videos = videos - notifications
|
||||||
|
end
|
||||||
|
|
||||||
|
return videos, notifications
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,106 +0,0 @@
|
|||||||
require "crypto/bcrypt/password"
|
|
||||||
|
|
||||||
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
|
|
||||||
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
|
|
||||||
|
|
||||||
def create_user(sid, email, password)
|
|
||||||
password = Crypto::Bcrypt::Password.create(password, cost: 10)
|
|
||||||
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
|
|
||||||
|
|
||||||
user = Invidious::User.new({
|
|
||||||
updated: Time.utc,
|
|
||||||
notifications: [] of String,
|
|
||||||
subscriptions: [] of String,
|
|
||||||
email: email,
|
|
||||||
preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple),
|
|
||||||
password: password.to_s,
|
|
||||||
token: token,
|
|
||||||
watched: [] of String,
|
|
||||||
feed_needs_update: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return user, sid
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_subscription_feed(user, max_results = 40, page = 1)
|
|
||||||
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
|
|
||||||
notifications = Invidious::Database::Users.select_notifications(user)
|
|
||||||
view_name = "subscriptions_#{sha256(user.email)}"
|
|
||||||
|
|
||||||
if user.preferences.notifications_only && !notifications.empty?
|
|
||||||
# Only show notifications
|
|
||||||
notifications = Invidious::Database::ChannelVideos.select(notifications)
|
|
||||||
videos = [] of ChannelVideo
|
|
||||||
|
|
||||||
notifications.sort_by!(&.published).reverse!
|
|
||||||
|
|
||||||
case user.preferences.sort
|
|
||||||
when "alphabetically"
|
|
||||||
notifications.sort_by!(&.title)
|
|
||||||
when "alphabetically - reverse"
|
|
||||||
notifications.sort_by!(&.title).reverse!
|
|
||||||
when "channel name"
|
|
||||||
notifications.sort_by!(&.author)
|
|
||||||
when "channel name - reverse"
|
|
||||||
notifications.sort_by!(&.author).reverse!
|
|
||||||
else nil # Ignore
|
|
||||||
end
|
|
||||||
else
|
|
||||||
if user.preferences.latest_only
|
|
||||||
if user.preferences.unseen_only
|
|
||||||
# Show latest video from a channel that a user hasn't watched
|
|
||||||
# "unseen_only" isn't really correct here, more accurate would be "unwatched_only"
|
|
||||||
|
|
||||||
if user.watched.empty?
|
|
||||||
values = "'{}'"
|
|
||||||
else
|
|
||||||
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
|
|
||||||
end
|
|
||||||
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo)
|
|
||||||
else
|
|
||||||
# Show latest video from each channel
|
|
||||||
|
|
||||||
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
|
|
||||||
end
|
|
||||||
|
|
||||||
videos.sort_by!(&.published).reverse!
|
|
||||||
else
|
|
||||||
if user.preferences.unseen_only
|
|
||||||
# Only show unwatched
|
|
||||||
|
|
||||||
if user.watched.empty?
|
|
||||||
values = "'{}'"
|
|
||||||
else
|
|
||||||
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
|
|
||||||
end
|
|
||||||
videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
|
|
||||||
else
|
|
||||||
# Sort subscriptions as normal
|
|
||||||
|
|
||||||
videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
case user.preferences.sort
|
|
||||||
when "published - reverse"
|
|
||||||
videos.sort_by!(&.published)
|
|
||||||
when "alphabetically"
|
|
||||||
videos.sort_by!(&.title)
|
|
||||||
when "alphabetically - reverse"
|
|
||||||
videos.sort_by!(&.title).reverse!
|
|
||||||
when "channel name"
|
|
||||||
videos.sort_by!(&.author)
|
|
||||||
when "channel name - reverse"
|
|
||||||
videos.sort_by!(&.author).reverse!
|
|
||||||
else nil # Ignore
|
|
||||||
end
|
|
||||||
|
|
||||||
notifications = Invidious::Database::Users.select_notifications(user)
|
|
||||||
notifications = videos.select { |v| notifications.includes? v.id }
|
|
||||||
videos = videos - notifications
|
|
||||||
end
|
|
||||||
|
|
||||||
return videos, notifications
|
|
||||||
end
|
|
||||||
@ -87,7 +87,7 @@ module Invidious::Videos::Parser
|
|||||||
|
|
||||||
# Although technically not a call to /videoplayback the fact that YouTube is returning the
|
# Although technically not a call to /videoplayback the fact that YouTube is returning the
|
||||||
# wrong video means that we should count it as a failure.
|
# wrong video means that we should count it as a failure.
|
||||||
Helpers.get_playback_statistic["totalRequests"] += 1
|
Invidious::Helpers.get_playback_statistic["totalRequests"] += 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
|
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
|
||||||
|
|||||||
@ -106,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<details>
|
<details>
|
||||||
<summary class="pure-button pure-button-secondary"><%=I18n.translate(locale, "timeline_parse_error_show_technical_details")%></summary>
|
<summary class="pure-button pure-button-secondary"><%=I18n.translate(locale, "timeline_parse_error_show_technical_details")%></summary>
|
||||||
<pre class="error-issue-template"><%=get_issue_template(env, item.parse_exception)[1]%></pre>
|
<pre class="error-issue-template"><%=Errors.get_issue_template(env, item.parse_exception)[1]%></pre>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user