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/playlists"
require "../src/invidious/search/ctoken"
require "../src/invidious/trending"
require "spectator"
Spectator.configure do |config|

View File

@ -44,6 +44,8 @@ require "./invidious/jsonify/**"
require "./invidious/*"
require "./invidious/comments/*"
require "./invidious/channels/*"
require "./invidious/playlists/*"
require "./invidious/feeds/*"
require "./invidious/user/*"
require "./invidious/search/*"
require "./invidious/routes/**"
@ -230,7 +232,7 @@ error 404 do |env|
end
error 500 do |env, exception|
error_template(500, exception)
Errors.error_template(500, exception)
end
# Init Kemal

View File

@ -19,6 +19,9 @@ record AboutChannel,
verified : Bool,
is_age_gated : Bool
module Invidious::Channels::About
extend self
def get_about_info(ucid) : AboutChannel
begin
# Fetch channel information from channel home page
@ -218,3 +221,4 @@ def fetch_related_channels(about_channel : AboutChannel, continuation : String?
return items.select(SearchChannel), continuation
end
end

View File

@ -123,7 +123,7 @@ def get_batch_channels(channels)
active_threads += 1
spawn do
begin
get_channel(ucid)
Invidious::Channels::Channels.get_channel(ucid)
finished_channel.send(ucid)
rescue ex
finished_channel.send(nil)
@ -144,11 +144,14 @@ def get_batch_channels(channels)
return final
end
module Invidious::Channels::Channels
extend self
def get_channel(id) : InvidiousChannel
channel = Invidious::Database::Channels.select(id)
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)
end
@ -294,3 +297,4 @@ def fetch_channel(ucid, pull_all_videos : Bool)
channel.updated = Time.utc
return channel
end
end

View File

@ -1,3 +1,5 @@
module Invidious::Channels::Community
extend self
private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000}
# TODO: Add "sort_by"
@ -11,7 +13,7 @@ def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
items << item
end
else
continuation = produce_channel_community_continuation(ucid, cursor)
continuation = self.produce_channel_community_continuation(ucid, cursor)
initial_data = YoutubeAPI.browse(continuation: continuation)
container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents")
@ -127,7 +129,7 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing
reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0")
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 "published", published.to_unix
@ -336,3 +338,4 @@ def extract_channel_community_cursor(continuation)
cursor
end
end

View File

@ -1,3 +1,6 @@
module Invidious::Channels::Playlists
extend self
def fetch_channel_playlists(ucid, author, continuation, sort_by)
if continuation
initial_data = YoutubeAPI.browse(continuation)
@ -53,3 +56,4 @@ def fetch_channel_courses(ucid, author, continuation)
end
return extract_items(initial_data, author, ucid)
end
end

View File

@ -246,7 +246,7 @@ module Invidious::Comments
end
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
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,8 +2,11 @@
# Issue template
# -------------------
module Errors
extend self
macro error_template(*args)
error_template_helper(env, {{args.splat}})
Errors.error_template_helper(env, {{args.splat}})
end
def github_details(summary : String, content : String)
@ -46,7 +49,7 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce
# Unpacking into issue_title, issue_template directly causes a compiler error
# 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
# URLs for the error message below
@ -106,7 +109,7 @@ end
# -------------------
macro error_atom(*args)
error_atom_helper(env, {{args.splat}})
Errors.error_atom_helper(env, {{args.splat}})
end
def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception)
@ -132,7 +135,7 @@ end
# -------------------
macro error_json(*args)
error_json_helper(env, {{args.splat}})
Errors.error_json_helper(env, {{args.splat}})
end
def error_json_helper(
@ -209,3 +212,4 @@ def error_redirect_helper(env : HTTP::Server::Context)
return ""
end
end
end

View File

@ -83,7 +83,7 @@ class AuthHandler < Kemal::Handler
if token = env.request.headers["Authorization"]?
token = JSON.parse(URI.decode_www_form(token.lchop("Bearer ")))
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)
user = Invidious::Database::Users.select!(email: email)

View File

@ -22,7 +22,7 @@ struct Annotation
property annotations : String
end
module Helpers
module Invidious::Helpers
extend self
private TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
@ -62,7 +62,7 @@ module Helpers
end
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})
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")
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
@ -63,7 +63,7 @@ struct SearchVideo
xml.element("media:title") { xml.text self.title }
xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
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
xml.element("media:community") do
@ -111,7 +111,7 @@ struct SearchVideo
Invidious::JSONify::APIv1.thumbnails(json, self.id)
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 "viewCount", self.views
@ -255,7 +255,7 @@ struct SearchChannel
json.field "videoCount", self.video_count
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
end
end
@ -332,7 +332,7 @@ struct ProblematicTimelineItem
end
xml.element("pre") do
get_issue_template(env, @parse_exception)
Errors.get_issue_template(env, @parse_exception)
end
end
end

View File

@ -1,5 +1,8 @@
require "crypto/subtle"
module Invidious::Helpers::Tokens
extend self
def generate_token(email, scopes, expire, key)
session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}"
Invidious::Database::SessionIDs.insert(session, email)
@ -136,10 +139,11 @@ end
def scopes_include_scope(scopes, subset)
scopes.each do |scope|
if scope_includes_scope(scope, subset)
if self.scope_includes_scope(scope, subset)
return true
end
end
return false
end
end

View File

@ -30,7 +30,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
spawn do
begin
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

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
if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool)
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)
end
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_"
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"]?
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)
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"
end
@ -43,33 +43,33 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
password = env.params.body["password"]?
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
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
return error_template(400, "New passwords must match")
return Errors.error_template(400, "New passwords must match")
end
new_password = new_passwords.uniq[0]
if new_password.empty?
return error_template(401, "Password cannot be empty")
return Errors.error_template(401, "Password cannot be empty")
end
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
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
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
@ -96,7 +96,7 @@ module Invidious::Routes::Account
user = user.as(User)
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"
end
@ -118,9 +118,9 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
@ -154,7 +154,7 @@ module Invidious::Routes::Account
user = user.as(User)
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"
end
@ -176,9 +176,9 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
Invidious::Database::Users.clear_watch_history(user)
@ -203,7 +203,7 @@ module Invidious::Routes::Account
user = user.as(User)
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 ||= [] of String
@ -235,16 +235,16 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v }
callback_url = env.params.body["callbackUrl"]?
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
access_token = URI.encode_www_form(access_token)
@ -310,7 +310,7 @@ module Invidious::Routes::Account
if redirect
return env.redirect referer
else
return error_json(403, "No such user")
return Errors.error_json(403, "No such user")
end
end
@ -319,12 +319,12 @@ module Invidious::Routes::Account
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
return error_template(400, ex)
return Errors.error_template(400, ex)
else
return error_json(400, ex)
return Errors.error_json(400, ex)
end
end
@ -333,7 +333,7 @@ module Invidious::Routes::Account
session = env.params.query["session"]
Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
else
return error_json(400, "Unsupported action #{action}")
return Errors.error_json(400, "Unsupported action #{action}")
end
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 ||= [] of String
# Helpers.create_notification_stream(env, topics, connection_channel)
# Invidious::Helpers.create_notification_stream(env, topics, connection_channel)
# end
def self.get_preferences(env)
@ -78,13 +78,13 @@ module Invidious::Routes::API::V1::Authenticated
user = env.get("user").as(User)
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
# Sanity checks
id = env.params.url["id"]
unless validate_video_id(id)
return error_json(400, InvalidVideoID.new(id))
return Errors.error_json(400, InvalidVideoID.new(id))
end
Invidious::Database::Users.mark_watched(user, id)
@ -95,12 +95,12 @@ module Invidious::Routes::API::V1::Authenticated
user = env.get("user").as(User)
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
video_id = env.params.url["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
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 ||= 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.object do
@ -175,7 +175,7 @@ module Invidious::Routes::API::V1::Authenticated
ucid = env.params.url["ucid"]
if !user.subscriptions.includes? ucid
get_channel(ucid)
Invidious::Channels::Channels.get_channel(ucid)
Invidious::Database::Users.subscribe_channel(user, ucid)
end
@ -214,19 +214,19 @@ module Invidious::Routes::API::V1::Authenticated
title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150)
if !title
return error_json(400, "Invalid title.")
return Errors.error_json(400, "Invalid title.")
end
privacy = env.params.json["privacy"]?.try { |p| PlaylistPrivacy.parse(p.as(String).downcase) }
if !privacy
return error_json(400, "Invalid privacy setting.")
return Errors.error_json(400, "Invalid privacy setting.")
end
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
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.status_code = 201
{
@ -241,16 +241,16 @@ module Invidious::Routes::API::V1::Authenticated
plid = env.params.url["plid"]?
if !plid || plid.empty?
return error_json(400, "A playlist ID is required")
return Errors.error_json(400, "A playlist ID is required")
end
playlist = Invidious::Database::Playlists.select(id: plid)
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
if playlist.author != user.email
return error_json(403, "Invalid user")
return Errors.error_json(403, "Invalid user")
end
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)
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
if playlist.author != user.email
return error_json(403, "Invalid user")
return Errors.error_json(403, "Invalid user")
end
Invidious::Database::Playlists.delete(plid)
@ -298,29 +298,29 @@ module Invidious::Routes::API::V1::Authenticated
playlist = Invidious::Database::Playlists.select(id: plid)
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
if playlist.author != user.email
return error_json(403, "Invalid user")
return Errors.error_json(403, "Invalid user")
end
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
video_id = env.params.json["videoId"].try &.as(String)
# Sanity checks
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
begin
video = get_video(video_id)
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
playlist_video = PlaylistVideo.new({
@ -355,15 +355,15 @@ module Invidious::Routes::API::V1::Authenticated
playlist = Invidious::Database::Playlists.select(id: plid)
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
if playlist.author != user.email
return error_json(403, "Invalid user")
return Errors.error_json(403, "Invalid user")
end
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
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)
expire = env.params.json["expire"]?.try &.as(Int64)
else
return error_json(400, "Invalid or missing header 'Content-Type'")
return Errors.error_json(400, "Invalid or missing header 'Content-Type'")
end
if callback_url && callback_url.empty?
@ -427,7 +427,7 @@ module Invidious::Routes::API::V1::Authenticated
# Used by template bellow.
# 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"
else
env.response.content_type = "application/json"
@ -436,12 +436,12 @@ module Invidious::Routes::API::V1::Authenticated
authorized_scopes = [] of String
scopes.each do |scope|
if scopes_include_scope(superset_scopes, scope)
if Invidious::Helpers::Tokens.scopes_include_scope(superset_scopes, scope)
authorized_scopes << scope
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
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
if session == env.get("session").as(String)
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)
else
return error_json(400, "Cannot revoke session #{session}")
return Errors.error_json(400, "Cannot revoke session #{session}")
end
env.response.status_code = 204
@ -489,6 +489,6 @@ module Invidious::Routes::API::V1::Authenticated
topics = raw_topics.try &.split(",").uniq!.first(1000)
topics ||= [] of String
Helpers.create_notification_stream(env, topics, CONNECTION_CHANNEL)
Invidious::Helpers.create_notification_stream(env, topics, CONNECTION_CHANNEL)
end
end

View File

@ -3,14 +3,14 @@ module Invidious::Routes::API::V1::Channels
# This sets the `channel` variable, or handles Exceptions.
private macro get_channel
begin
channel = get_about_info(ucid)
channel = Invidious::Channels::About.get_about_info(ucid)
rescue ex : ChannelRedirect
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
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -29,8 +29,8 @@ module Invidious::Routes::API::V1::Channels
if channel.is_age_gated
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
videos = get_playlist_videos(playlist, offset: 0)
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
@ -39,7 +39,7 @@ module Invidious::Routes::API::V1::Channels
begin
videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -96,7 +96,7 @@ module Invidious::Routes::API::V1::Channels
json.field "autoGenerated", channel.auto_generated
json.field "ageGated", channel.is_age_gated
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 "allowedRegions", channel.allowed_regions
@ -117,7 +117,7 @@ module Invidious::Routes::API::V1::Channels
json.array do
# Fetch related channels
begin
related_channels, _ = fetch_related_channels(channel)
related_channels, _ = Invidious::Channels::About.fetch_related_channels(channel)
rescue ex
related_channels = [] of SearchChannel
end
@ -155,8 +155,8 @@ module Invidious::Routes::API::V1::Channels
if channel.is_age_gated
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
videos = get_playlist_videos(playlist, offset: 0)
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
@ -168,7 +168,7 @@ module Invidious::Routes::API::V1::Channels
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -201,8 +201,8 @@ module Invidious::Routes::API::V1::Channels
if channel.is_age_gated
begin
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
videos = get_playlist_videos(playlist, offset: 0)
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UUSH"))
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
@ -214,7 +214,7 @@ module Invidious::Routes::API::V1::Channels
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -247,8 +247,8 @@ module Invidious::Routes::API::V1::Channels
if channel.is_age_gated
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
videos = get_playlist_videos(playlist, offset: 0)
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULV"))
videos = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
videos = [] of PlaylistVideo
@ -260,7 +260,7 @@ module Invidious::Routes::API::V1::Channels
channel, continuation: continuation, sort_by: sort_by
)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -292,7 +292,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
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.object do
@ -321,7 +321,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
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.object do
@ -350,7 +350,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
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.object do
@ -379,7 +379,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
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.object do
@ -413,9 +413,9 @@ module Invidious::Routes::API::V1::Channels
# sort_by = env.params.query["sort_by"]?.try &.downcase
begin
fetch_channel_community(ucid, continuation, locale, format, thin_mode)
Invidious::Channels::Community.fetch_channel_community(ucid, continuation, locale, format, thin_mode)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -434,16 +434,16 @@ module Invidious::Routes::API::V1::Channels
if ucid.nil?
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
return error_json(400, "Invalid post ID") if response["error"]?
ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
return Errors.error_json(400, "Invalid post ID") if response["error"]?
ucid = Invidious::Channels::Community.decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
else
ucid = ucid.to_s
end
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
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -487,9 +487,9 @@ module Invidious::Routes::API::V1::Channels
continuation = env.params.query["continuation"]?
begin
items, next_continuation = fetch_related_channels(channel, continuation)
items, next_continuation = Invidious::Channels::About.fetch_related_channels(channel, continuation)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
JSON.build do |json|
@ -520,7 +520,7 @@ module Invidious::Routes::API::V1::Channels
begin
search_results = query.process
rescue ex
return error_json(400, ex)
return Errors.error_json(400, ex)
end
JSON.build do |json|

View File

@ -8,9 +8,9 @@ module Invidious::Routes::API::V1::Feeds
trending_type = env.params.query["type"]?
begin
trending, _ = fetch_trending(trending_type, region, locale)
trending, _ = Invidious::Feeds::Trending.fetch(trending_type, region, locale)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
videos = JSON.build do |json|

View File

@ -50,16 +50,16 @@ module Invidious::Routes::API::V1::Misc
end
begin
playlist = get_playlist(plid)
playlist = Invidious::Playlists::Playlists.get_playlist(plid)
rescue ex : InfoException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(404, "Playlist does not exist.")
return Errors.error_json(404, "Playlist does not exist.")
end
user = env.get?("user").try &.as(User)
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
# includes into the playlist a maximum of 20 videos, before the offset
@ -88,7 +88,7 @@ module Invidious::Routes::API::V1::Misc
end
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}
response = {
@ -127,7 +127,7 @@ module Invidious::Routes::API::V1::Misc
mix.videos = mix.videos[index..-1]
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
response = JSON.build do |json|
@ -178,14 +178,14 @@ module Invidious::Routes::API::V1::Misc
env.response.content_type = "application/json"
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
resolved_url = YoutubeAPI.resolve_url(url.as(String))
endpoint = resolved_url["endpoint"]
page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || ""
if page_type == "WEB_PAGE_TYPE_UNKNOWN"
return error_json(400, "Unknown url")
return Errors.error_json(400, "Unknown url")
end
sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint
@ -204,7 +204,7 @@ module Invidious::Routes::API::V1::Misc
post_id = nil
end
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
JSON.build do |json|
json.object do

View File

@ -10,7 +10,7 @@ module Invidious::Routes::API::V1::Search
begin
search_results = query.process
rescue ex
return error_json(400, ex)
return Errors.error_json(400, ex)
end
JSON.build do |json|
@ -53,7 +53,7 @@ module Invidious::Routes::API::V1::Search
end
end
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -67,9 +67,9 @@ module Invidious::Routes::API::V1::Search
env.response.content_type = "application/json"
begin
results = Invidious::Hashtag.fetch(hashtag, page, region)
results = Invidious::Search::Hashtag.fetch_hashtag(hashtag, page, region)
rescue ex
return error_json(400, ex)
return Errors.error_json(400, ex)
end
JSON.build do |json|

View File

@ -16,9 +16,9 @@ module Invidious::Routes::API::V1::Videos
begin
video = get_video(id, region: region)
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
return JSON.build do |json|
@ -34,7 +34,7 @@ module Invidious::Routes::API::V1::Videos
# Sanity checks
unless validate_video_id(id)
return error_json(400, InvalidVideoID.new(id))
return Errors.error_json(400, InvalidVideoID.new(id))
end
# 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
Helpers.cache_annotation(video_id, annotations)
Invidious::Helpers.cache_annotation(video_id, annotations)
end
else # "youtube"
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{video_id}")
@ -354,9 +354,9 @@ module Invidious::Routes::API::V1::Videos
begin
comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
return comments
@ -371,7 +371,7 @@ module Invidious::Routes::API::V1::Videos
end
if !reddit_thread || !comments
return error_json(404, "No reddit threads found")
return Errors.error_json(404, "No reddit threads found")
end
if format == "json"
@ -404,10 +404,10 @@ module Invidious::Routes::API::V1::Videos
proxy = {"1", "true"}.any? &.== env.params.query["local"]?
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
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
end_time = nil
@ -420,9 +420,9 @@ module Invidious::Routes::API::V1::Videos
begin
video = get_video(video_id, region: region)
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
return JSON.build do |json|
@ -454,9 +454,9 @@ module Invidious::Routes::API::V1::Videos
begin
video = get_video(id)
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
response = JSON.build do |json|
@ -494,14 +494,14 @@ module Invidious::Routes::API::V1::Videos
begin
video = get_video(id)
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
target_transcript = video.captions.select(&.name.== label)
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
target_transcript = target_transcript[0]
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
)
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
return error_json(500, ex)
return Errors.error_json(500, ex)
end
return transcript.to_json

View File

@ -1,4 +1,6 @@
module Invidious::Routes::BeforeAll
extend self
struct CompanionCSP
property companion_urls : String = ""
@ -89,7 +91,7 @@ module Invidious::Routes::BeforeAll
if email = Database::SessionIDs.select_email(sid)
user = Database::Users.select!(email: email)
csrf_token = generate_response(sid, {
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {
":authorize_token",
":playlist_ajax",
":signout",
@ -107,7 +109,7 @@ module Invidious::Routes::BeforeAll
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 = (thin_mode == "true") || preferences.thin_mode
locale = env.params.query["hl"]? || preferences.locale

View File

@ -23,7 +23,7 @@ module Invidious::Routes::Channels
sort_by ||= "last"
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
)
@ -42,8 +42,8 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULF"))
items = get_playlist_videos(playlist, offset: 0)
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULF"))
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
@ -77,8 +77,8 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UUSH"))
items = get_playlist_videos(playlist, offset: 0)
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UUSH"))
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
@ -112,8 +112,8 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
begin
playlist = get_playlist(channel.ucid.sub("UC", "UULV"))
items = get_playlist_videos(playlist, offset: 0)
playlist = Invidious::Playlists::Playlists.get_playlist(channel.ucid.sub("UC", "UULV"))
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: 0)
rescue ex : InfoException
# playlist doesnt exist.
items = [] of PlaylistVideo
@ -146,7 +146,7 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
end
items, next_continuation = fetch_channel_playlists(
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_playlists(
channel.ucid, channel.author, continuation, (sort_by || "last")
)
@ -166,7 +166,7 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
items, next_continuation = fetch_channel_podcasts(
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_podcasts(
channel.ucid, channel.author, continuation
)
@ -186,7 +186,7 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
items, next_continuation = fetch_channel_releases(
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_releases(
channel.ucid, channel.author, continuation
)
@ -206,7 +206,7 @@ module Invidious::Routes::Channels
sort_by = ""
sort_options = [] of String
items, next_continuation = fetch_channel_courses(
items, next_continuation = Invidious::Channels::Playlists.fetch_channel_courses(
channel.ucid, channel.author, continuation
)
@ -247,7 +247,7 @@ module Invidious::Routes::Channels
sort_options = [] of String
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
env.response.status_code = 500
error_message = ex.message
@ -255,7 +255,7 @@ module Invidious::Routes::Channels
env.response.status_code = 404
error_message = ex.message
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
templated "community"
@ -280,14 +280,14 @@ module Invidious::Routes::Channels
if !ucid.nil?
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
# resolve the url to get the author's UCID
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)
post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
ucid = Invidious::Channels::Community.decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s)
post_response = Invidious::Channels::Community.fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
end
post_response = JSON.parse(post_response)
@ -309,7 +309,7 @@ module Invidious::Routes::Channels
return env.redirect "/channel/#{channel.ucid}"
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
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}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
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
selected_tab = env.params.url["tab"]?
@ -380,7 +380,7 @@ module Invidious::Routes::Channels
user = env.params.query["user"]?
if !user
return error_template(404, "This channel does not exist.")
return Errors.error_template(404, "This channel does not exist.")
else
env.redirect "/user/#{user}#{uri_params}"
end
@ -433,13 +433,13 @@ module Invidious::Routes::Channels
continuation = env.params.query["continuation"]?
begin
channel = get_about_info(ucid)
channel = Invidious::Channels::About.get_about_info(ucid)
rescue ex : ChannelRedirect
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
rescue ex : NotFoundException
return error_template(404, ex)
return Errors.error_template(404, ex)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
env.set "search", "channel:#{ucid} "

View File

@ -5,9 +5,9 @@ module Invidious::Routes::Embed
locale = env.get("preferences").as(Preferences).locale
if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
begin
playlist = get_playlist(plid)
playlist = Invidious::Playlists::Playlists.get_playlist(plid)
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?
url = "/playlist?list=#{plid}"
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)
rescue ex : NotFoundException
return error_template(404, ex)
return Errors.error_template(404, ex)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
url = "/embed/#{first_playlist_video.id}?#{env.params.query}"
@ -66,9 +66,9 @@ module Invidious::Routes::Embed
if plid
begin
playlist = get_playlist(plid)
playlist = Invidious::Playlists::Playlists.get_playlist(plid)
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?
url = "/playlist?list=#{plid}"
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)
rescue ex : NotFoundException
return error_template(404, ex)
return Errors.error_template(404, ex)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
url = "/embed/#{first_playlist_video.id}"
@ -101,7 +101,7 @@ module Invidious::Routes::Embed
env.params.query.delete_all("channel")
if !video_id || video_id == "live_stream"
return error_template(500, "Video is unavailable.")
return Errors.error_template(500, "Video is unavailable.")
end
url = "/embed/#{video_id}"
@ -135,9 +135,9 @@ module Invidious::Routes::Embed
begin
video = get_video(id, region: params.region)
rescue ex : NotFoundException
return error_template(404, ex)
return Errors.error_template(404, ex)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
if preferences.annotations_subscribed &&

View File

@ -53,9 +53,9 @@ module Invidious::Routes::Feeds
region ||= preferences.region
begin
trending, plid = fetch_trending(trending_type, region, locale)
trending, plid = Invidious::Feeds::Trending.fetch(trending_type, region, locale)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
templated "feeds/trending"
@ -91,7 +91,7 @@ module Invidious::Routes::Feeds
page = env.params.query["page"]?.try &.to_i?
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
# "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-]+$/)
ucid = env.params.url["ucid"]
else
return error_atom(400, InfoException.new("Invalid channel ucid provided."))
return Errors.error_atom(400, InfoException.new("Invalid channel ucid provided."))
end
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}")
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)
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"]? || "")
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.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 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)
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
return XML.build(indent: " ", encoding: "UTF-8") do |xml|
@ -317,7 +317,7 @@ module Invidious::Routes::Feeds
end
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.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node|

View File

@ -106,7 +106,7 @@ module Invidious::Routes::Images
break
end
Helpers.proxy_file(response, env)
Invidious::Helpers.proxy_file(response, env)
end
rescue ex
end
@ -158,6 +158,6 @@ module Invidious::Routes::Images
return env.response.headers.delete("Transfer-Encoding")
end
return Helpers.proxy_file(response, env)
return Invidious::Helpers.proxy_file(response, env)
end
end

View File

@ -11,7 +11,7 @@ module Invidious::Routes::Login
return env.redirect referer if user
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
email = nil
@ -31,7 +31,7 @@ module Invidious::Routes::Login
referer = get_referer(env, "/feed/subscriptions")
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
# https://stackoverflow.com/a/574698
@ -44,11 +44,11 @@ module Invidious::Routes::Login
case account_type
when "invidious"
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
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
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)
end
else
return error_template(401, "Wrong username or password")
return Errors.error_template(401, "Wrong username or password")
end
# Since this user has already registered, we don't want to overwrite their preferences
@ -75,16 +75,16 @@ module Invidious::Routes::Login
end
else
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
if password.empty?
return error_template(401, "Password cannot be empty")
return Errors.error_template(401, "Password cannot be empty")
end
# See https://security.stackexchange.com/a/39851
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
password = password.byte_slice(0, 55)
@ -102,11 +102,11 @@ module Invidious::Routes::Login
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
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
return error_template(400, InfoException.new("Erroneous CAPTCHA"))
return Errors.error_template(400, InfoException.new("Erroneous CAPTCHA"))
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
else
return templated "user/login"
@ -114,7 +114,7 @@ module Invidious::Routes::Login
end
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 = ANG.language_negotiator.best(language_header, I18n::LOCALES.keys)
@ -126,7 +126,7 @@ module Invidious::Routes::Login
Invidious::Database::SessionIDs.insert(sid, 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)
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"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
Invidious::Database::SessionIDs.delete(sid: sid)

View File

@ -18,7 +18,7 @@ module Invidious::Routes::Notifications
if redirect
return env.redirect referer
else
return error_json(403, "No such user")
return Errors.error_json(403, "No such user")
end
end

View File

@ -12,7 +12,7 @@ module Invidious::Routes::Playlists
user = user.as(User)
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"
end
@ -31,26 +31,26 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
title = env.params.body["title"]?.try &.as(String)
if !title || title.empty?
return error_template(400, "Title cannot be empty.")
return Errors.error_template(400, "Title cannot be empty.")
end
privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "")
if !privacy
return error_template(400, "Invalid privacy setting.")
return Errors.error_template(400, "Invalid privacy setting.")
end
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
playlist = create_playlist(title, privacy, user)
playlist = Invidious::Playlists::Playlists.create_playlist(title, privacy, user)
env.redirect "/playlist?list=#{playlist.id}"
end
@ -67,13 +67,13 @@ module Invidious::Routes::Playlists
playlist_id = env.params.query["list"]
begin
playlist = get_playlist(playlist_id)
playlist = Invidious::Playlists::Playlists.get_playlist(playlist_id)
rescue ex : NotFoundException
return error_template(404, ex)
return Errors.error_template(404, ex)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
subscribe_playlist(user, playlist)
Invidious::Playlists::Playlists.subscribe_playlist(user, playlist)
env.redirect "/playlist?list=#{playlist.id}"
end
@ -92,7 +92,7 @@ module Invidious::Routes::Playlists
plid = env.params.query["list"]?
if !plid || plid.empty?
return error_template(400, "A playlist ID is required")
return Errors.error_template(400, "A playlist ID is required")
end
playlist = Invidious::Database::Playlists.select(id: plid)
@ -100,7 +100,7 @@ module Invidious::Routes::Playlists
return env.redirect referer
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"
end
@ -122,9 +122,9 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
playlist = Invidious::Database::Playlists.select(id: plid)
@ -163,12 +163,12 @@ module Invidious::Routes::Playlists
end
begin
items = get_playlist_videos(playlist, offset: (page - 1) * 100)
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: (page - 1) * 100)
rescue ex
items = [] of PlaylistVideo
end
csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY)
csrf_token = Invidious::Helpers::Tokens.generate_response(sid, {":edit_playlist"}, HMAC_KEY)
# Pagination
page_nav_html = Frontend::Pagination.nav_numeric(locale,
@ -197,9 +197,9 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
return error_template(400, ex)
return Errors.error_template(400, ex)
end
playlist = Invidious::Database::Playlists.select(id: plid)
@ -286,7 +286,7 @@ module Invidious::Routes::Playlists
if redirect
return env.redirect referer
else
return error_json(403, "No such user")
return Errors.error_json(403, "No such user")
end
end
@ -295,26 +295,26 @@ module Invidious::Routes::Playlists
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
return error_template(400, ex)
return Errors.error_template(400, ex)
else
return error_json(400, ex)
return Errors.error_json(400, ex)
end
end
begin
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
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
if redirect
return error_template(400, ex)
return Errors.error_template(400, ex)
else
return error_json(400, ex)
return Errors.error_json(400, ex)
end
end
@ -322,9 +322,9 @@ module Invidious::Routes::Playlists
when "add_video"
if playlist.index.size >= CONFIG.playlist_length_limit
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
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
@ -333,12 +333,12 @@ module Invidious::Routes::Playlists
begin
video = get_video(video_id)
rescue ex : NotFoundException
return error_json(404, ex)
return Errors.error_json(404, ex)
rescue ex
if redirect
return error_template(500, ex)
return Errors.error_template(500, ex)
else
return error_json(500, ex)
return Errors.error_json(500, ex)
end
end
@ -359,7 +359,7 @@ module Invidious::Routes::Playlists
when "remove_video"
index = env.params.query["set_video_id"].to_i64?
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
Invidious::Database::PlaylistVideos.delete(index, playlist_id)
@ -367,9 +367,9 @@ module Invidious::Routes::Playlists
when "move_video_before"
# TODO: Playlist stub
when nil
return error_json(400, "Missing action")
return Errors.error_json(400, "Missing action")
else
return error_json(400, "Unsupported action #{action}")
return Errors.error_json(400, "Unsupported action #{action}")
end
if redirect
@ -399,11 +399,11 @@ module Invidious::Routes::Playlists
end
begin
playlist = get_playlist(plid)
playlist = Invidious::Playlists::Playlists.get_playlist(plid)
rescue ex : NotFoundException
return error_template(404, ex)
return Errors.error_template(404, ex)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
if playlist.is_a? InvidiousPlaylist
@ -419,17 +419,17 @@ module Invidious::Routes::Playlists
end
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
begin
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
items = get_playlist_videos(playlist, offset: (page - 1) * 200)
items = Invidious::Playlists::Playlists.get_playlist_videos(playlist, offset: (page - 1) * 200)
end
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
if playlist.author == user.try &.email
@ -460,7 +460,7 @@ module Invidious::Routes::Playlists
begin
mix = fetch_mix(rdid, continuation, locale: locale)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
templated "mix"

View File

@ -329,7 +329,7 @@ module Invidious::Routes::PreferencesRoute
if !success
haltf(env, status_code: 415,
response: error_template(415, "Invalid subscription file uploaded")
response: Errors.error_template(415, "Invalid subscription file uploaded")
)
end
when "import_youtube_pl"
@ -338,7 +338,7 @@ module Invidious::Routes::PreferencesRoute
if !success
haltf(env, status_code: 415,
response: error_template(415, "Invalid playlist file uploaded")
response: Errors.error_template(415, "Invalid playlist file uploaded")
)
end
when "import_youtube_wh"
@ -347,7 +347,7 @@ module Invidious::Routes::PreferencesRoute
if !success
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
when "import_freetube"
@ -359,7 +359,7 @@ module Invidious::Routes::PreferencesRoute
if !success
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
else nil # Ignore

View File

@ -71,9 +71,9 @@ module Invidious::Routes::Search
items = query.process
end
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
return error_template(500, ex)
return Errors.error_template(500, ex)
end
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
@ -100,7 +100,7 @@ module Invidious::Routes::Search
hashtag = env.params.url["hashtag"]?
if hashtag.nil? || hashtag.empty?
return error_template(400, "Invalid request")
return Errors.error_template(400, "Invalid request")
end
page = env.params.query["page"]?
@ -112,9 +112,9 @@ module Invidious::Routes::Search
end
begin
items = Invidious::Hashtag.fetch(hashtag, page)
items = Invidious::Search::Hashtag.fetch_hashtag(hashtag, page)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
# Pagination

View File

@ -14,7 +14,7 @@ module Invidious::Routes::Subscriptions
if redirect
return env.redirect referer
else
return error_json(403, "No such user")
return Errors.error_json(403, "No such user")
end
end
@ -23,12 +23,12 @@ module Invidious::Routes::Subscriptions
token = env.params.body["csrf_token"]?
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
return error_template(400, ex)
return Errors.error_template(400, ex)
else
return error_json(400, ex)
return Errors.error_json(400, ex)
end
end
@ -38,13 +38,13 @@ module Invidious::Routes::Subscriptions
case action = env.params.query["action"]?
when "create_subscription_to_channel"
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)
end
when "remove_subscriptions"
Invidious::Database::Users.unsubscribe_channel(user, channel_id)
else
return error_json(400, "Unsupported action #{action}")
return Errors.error_json(400, "Unsupported action #{action}")
end
if redirect

View File

@ -24,7 +24,7 @@ module Invidious::Routes::VideoPlayback
# Sanity check, to avoid being used as an open proxy
if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/)
return error_template(400, "Invalid \"host\" parameter.")
return Errors.error_template(400, "Invalid \"host\" parameter.")
end
host = "https://#{host}"
@ -83,7 +83,7 @@ module Invidious::Routes::VideoPlayback
# Remove the Range header added previously.
headers.delete("Range") if range_header.nil?
playback_statistics = Helpers.get_playback_statistic
playback_statistics = Invidious::Helpers.get_playback_statistic
playback_statistics["totalRequests"] += 1
if response.status_code >= 400
@ -95,7 +95,7 @@ module Invidious::Routes::VideoPlayback
if url.includes? "&file=seg.ts"
if CONFIG.disabled?("livestreams")
return error_template(403, "Administrator has disabled this endpoint.")
return Errors.error_template(403, "Administrator has disabled this endpoint.")
end
begin
@ -120,7 +120,7 @@ module Invidious::Routes::VideoPlayback
else
if query_params["title"]? && CONFIG.disabled?("downloads") ||
CONFIG.disabled?("dash")
return error_template(403, "Administrator has disabled this endpoint.")
return Errors.error_template(403, "Administrator has disabled this endpoint.")
end
content_length = nil
@ -195,7 +195,7 @@ module Invidious::Routes::VideoPlayback
end
end
Helpers.proxy_file(resp, env)
Invidious::Helpers.proxy_file(resp, env)
end
rescue ex
if ex.message != "Error reading socket: Connection reset by peer"
@ -269,11 +269,11 @@ module Invidious::Routes::VideoPlayback
# Sanity checks
unless id && validate_video_id(id)
return error_template(400, InvalidVideoID.new(id))
return Errors.error_template(400, InvalidVideoID.new(id))
end
if !itag.nil? && (itag <= 0 || itag >= 1000)
return error_template(400, "Invalid itag")
return Errors.error_template(400, "Invalid itag")
end
region = env.params.query["region"]?
@ -282,15 +282,15 @@ module Invidious::Routes::VideoPlayback
title = env.params.query["title"]?
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
begin
video = get_video(id, region: region)
rescue ex : NotFoundException
return error_template(404, ex)
return Errors.error_template(404, ex)
rescue ex
return error_template(500, ex)
return Errors.error_template(500, ex)
end
if itag.nil?

View File

@ -15,11 +15,11 @@ module Invidious::Routes::Watch
id = env.params.query["v"]
if env.params.query["v"].empty?
return error_template(400, "Invalid parameters.")
return Errors.error_template(400, "Invalid parameters.")
end
unless validate_video_id(id)
return error_template(400, InvalidVideoID.new(id))
return Errors.error_template(400, InvalidVideoID.new(id))
end
else
return env.redirect "/"
@ -48,10 +48,10 @@ module Invidious::Routes::Watch
video = get_video(id, region: params.region)
rescue ex : NotFoundException
LOGGER.error("get_video not found: #{id} : #{ex.message}")
return error_template(404, ex)
return Errors.error_template(404, ex)
rescue ex
LOGGER.error("get_video: #{id} : #{ex.message}")
return error_template(500, ex)
return Errors.error_template(500, ex)
end
if preferences.annotations_subscribed &&
@ -220,7 +220,7 @@ module Invidious::Routes::Watch
if redirect
return env.redirect referer
else
return error_json(403, "No such user")
return Errors.error_json(403, "No such user")
end
end
@ -235,12 +235,12 @@ module Invidious::Routes::Watch
end
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
Invidious::Helpers::Tokens.validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
if redirect
return error_template(400, ex)
return Errors.error_template(400, ex)
else
return error_json(400, ex)
return Errors.error_json(400, ex)
end
end
@ -250,7 +250,7 @@ module Invidious::Routes::Watch
when "mark_unwatched"
Invidious::Database::Users.mark_unwatched(user, id)
else
return error_json(400, "Unsupported action #{action}")
return Errors.error_json(400, "Unsupported action #{action}")
end
if redirect
@ -264,10 +264,10 @@ module Invidious::Routes::Watch
def self.clip(env)
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}")
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 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}"
else
return error_template(404, "The requested clip doesn't exist")
return Errors.error_template(404, "The requested clip doesn't exist")
end
end
def self.download(env)
if CONFIG.disabled?("downloads")
return error_template(403, "Administrator has disabled this endpoint.")
return Errors.error_template(403, "Administrator has disabled this endpoint.")
end
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
title = env.params.body["title"]? || ""
@ -295,7 +295,7 @@ module Invidious::Routes::Watch
selection = env.params.body["download_widget"]?
if title.empty? || video_id.empty? || selection.nil?
return error_template(400, "Missing form data")
return Errors.error_template(400, "Missing form data")
end
download_widget = JSON.parse(selection)
@ -326,7 +326,7 @@ module Invidious::Routes::Watch
return Invidious::Routes::VideoPlayback.latest_version(env)
else
return error_template(400, "Invalid label or itag")
return Errors.error_template(400, "Invalid label or itag")
end
end
end

View File

@ -1,3 +1,6 @@
module Invidious::Search::CToken
extend self
def produce_channel_search_continuation(ucid, query, page)
if page <= 1
idx = 0_i64
@ -30,3 +33,4 @@ def produce_channel_search_continuation(ucid, query, page)
return continuation
end
end

View File

@ -1,7 +1,7 @@
module Invidious::Hashtag
module Invidious::Search::Hashtag
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
ctoken = generate_continuation(hashtag, cursor)

View File

@ -1,5 +1,4 @@
module Invidious::Search
module Processors
module Invidious::Search::Processors
extend self
# Regular search (`/search` endpoint)
@ -21,14 +20,14 @@ module Invidious::Search
if response.status_code == 404
response = YT_POOL.client &.get("/user/#{query.channel}")
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?)
raise ChannelSearchException.new(query.channel) if !ucid
else
ucid = query.channel
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)
items, _ = extract_items(response_json, "", ucid)
@ -53,4 +52,3 @@ module Invidious::Search
)
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 {
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

View File

@ -1,3 +1,6 @@
module Invidious::User::Converters
extend self
def convert_theme(theme)
case theme
when "true"
@ -10,3 +13,4 @@ def convert_theme(theme)
theme
end
end
end

View File

@ -15,7 +15,7 @@ struct Invidious::User
playlists.each do |playlist|
json.object do
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 "videos" 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."
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)
# Add each video to the playlist from the body content
@ -117,7 +117,7 @@ struct Invidious::User
next if !description
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)
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
# 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 {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),

View File

@ -106,7 +106,7 @@
</div>
<details>
<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>
</div>
<% else %>