chore: Encapsulate more functions into their own modules and improve specs

This commit is contained in:
Fijxu 2026-09-15 17:49:12 -03:00
parent e4ad826f1f
commit 7e106030e6
No known key found for this signature in database
GPG Key ID: 32C1DDF333EDA6A4
58 changed files with 2269 additions and 2006 deletions

File diff suppressed because one or more lines are too long

View 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

View 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

View File

@ -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|

View File

@ -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

View File

@ -19,7 +19,10 @@ record AboutChannel,
verified : Bool, verified : Bool,
is_age_gated : Bool is_age_gated : Bool
def get_about_info(ucid) : AboutChannel module Invidious::Channels::About
extend self
def get_about_info(ucid) : AboutChannel
begin begin
# Fetch channel information from channel home page # Fetch channel information from channel home page
initdata = YoutubeAPI.browse(browse_id: ucid, params: "") initdata = YoutubeAPI.browse(browse_id: ucid, params: "")
@ -204,9 +207,9 @@ def get_about_info(ucid) : AboutChannel
verified: author_verified || false, verified: author_verified || false,
is_age_gated: is_age_gated || false, is_age_gated: is_age_gated || false,
) )
end end
def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?} def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?}
if continuation.nil? if continuation.nil?
# params is {"2:string":"channels"} encoded # params is {"2:string":"channels"} encoded
initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D")
@ -217,4 +220,5 @@ def fetch_related_channels(about_channel : AboutChannel, continuation : String?
items, continuation = extract_items(initial_data) items, continuation = extract_items(initial_data)
return items.select(SearchChannel), continuation return items.select(SearchChannel), continuation
end
end end

View File

@ -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,18 +144,21 @@ def get_batch_channels(channels)
return final return final
end end
def get_channel(id) : InvidiousChannel module Invidious::Channels::Channels
extend self
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
return channel return channel
end end
def fetch_channel(ucid, pull_all_videos : Bool) def fetch_channel(ucid, pull_all_videos : Bool)
LOGGER.debug("fetch_channel: #{ucid}") LOGGER.debug("fetch_channel: #{ucid}")
LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}") LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}")
@ -293,4 +296,5 @@ def fetch_channel(ucid, pull_all_videos : Bool)
channel.updated = Time.utc channel.updated = Time.utc
return channel return channel
end
end end

View File

@ -1,7 +1,9 @@
private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000} module Invidious::Channels::Community
extend self
private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000}
# TODO: Add "sort_by" # TODO: Add "sort_by"
def fetch_channel_community(ucid, cursor, locale, format, thin_mode) def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
if cursor.nil? if cursor.nil?
# EgVwb3N0c_IGBAoCSgA%3D is the protobuf object to load "posts" # EgVwb3N0c_IGBAoCSgA%3D is the protobuf object to load "posts"
initial_data = YoutubeAPI.browse(ucid, params: "EgVwb3N0c_IGBAoCSgA%3D") initial_data = YoutubeAPI.browse(ucid, params: "EgVwb3N0c_IGBAoCSgA%3D")
@ -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")
@ -22,18 +24,18 @@ def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
end end
return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode)
end end
def decode_ucid_from_post_protobuf(params) def decode_ucid_from_post_protobuf(params)
decoded_protobuf = params.try { |i| URI.decode_www_form(i) } decoded_protobuf = params.try { |i| URI.decode_www_form(i) }
.try { |i| Base64.decode(i) } .try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) } .try { |i| IO::Memory.new(i) }
.try { |i| Protodec::Any.parse(i) } .try { |i| Protodec::Any.parse(i) }
return decoded_protobuf.try(&.["56:0:embedded"]["2:0:string"].as_s) return decoded_protobuf.try(&.["56:0:embedded"]["2:0:string"].as_s)
end end
def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode) def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode)
object = { object = {
"56:embedded" => { "56:embedded" => {
"2:string" => ucid, "2:string" => ucid,
@ -54,9 +56,9 @@ def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode)
end end
return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true) return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true)
end end
def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false) def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false)
if message = items[0]["messageRenderer"]? if message = items[0]["messageRenderer"]?
error_message = (message["text"]["simpleText"]? || error_message = (message["text"]["simpleText"]? ||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?) message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
@ -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
@ -295,9 +297,9 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing
end end
return response return response
end end
def produce_channel_community_continuation(ucid, cursor) def produce_channel_community_continuation(ucid, cursor)
object = { object = {
"80226972:embedded" => { "80226972:embedded" => {
"2:string" => ucid, "2:string" => ucid,
@ -311,9 +313,9 @@ def produce_channel_community_continuation(ucid, cursor)
.try { |i| URI.encode_www_form(i) } .try { |i| URI.encode_www_form(i) }
return continuation return continuation
end end
def extract_channel_community_cursor(continuation) def extract_channel_community_cursor(continuation)
object = URI.decode_www_form(continuation) object = URI.decode_www_form(continuation)
.try { |i| Base64.decode(i) } .try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) } .try { |i| IO::Memory.new(i) }
@ -335,4 +337,5 @@ def extract_channel_community_cursor(continuation)
.try { |i| Base64.urlsafe_encode(i) } .try { |i| Base64.urlsafe_encode(i) }
cursor cursor
end
end end

View File

@ -1,4 +1,7 @@
def fetch_channel_playlists(ucid, author, continuation, sort_by) module Invidious::Channels::Playlists
extend self
def fetch_channel_playlists(ucid, author, continuation, sort_by)
if continuation if continuation
initial_data = YoutubeAPI.browse(continuation) initial_data = YoutubeAPI.browse(continuation)
else else
@ -25,31 +28,32 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by)
end end
return extract_items(initial_data, author, ucid) return extract_items(initial_data, author, ucid)
end end
def fetch_channel_podcasts(ucid, author, continuation) def fetch_channel_podcasts(ucid, author, continuation)
if continuation if continuation
initial_data = YoutubeAPI.browse(continuation) initial_data = YoutubeAPI.browse(continuation)
else else
initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA") initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA")
end end
return extract_items(initial_data, author, ucid) return extract_items(initial_data, author, ucid)
end end
def fetch_channel_releases(ucid, author, continuation) def fetch_channel_releases(ucid, author, continuation)
if continuation if continuation
initial_data = YoutubeAPI.browse(continuation) initial_data = YoutubeAPI.browse(continuation)
else else
initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA") initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA")
end end
return extract_items(initial_data, author, ucid) return extract_items(initial_data, author, ucid)
end end
def fetch_channel_courses(ucid, author, continuation) def fetch_channel_courses(ucid, author, continuation)
if continuation if continuation
initial_data = YoutubeAPI.browse(continuation) initial_data = YoutubeAPI.browse(continuation)
else else
initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D") initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D")
end end
return extract_items(initial_data, author, ucid) return extract_items(initial_data, author, ucid)
end
end end

View File

@ -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

View 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

View 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

View File

@ -2,11 +2,14 @@
# Issue template # Issue template
# ------------------- # -------------------
macro error_template(*args) module Errors
error_template_helper(env, {{args.splat}}) extend self
end
def github_details(summary : String, content : String) macro error_template(*args)
Errors.error_template_helper(env, {{args.splat}})
end
def github_details(summary : String, content : String)
details = %(\n<details>) details = %(\n<details>)
details += %(\n<summary>#{summary}</summary>) details += %(\n<summary>#{summary}</summary>)
details += %(\n<p>) details += %(\n<p>)
@ -16,9 +19,9 @@ def github_details(summary : String, content : String)
details += %(\n</p>) details += %(\n</p>)
details += %(\n</details>) details += %(\n</details>)
return HTML.escape(details) return HTML.escape(details)
end end
def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tuple(String, String) def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tuple(String, String)
issue_title = "#{exception.message} (#{exception.class})" issue_title = "#{exception.message} (#{exception.class})"
issue_template = <<-TEXT issue_template = <<-TEXT
@ -32,9 +35,9 @@ def get_issue_template(env : HTTP::Server::Context, exception : Exception) : Tup
issue_template += github_details("Backtrace", exception.inspect_with_backtrace) issue_template += github_details("Backtrace", exception.inspect_with_backtrace)
return issue_title, issue_template return issue_title, issue_template
end end
def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
if exception.is_a?(InfoException) if exception.is_a?(InfoException)
return error_template_helper(env, status_code, exception.message || "") return error_template_helper(env, status_code, exception.message || "")
end end
@ -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
@ -87,9 +90,9 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
next_steps = "" next_steps = ""
return templated "error" return templated "error"
end end
def error_template_helper(env : HTTP::Server::Context, status_code : Int32, message : String) def error_template_helper(env : HTTP::Server::Context, status_code : Int32, message : String)
env.response.content_type = "text/html" env.response.content_type = "text/html"
env.response.status_code = status_code env.response.status_code = status_code
@ -99,17 +102,17 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, mess
next_steps = error_redirect_helper(env) next_steps = error_redirect_helper(env)
return templated "error" return templated "error"
end end
# ------------------- # -------------------
# Atom feeds # Atom feeds
# ------------------- # -------------------
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)
if exception.is_a?(InfoException) if exception.is_a?(InfoException)
return error_atom_helper(env, status_code, exception.message || "") return error_atom_helper(env, status_code, exception.message || "")
end end
@ -118,29 +121,29 @@ def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exceptio
env.response.status_code = status_code env.response.status_code = status_code
return "<error>#{exception.inspect_with_backtrace}</error>" return "<error>#{exception.inspect_with_backtrace}</error>"
end end
def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, message : String) def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, message : String)
env.response.content_type = "application/atom+xml" env.response.content_type = "application/atom+xml"
env.response.status_code = status_code env.response.status_code = status_code
return "<error>#{message}</error>" return "<error>#{message}</error>"
end end
# ------------------- # -------------------
# JSON # JSON
# ------------------- # -------------------
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(
env : HTTP::Server::Context, env : HTTP::Server::Context,
status_code : Int32, status_code : Int32,
exception : Exception, exception : Exception,
additional_fields : Hash(String, Object) | Nil = nil, additional_fields : Hash(String, Object) | Nil = nil,
) )
if exception.is_a?(InfoException) if exception.is_a?(InfoException)
return error_json_helper(env, status_code, exception.message || "", additional_fields) return error_json_helper(env, status_code, exception.message || "", additional_fields)
end end
@ -155,14 +158,14 @@ def error_json_helper(
end end
return error_message.to_json return error_message.to_json
end end
def error_json_helper( def error_json_helper(
env : HTTP::Server::Context, env : HTTP::Server::Context,
status_code : Int32, status_code : Int32,
message : String, message : String,
additional_fields : Hash(String, Object) | Nil = nil, additional_fields : Hash(String, Object) | Nil = nil,
) )
env.response.content_type = "application/json" env.response.content_type = "application/json"
env.response.status_code = status_code env.response.status_code = status_code
@ -173,13 +176,13 @@ def error_json_helper(
end end
return error_message.to_json return error_message.to_json
end end
# ------------------- # -------------------
# Redirect # Redirect
# ------------------- # -------------------
def error_redirect_helper(env : HTTP::Server::Context) def error_redirect_helper(env : HTTP::Server::Context)
request_path = env.request.path request_path = env.request.path
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
@ -208,4 +211,5 @@ def error_redirect_helper(env : HTTP::Server::Context)
else else
return "" return ""
end end
end
end end

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,9 @@
require "crypto/subtle" require "crypto/subtle"
def generate_token(email, scopes, expire, key) module Invidious::Helpers::Tokens
extend self
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)
@ -17,9 +20,9 @@ def generate_token(email, scopes, expire, key)
token["signature"] = sign_token(key, token) token["signature"] = sign_token(key, token)
return token.to_json return token.to_json
end end
def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false) def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false)
expire = Time.utc + expire expire = Time.utc + expire
token = { token = {
@ -37,9 +40,9 @@ def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false)
token["signature"] = sign_token(key, token) token["signature"] = sign_token(key, token)
return token.to_json return token.to_json
end end
def sign_token(key, hash) def sign_token(key, hash)
string_to_sign = [] of String string_to_sign = [] of String
# TODO: figure out which "key" variable is used # TODO: figure out which "key" variable is used
@ -64,9 +67,9 @@ def sign_token(key, hash)
string_to_sign = string_to_sign.sort.join("\n") string_to_sign = string_to_sign.sort.join("\n")
return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip
end end
def validate_request(token, session, request, key, locale = nil) def validate_request(token, session, request, key, locale = nil)
case token case token
when String when String
token = JSON.parse(URI.decode_www_form(token)).as_h token = JSON.parse(URI.decode_www_form(token)).as_h
@ -104,9 +107,9 @@ def validate_request(token, session, request, key, locale = nil)
end end
return {scopes, expire, token["signature"].as_s} return {scopes, expire, token["signature"].as_s}
end end
def scope_includes_scope(scope, subset) def scope_includes_scope(scope, subset)
methods, endpoint = scope.split(":") methods, endpoint = scope.split(":")
methods = methods.split(";").map(&.upcase).reject(&.empty?).sort! methods = methods.split(";").map(&.upcase).reject(&.empty?).sort!
endpoint = endpoint.downcase endpoint = endpoint.downcase
@ -132,14 +135,15 @@ def scope_includes_scope(scope, subset)
end end
return true return true
end 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.")

View 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

View File

@ -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

View File

@ -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

View File

@ -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|

View File

@ -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|

View File

@ -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

View File

@ -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|

View File

@ -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

View File

@ -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

View File

@ -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} "

View File

@ -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 &&

View File

@ -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|

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -1,4 +1,7 @@
def produce_channel_search_continuation(ucid, query, page) module Invidious::Search::CToken
extend self
def produce_channel_search_continuation(ucid, query, page)
if page <= 1 if page <= 1
idx = 0_i64 idx = 0_i64
else else
@ -29,4 +32,5 @@ def produce_channel_search_continuation(ucid, query, page)
.try { |i| URI.encode_www_form(i) } .try { |i| URI.encode_www_form(i) }
return continuation return continuation
end
end end

View File

@ -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)

View File

@ -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)
@ -52,5 +51,4 @@ module Invidious::Search
as: ChannelVideo as: ChannelVideo
) )
end end
end
end end

View File

@ -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

View File

@ -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

View File

@ -1,4 +1,7 @@
def convert_theme(theme) module Invidious::User::Converters
extend self
def convert_theme(theme)
case theme case theme
when "true" when "true"
"dark" "dark"
@ -9,4 +12,5 @@ def convert_theme(theme)
else else
theme theme
end end
end
end end

View File

@ -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

View File

@ -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
View 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

View File

@ -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

View File

@ -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),

View File

@ -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 %>