summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/invidious.cr1815
-rw-r--r--src/invidious/channels.cr101
-rw-r--r--src/invidious/comments.cr16
-rw-r--r--src/invidious/helpers/errors.cr103
-rw-r--r--src/invidious/helpers/helpers.cr36
-rw-r--r--src/invidious/helpers/logger.cr46
-rw-r--r--src/invidious/helpers/proxy.cr6
-rw-r--r--src/invidious/helpers/tokens.cr12
-rw-r--r--src/invidious/helpers/utils.cr12
-rw-r--r--src/invidious/jobs/bypass_captcha_job.cr18
-rw-r--r--src/invidious/jobs/refresh_channels_job.cr35
-rw-r--r--src/invidious/jobs/refresh_feeds_job.cr20
-rw-r--r--src/invidious/jobs/subscribe_to_feeds_job.cr16
-rw-r--r--src/invidious/mixes.cr2
-rw-r--r--src/invidious/playlists.cr20
-rw-r--r--src/invidious/routes/base_route.cr2
-rw-r--r--src/invidious/routes/embed/index.cr4
-rw-r--r--src/invidious/routes/embed/show.cr11
-rw-r--r--src/invidious/routes/home.cr12
-rw-r--r--src/invidious/routes/login.cr508
-rw-r--r--src/invidious/routes/playlists.cr472
-rw-r--r--src/invidious/routes/search.cr59
-rw-r--r--src/invidious/routes/user_preferences.cr259
-rw-r--r--src/invidious/routes/watch.cr18
-rw-r--r--src/invidious/routing.cr11
-rw-r--r--src/invidious/users.cr14
-rw-r--r--src/invidious/videos.cr27
-rw-r--r--src/invidious/views/components/player.ecr2
-rw-r--r--src/invidious/views/components/player_sources.ecr1
-rw-r--r--src/invidious/views/data_control.ecr2
-rw-r--r--src/invidious/views/embed.ecr3
-rw-r--r--src/invidious/views/message.ecr12
-rw-r--r--src/invidious/views/playlists.ecr2
-rw-r--r--src/invidious/views/preferences.ecr35
-rw-r--r--src/invidious/views/template.ecr17
-rw-r--r--src/invidious/views/watch.ecr4
36 files changed, 1865 insertions, 1868 deletions
diff --git a/src/invidious.cr b/src/invidious.cr
index 284b238c..8d4c2e58 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -64,7 +64,7 @@ HTTP_CHUNK_SIZE = 10485760 # ~10MB
CURRENT_BRANCH = {{ "#{`git branch | sed -n '/* /s///p'`.strip}" }}
CURRENT_COMMIT = {{ "#{`git rev-list HEAD --max-count=1 --abbrev-commit`.strip}" }}
-CURRENT_VERSION = {{ "#{`git describe --tags --abbrev=0`.strip}" }}
+CURRENT_VERSION = {{ "#{`git log -1 --format=%ci | awk '{print $1}' | sed s/-/./g`.strip}" }}
# This is used to determine the `?v=` on the end of file URLs (for cache busting). We
# only need to expire modified assets, so we can use this to find the last commit that changes
@@ -104,10 +104,11 @@ LOCALES = {
"zh-TW" => load_locale("zh-TW"),
}
-YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 0.1)
+YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0)
config = CONFIG
-logger = Invidious::LogHandler.new
+output = STDOUT
+loglvl = LogLevel::Debug
Kemal.config.extra_options do |parser|
parser.banner = "Usage: invidious [arguments]"
@@ -127,11 +128,14 @@ Kemal.config.extra_options do |parser|
exit
end
end
- parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output|
- FileUtils.mkdir_p(File.dirname(output))
- logger = Invidious::LogHandler.new(File.open(output, mode: "a"))
+ parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output_arg|
+ FileUtils.mkdir_p(File.dirname(output_arg))
+ output = File.open(output_arg, mode: "a")
end
- parser.on("-v", "--version", "Print version") do |output|
+ parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{loglvl})") do |loglvl_arg|
+ loglvl = LogLevel.parse(loglvl_arg)
+ end
+ parser.on("-v", "--version", "Print version") do
puts SOFTWARE.to_pretty_json
exit
end
@@ -139,6 +143,8 @@ end
Kemal::CLI.new ARGV
+logger = Invidious::LogHandler.new(output, loglvl)
+
# Check table integrity
if CONFIG.check_tables
check_enum(PG_DB, logger, "privacy", PlaylistPrivacy)
@@ -162,13 +168,16 @@ end
Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB, logger, config)
Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB, logger, config)
Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, logger, config, HMAC_KEY)
-Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new
if config.statistics_enabled
Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, config, SOFTWARE)
end
+if config.popular_enabled
+ Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
+end
+
if config.captcha_key
Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new(logger, config)
end
@@ -197,6 +206,7 @@ before_all do |env|
extra_media_csp = ""
if CONFIG.disabled?("local") || !preferences.local
extra_media_csp += " https://*.googlevideo.com:443"
+ extra_media_csp += " https://*.youtube.com:443"
end
# TODO: Remove style-src's 'unsafe-inline', requires to remove all inline styles (<style> [..] </style>, style=" [..] ")
env.response.headers["Content-Security-Policy"] = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; manifest-src 'self'; media-src 'self' blob:#{extra_media_csp}"
@@ -249,7 +259,7 @@ before_all do |env|
headers["Cookie"] = env.request.headers["Cookie"]
begin
- user, sid = get_user(sid, headers, PG_DB, false)
+ user, sid = get_user(sid, headers, PG_DB, logger, false)
csrf_token = generate_response(sid, {
":authorize_token",
":playlist_ajax",
@@ -299,1394 +309,30 @@ Invidious::Routing.get "/licenses", Invidious::Routes::Licenses
Invidious::Routing.get "/watch", Invidious::Routes::Watch
Invidious::Routing.get "/embed/", Invidious::Routes::Embed::Index
Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed::Show
-
-# Playlists
-
-get "/feed/playlists" do |env|
- env.redirect "/view_all_playlists"
-end
-
-get "/view_all_playlists" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- user = user.as(User)
-
- items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist)
- items_created.map! do |item|
- item.author = ""
- item
- end
-
- items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist)
- items_saved.map! do |item|
- item.author = ""
- item
- end
-
- templated "view_all_playlists"
-end
-
-get "/create_playlist" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- user = user.as(User)
- sid = sid.as(String)
- csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB)
-
- templated "create_playlist"
-end
-
-post "/create_playlist" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- user = user.as(User)
- sid = sid.as(String)
- token = env.params.body["csrf_token"]?
-
- begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
- rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
- end
-
- title = env.params.body["title"]?.try &.as(String)
- if !title || title.empty?
- error_message = "Title cannot be empty."
- next templated "error"
- end
-
- privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "")
- if !privacy
- error_message = "Invalid privacy setting."
- next templated "error"
- end
-
- if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100
- error_message = "User cannot have more than 100 playlists."
- next templated "error"
- end
-
- playlist = create_playlist(PG_DB, title, privacy, user)
-
- env.redirect "/playlist?list=#{playlist.id}"
-end
-
-get "/subscribe_playlist" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- user = user.as(User)
-
- playlist_id = env.params.query["list"]
- playlist = get_playlist(PG_DB, playlist_id, locale)
- subscribe_playlist(PG_DB, user, playlist)
-
- env.redirect "/playlist?list=#{playlist.id}"
-end
-
-get "/delete_playlist" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- user = user.as(User)
- sid = sid.as(String)
-
- plid = env.params.query["list"]?
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
- if !playlist || playlist.author != user.email
- next env.redirect referer
- end
-
- csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB)
-
- templated "delete_playlist"
-end
-
-post "/delete_playlist" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- plid = env.params.query["list"]?
- if !plid
- next env.redirect referer
- end
-
- user = user.as(User)
- sid = sid.as(String)
- token = env.params.body["csrf_token"]?
-
- begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
- rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
- end
-
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
- if !playlist || playlist.author != user.email
- next env.redirect referer
- end
-
- PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid)
- PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid)
-
- env.redirect "/view_all_playlists"
-end
-
-get "/edit_playlist" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- user = user.as(User)
- sid = sid.as(String)
-
- plid = env.params.query["list"]?
- if !plid || !plid.starts_with?("IV")
- next env.redirect referer
- end
-
- page = env.params.query["page"]?.try &.to_i?
- page ||= 1
-
- begin
- playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
- if !playlist || playlist.author != user.email
- next env.redirect referer
- end
- rescue ex
- next env.redirect referer
- end
-
- begin
- videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale)
- rescue ex
- videos = [] of PlaylistVideo
- end
-
- csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB)
-
- templated "edit_playlist"
-end
-
-post "/edit_playlist" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- plid = env.params.query["list"]?
- if !plid
- next env.redirect referer
- end
-
- user = user.as(User)
- sid = sid.as(String)
- token = env.params.body["csrf_token"]?
-
- begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
- rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
- end
-
- playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
- if !playlist || playlist.author != user.email
- next env.redirect referer
- end
-
- title = env.params.body["title"]?.try &.delete("<>") || ""
- privacy = PlaylistPrivacy.parse(env.params.body["privacy"]? || "Public")
- description = env.params.body["description"]?.try &.delete("\r") || ""
-
- if title != playlist.title ||
- privacy != playlist.privacy ||
- description != playlist.description
- updated = Time.utc
- else
- updated = playlist.updated
- end
-
- PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid)
-
- env.redirect "/playlist?list=#{plid}"
-end
-
-get "/add_playlist_items" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect "/"
- end
-
- user = user.as(User)
- sid = sid.as(String)
-
- plid = env.params.query["list"]?
- if !plid || !plid.starts_with?("IV")
- next env.redirect referer
- end
-
- page = env.params.query["page"]?.try &.to_i?
- page ||= 1
-
- begin
- playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
- if !playlist || playlist.author != user.email
- next env.redirect referer
- end
- rescue ex
- next env.redirect referer
- end
-
- query = env.params.query["q"]?
- if query
- begin
- search_query, count, items = process_search_query(query, page, user, region: nil)
- videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) }
- rescue ex
- videos = [] of SearchVideo
- count = 0
- end
- else
- videos = [] of SearchVideo
- count = 0
- end
-
- env.set "add_playlist_items", plid
- templated "add_playlist_items"
-end
-
-post "/playlist_ajax" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env, "/")
-
- redirect = env.params.query["redirect"]?
- redirect ||= "true"
- redirect = redirect == "true"
-
- if !user
- if redirect
- next env.redirect referer
- else
- error_message = {"error" => "No such user"}.to_json
- env.response.status_code = 403
- next error_message
- end
- end
-
- user = user.as(User)
- sid = sid.as(String)
- token = env.params.body["csrf_token"]?
-
- begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
- rescue ex
- if redirect
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
- else
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 400
- next error_message
- end
- end
-
- if env.params.query["action_create_playlist"]?
- action = "action_create_playlist"
- elsif env.params.query["action_delete_playlist"]?
- action = "action_delete_playlist"
- elsif env.params.query["action_edit_playlist"]?
- action = "action_edit_playlist"
- elsif env.params.query["action_add_video"]?
- action = "action_add_video"
- video_id = env.params.query["video_id"]
- elsif env.params.query["action_remove_video"]?
- action = "action_remove_video"
- elsif env.params.query["action_move_video_before"]?
- action = "action_move_video_before"
- else
- next env.redirect referer
- end
-
- begin
- playlist_id = env.params.query["playlist_id"]
- playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist)
- raise "Invalid user" if playlist.author != user.email
- rescue ex
- if redirect
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
- else
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 400
- next error_message
- end
- end
-
- if !user.password
- # TODO: Playlist stub, sync with YouTube for Google accounts
- # playlist_ajax(playlist_id, action, env.request.headers)
- end
- email = user.email
-
- case action
- when "action_edit_playlist"
- # TODO: Playlist stub
- when "action_add_video"
- if playlist.index.size >= 500
- env.response.status_code = 400
- if redirect
- error_message = "Playlist cannot have more than 500 videos"
- next templated "error"
- else
- error_message = {"error" => "Playlist cannot have more than 500 videos"}.to_json
- next error_message
- end
- end
-
- video_id = env.params.query["video_id"]
-
- begin
- video = get_video(video_id, PG_DB)
- rescue ex
- env.response.status_code = 500
- if redirect
- error_message = ex.message
- next templated "error"
- else
- error_message = {"error" => ex.message}.to_json
- next error_message
- end
- end
-
- playlist_video = PlaylistVideo.new({
- title: video.title,
- id: video.id,
- author: video.author,
- ucid: video.ucid,
- length_seconds: video.length_seconds,
- published: video.published,
- plid: playlist_id,
- live_now: video.live_now,
- index: Random::Secure.rand(0_i64..Int64::MAX),
- })
-
- video_array = playlist_video.to_a
- args = arg_array(video_array)
-
- PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array)
- PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id)
- when "action_remove_video"
- index = env.params.query["set_video_id"]
- PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index)
- PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id)
- when "action_move_video_before"
- # TODO: Playlist stub
- else
- error_message = {"error" => "Unsupported action #{action}"}.to_json
- env.response.status_code = 400
- next error_message
- end
-
- if redirect
- env.redirect referer
- else
- env.response.content_type = "application/json"
- "{}"
- end
-end
-
-get "/playlist" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get?("user").try &.as(User)
- referer = get_referer(env)
-
- plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
- if !plid
- next env.redirect "/"
- end
-
- page = env.params.query["page"]?.try &.to_i?
- page ||= 1
-
- if plid.starts_with? "RD"
- next env.redirect "/mix?list=#{plid}"
- end
-
- begin
- playlist = get_playlist(PG_DB, plid, locale)
- rescue ex
- error_message = ex.message
- env.response.status_code = 500
- next templated "error"
- end
-
- if playlist.privacy == PlaylistPrivacy::Private && playlist.author != user.try &.email
- error_message = "This playlist is private."
- env.response.status_code = 403
- next templated "error"
- end
-
- begin
- videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale)
- rescue ex
- videos = [] of PlaylistVideo
- end
-
- if playlist.author == user.try &.email
- env.set "remove_playlist_items", plid
- end
-
- templated "playlist"
-end
-
-get "/mix" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- rdid = env.params.query["list"]?
- if !rdid
- next env.redirect "/"
- end
-
- continuation = env.params.query["continuation"]?
- continuation ||= rdid.lchop("RD")
-
- begin
- mix = fetch_mix(rdid, continuation, locale: locale)
- rescue ex
- error_message = ex.message
- env.response.status_code = 500
- next templated "error"
- end
-
- templated "mix"
-end
-
-# Search
-
-get "/opensearch.xml" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
- env.response.content_type = "application/opensearchdescription+xml"
-
- XML.build(indent: " ", encoding: "UTF-8") do |xml|
- xml.element("OpenSearchDescription", xmlns: "http://a9.com/-/spec/opensearch/1.1/") do
- xml.element("ShortName") { xml.text "Invidious" }
- xml.element("LongName") { xml.text "Invidious Search" }
- xml.element("Description") { xml.text "Search for videos, channels, and playlists on Invidious" }
- xml.element("InputEncoding") { xml.text "UTF-8" }
- xml.element("Image", width: 48, height: 48, type: "image/x-icon") { xml.text "#{HOST_URL}/favicon.ico" }
- xml.element("Url", type: "text/html", method: "get", template: "#{HOST_URL}/search?q={searchTerms}")
- end
- end
-end
-
-get "/results" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- query = env.params.query["search_query"]?
- query ||= env.params.query["q"]?
- query ||= ""
-
- page = env.params.query["page"]?.try &.to_i?
- page ||= 1
-
- if query
- env.redirect "/search?q=#{URI.encode_www_form(query)}&page=#{page}"
- else
- env.redirect "/"
- end
-end
-
-get "/search" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
- region = env.params.query["region"]?
-
- query = env.params.query["search_query"]?
- query ||= env.params.query["q"]?
- query ||= ""
-
- if query.empty?
- next env.redirect "/"
- end
-
- page = env.params.query["page"]?.try &.to_i?
- page ||= 1
-
- user = env.get? "user"
-
- begin
- search_query, count, videos = process_search_query(query, page, user, region: nil)
- rescue ex
- error_message = ex.message
- env.response.status_code = 500
- next templated "error"
- end
-
- env.set "search", query
- templated "search"
-end
+Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Playlists, :index
+Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new
+Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create
+Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe
+Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page
+Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete
+Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit
+Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update
+Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page
+Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
+Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
+Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
+Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
+Invidious::Routing.get "/results", Invidious::Routes::Search, :results
+Invidious::Routing.get "/search", Invidious::Routes::Search, :search
+Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
+Invidious::Routing.post "/login", Invidious::Routes::Login, :login
+Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
+Invidious::Routing.get "/preferences", Invidious::Routes::UserPreferences, :show
+Invidious::Routing.post "/preferences", Invidious::Routes::UserPreferences, :update
+Invidious::Routing.get "/toggle_theme", Invidious::Routes::UserPreferences, :toggle_theme
# Users
-get "/login" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- if user
- next env.redirect "/feed/subscriptions"
- end
-
- if !config.login_enabled
- error_message = "Login has been disabled by administrator."
- env.response.status_code = 400
- next templated "error"
- end
-
- referer = get_referer(env, "/feed/subscriptions")
-
- email = nil
- password = nil
- captcha = nil
-
- account_type = env.params.query["type"]?
- account_type ||= "invidious"
-
- captcha_type = env.params.query["captcha"]?
- captcha_type ||= "image"
-
- tfa = env.params.query["tfa"]?
- prompt = nil
-
- templated "login"
-end
-
-post "/login" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- referer = get_referer(env, "/feed/subscriptions")
-
- if !config.login_enabled
- error_message = "Login has been disabled by administrator."
- env.response.status_code = 403
- next templated "error"
- end
-
- # https://stackoverflow.com/a/574698
- email = env.params.body["email"]?.try &.downcase.byte_slice(0, 254)
- password = env.params.body["password"]?
-
- account_type = env.params.query["type"]?
- account_type ||= "invidious"
-
- case account_type
- when "google"
- tfa_code = env.params.body["tfa"]?.try &.lchop("G-")
- traceback = IO::Memory.new
-
- # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82
- begin
- client = QUIC::Client.new(LOGIN_URL)
- headers = HTTP::Headers.new
-
- login_page = client.get("/ServiceLogin")
- headers = login_page.cookies.add_request_headers(headers)
-
- lookup_req = {
- email, nil, [] of String, nil, "US", nil, nil, 2, false, true,
- {nil, nil,
- {2, 1, nil, 1,
- "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn",
- nil, [] of String, 4},
- 1,
- {nil, nil, [] of String},
- nil, nil, nil, true,
- },
- email,
- }.to_json
-
- traceback << "Getting lookup..."
-
- headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8"
- headers["Google-Accounts-XSRF"] = "1"
-
- response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req))
- lookup_results = JSON.parse(response.body[5..-1])
-
- traceback << "done, returned #{response.status_code}.<br/>"
-
- user_hash = lookup_results[0][2]
-
- if token = env.params.body["token"]?
- answer = env.params.body["answer"]?
- captcha = {token, answer}
- else
- captcha = nil
- end
-
- challenge_req = {
- user_hash, nil, 1, nil,
- {1, nil, nil, nil,
- {password, captcha, true},
- },
- {nil, nil,
- {2, 1, nil, 1,
- "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn",
- nil, [] of String, 4},
- 1,
- {nil, nil, [] of String},
- nil, nil, nil, true,
- },
- }.to_json
-
- traceback << "Getting challenge..."
-
- response = client.post("/_/signin/sl/challenge", headers, login_req(challenge_req))
- headers = response.cookies.add_request_headers(headers)
- challenge_results = JSON.parse(response.body[5..-1])
-
- traceback << "done, returned #{response.status_code}.<br/>"
-
- headers["Cookie"] = URI.decode_www_form(headers["Cookie"])
-
- if challenge_results[0][3]?.try &.== 7
- error_message = translate(locale, "Account has temporarily been disabled")
- env.response.status_code = 423
- next templated "error"
- end
-
- if token = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a?.try &.[-1].as_s
- account_type = "google"
- captcha_type = "image"
- prompt = nil
- tfa = tfa_code
- captcha = {tokens: [token], question: ""}
-
- next templated "login"
- end
-
- if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED"
- error_message = translate(locale, "Incorrect password")
- env.response.status_code = 401
- next templated "error"
- end
-
- prompt_type = challenge_results[0][-1]?.try &.[0].as_a?.try &.[0][2]?
- if {"TWO_STEP_VERIFICATION", "LOGIN_CHALLENGE"}.includes? prompt_type
- traceback << "Handling prompt #{prompt_type}.<br/>"
- case prompt_type
- when "TWO_STEP_VERIFICATION"
- prompt_type = 2
- else # "LOGIN_CHALLENGE"
- prompt_type = 4
- end
-
- # Prefer Authenticator app and SMS over unsupported protocols
- if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2
- tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0]
-
- traceback << "Selecting challenge #{tfa[8]}..."
- select_challenge = {prompt_type, nil, nil, nil, {tfa[8]}}.to_json
-
- tl = challenge_results[1][2]
-
- tfa = client.post("/_/signin/selectchallenge?TL=#{tl}", headers, login_req(select_challenge)).body
- tfa = tfa[5..-1]
- tfa = JSON.parse(tfa)[0][-1]
-
- traceback << "done.<br/>"
- else
- traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.<br/>"
- tfa = challenge_results[0][-1][0][0]
- end
-
- if tfa[5] == "QUOTA_EXCEEDED"
- error_message = translate(locale, "Quota exceeded, try again in a few hours")
- env.response.status_code = 423
- next templated "error"
- end
-
- if !tfa_code
- account_type = "google"
- captcha_type = "image"
-
- case tfa[8]
- when 6, 9
- prompt = "Google verification code"
- when 12
- prompt = "Login verification, recovery email: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}"
- when 15
- prompt = "Login verification, security question: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}"
- else
- prompt = "Google verification code"
- end
-
- tfa = nil
- captcha = nil
- next templated "login"
- end
-
- tl = challenge_results[1][2]
-
- request_type = tfa[8]
- case request_type
- when 6 # Authenticator app
- tfa_req = {
- user_hash, nil, 2, nil,
- {6, nil, nil, nil, nil,
- {tfa_code, false},
- },
- }.to_json
- when 9 # Voice or text message
- tfa_req = {
- user_hash, nil, 2, nil,
- {9, nil, nil, nil, nil, nil, nil, nil,
- {nil, tfa_code, false, 2},
- },
- }.to_json
- when 12 # Recovery email
- tfa_req = {
- user_hash, nil, 4, nil,
- {12, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
- {tfa_code},
- },
- }.to_json
- when 15 # Security question
- tfa_req = {
- user_hash, nil, 5, nil,
- {15, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
- {tfa_code},
- },
- }.to_json
- else
- error_message = translate(locale, "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.")
- env.response.status_code = 500
- next templated "error"
- end
-
- traceback << "Submitting challenge..."
-
- response = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(tfa_req))
- headers = response.cookies.add_request_headers(headers)
- challenge_results = JSON.parse(response.body[5..-1])
-
- if (challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED") ||
- (challenge_results[0][-1]?.try &.[5] == "INVALID_INPUT")
- error_message = translate(locale, "Invalid TFA code")
- env.response.status_code = 401
- next templated "error"
- end
-
- traceback << "done.<br/>"
- end
-
- traceback << "Logging in..."
-
- location = URI.parse(challenge_results[0][-1][2].to_s)
- cookies = HTTP::Cookies.from_headers(headers)
-
- headers.delete("Content-Type")
- headers.delete("Google-Accounts-XSRF")
-
- loop do
- if !location || location.path == "/ManageAccount"
- break
- end
-
- # Occasionally there will be a second page after login confirming
- # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle.
-
- if location.path.starts_with? "/b/0/SmsAuthInterstitial"
- traceback << "Unhandled dialog /b/0/SmsAuthInterstitial."
- end
-
- login = client.get(location.full_path, headers)
-
- headers = login.cookies.add_request_headers(headers)
- location = login.headers["Location"]?.try { |u| URI.parse(u) }
- end
-
- cookies = HTTP::Cookies.from_headers(headers)
- sid = cookies["SID"]?.try &.value
- if !sid
- raise "Couldn't get SID."
- end
-
- user, sid = get_user(sid, headers, PG_DB)
-
- # We are now logged in
- traceback << "done.<br/>"
-
- host = URI.parse(env.request.headers["Host"]).host
-
- if Kemal.config.ssl || config.https_only
- secure = true
- else
- secure = false
- end
-
- cookies.each do |cookie|
- if Kemal.config.ssl || config.https_only
- cookie.secure = secure
- else
- cookie.secure = secure
- end
-
- if cookie.extension
- cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host)
- cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "")
- end
- env.response.cookies << cookie
- end
-
- if env.request.cookies["PREFS"]?
- preferences = env.get("preferences").as(Preferences)
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
-
- cookie = env.request.cookies["PREFS"]
- cookie.expires = Time.utc(1990, 1, 1)
- env.response.cookies << cookie
- end
-
- env.redirect referer
- rescue ex
- traceback.rewind
- # error_message = translate(locale, "Login failed. This may be because two-factor authentication is not turned on for your account.")
- error_message = %(#{ex.message}<br/>Traceback:<br/><div style="padding-left:2em" id="traceback">#{traceback.gets_to_end}</div>)
- env.response.status_code = 500
- next templated "error"
- end
- when "invidious"
- if !email
- error_message = translate(locale, "User ID is a required field")
- env.response.status_code = 401
- next templated "error"
- end
-
- if !password
- error_message = translate(locale, "Password is a required field")
- env.response.status_code = 401
- next templated "error"
- end
-
- user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User)
-
- if user
- if !user.password
- error_message = translate(locale, "Please sign in using 'Log in with Google'")
- env.response.status_code = 400
- next templated "error"
- end
-
- if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
- sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
- PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc)
-
- if Kemal.config.ssl || config.https_only
- secure = true
- else
- secure = false
- end
-
- if config.domain
- env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{config.domain}", value: sid, expires: Time.utc + 2.years,
- secure: secure, http_only: true)
- else
- env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
- secure: secure, http_only: true)
- end
- else
- error_message = translate(locale, "Wrong username or password")
- env.response.status_code = 401
- next templated "error"
- end
-
- # Since this user has already registered, we don't want to overwrite their preferences
- if env.request.cookies["PREFS"]?
- cookie = env.request.cookies["PREFS"]
- cookie.expires = Time.utc(1990, 1, 1)
- env.response.cookies << cookie
- end
- else
- if !config.registration_enabled
- error_message = "Registration has been disabled by administrator."
- env.response.status_code = 400
- next templated "error"
- end
-
- if password.empty?
- error_message = translate(locale, "Password cannot be empty")
- env.response.status_code = 401
- next templated "error"
- end
-
- # See https://security.stackexchange.com/a/39851
- if password.bytesize > 55
- error_message = translate(locale, "Password should not be longer than 55 characters")
- env.response.status_code = 400
- next templated "error"
- end
-
- password = password.byte_slice(0, 55)
-
- if config.captcha_enabled
- captcha_type = env.params.body["captcha_type"]?
- answer = env.params.body["answer"]?
- change_type = env.params.body["change_type"]?
-
- if !captcha_type || change_type
- if change_type
- captcha_type = change_type
- end
- captcha_type ||= "image"
-
- account_type = "invidious"
- tfa = false
- prompt = ""
-
- if captcha_type == "image"
- captcha = generate_captcha(HMAC_KEY, PG_DB)
- else
- captcha = generate_text_captcha(HMAC_KEY, PG_DB)
- end
-
- next templated "login"
- end
-
- tokens = env.params.body.select { |k, v| k.match(/^token\[\d+\]$/) }.map { |k, v| v }
-
- answer ||= ""
- captcha_type ||= "image"
-
- case captcha_type
- when "image"
- answer = answer.lstrip('0')
- answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
-
- begin
- validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale)
- rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
- end
- else # "text"
- answer = Digest::MD5.hexdigest(answer.downcase.strip)
-
- found_valid_captcha = false
-
- error_message = translate(locale, "Erroneous CAPTCHA")
- tokens.each_with_index do |token, i|
- begin
- validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale)
- found_valid_captcha = true
- rescue ex
- error_message = ex.message
- end
- end
-
- if !found_valid_captcha
- env.response.status_code = 500
- next templated "error"
- end
- end
- end
-
- sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
- user, sid = create_user(sid, email, password)
- user_array = user.to_a
- user_array[4] = user_array[4].to_json # User preferences
-
- args = arg_array(user_array)
-
- PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array)
- PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc)
-
- view_name = "subscriptions_#{sha256(user.email)}"
- PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
-
- if Kemal.config.ssl || config.https_only
- secure = true
- else
- secure = false
- end
-
- if config.domain
- env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{config.domain}", value: sid, expires: Time.utc + 2.years,
- secure: secure, http_only: true)
- else
- env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
- secure: secure, http_only: true)
- end
-
- if env.request.cookies["PREFS"]?
- preferences = env.get("preferences").as(Preferences)
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
-
- cookie = env.request.cookies["PREFS"]
- cookie.expires = Time.utc(1990, 1, 1)
- env.response.cookies << cookie
- end
- end
-
- env.redirect referer
- else
- env.redirect referer
- end
-end
-
-post "/signout" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- user = env.get? "user"
- sid = env.get? "sid"
- referer = get_referer(env)
-
- if !user
- next env.redirect referer
- end
-
- user = user.as(User)
- sid = sid.as(String)
- token = env.params.body["csrf_token"]?
-
- begin
- validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
- rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
- end
-
- PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid)
-
- env.request.cookies.each do |cookie|
- cookie.expires = Time.utc(1990, 1, 1)
- env.response.cookies << cookie
- end
-
- env.redirect referer
-end
-
-get "/preferences" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- referer = get_referer(env)
-
- preferences = env.get("preferences").as(Preferences)
-
- templated "preferences"
-end
-
-post "/preferences" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
- referer = get_referer(env)
-
- video_loop = env.params.body["video_loop"]?.try &.as(String)
- video_loop ||= "off"
- video_loop = video_loop == "on"
-
- annotations = env.params.body["annotations"]?.try &.as(String)
- annotations ||= "off"
- annotations = annotations == "on"
-
- annotations_subscribed = env.params.body["annotations_subscribed"]?.try &.as(String)
- annotations_subscribed ||= "off"
- annotations_subscribed = annotations_subscribed == "on"
-
- autoplay = env.params.body["autoplay"]?.try &.as(String)
- autoplay ||= "off"
- autoplay = autoplay == "on"
-
- continue = env.params.body["continue"]?.try &.as(String)
- continue ||= "off"
- continue = continue == "on"
-
- continue_autoplay = env.params.body["continue_autoplay"]?.try &.as(String)
- continue_autoplay ||= "off"
- continue_autoplay = continue_autoplay == "on"
-
- listen = env.params.body["listen"]?.try &.as(String)
- listen ||= "off"
- listen = listen == "on"
-
- local = env.params.body["local"]?.try &.as(String)
- local ||= "off"
- local = local == "on"
-
- speed = env.params.body["speed"]?.try &.as(String).to_f32?
- speed ||= CONFIG.default_user_preferences.speed
-
- player_style = env.params.body["player_style"]?.try &.as(String)
- player_style ||= CONFIG.default_user_preferences.player_style
-
- quality = env.params.body["quality"]?.try &.as(String)
- quality ||= CONFIG.default_user_preferences.quality
-
- volume = env.params.body["volume"]?.try &.as(String).to_i?
- volume ||= CONFIG.default_user_preferences.volume
-
- comments = [] of String
- 2.times do |i|
- comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i])
- end
-
- captions = [] of String
- 3.times do |i|
- captions << (env.params.body["captions[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.captions[i])
- end
-
- related_videos = env.params.body["related_videos"]?.try &.as(String)
- related_videos ||= "off"
- related_videos = related_videos == "on"
-
- default_home = env.params.body["default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home
-
- feed_menu = [] of String
- 5.times do |index|
- option = env.params.body["feed_menu[#{index}]"]?.try &.as(String) || ""
- if !option.empty?
- feed_menu << option
- end
- end
-
- locale = env.params.body["locale"]?.try &.as(String)
- locale ||= CONFIG.default_user_preferences.locale
-
- dark_mode = env.params.body["dark_mode"]?.try &.as(String)
- dark_mode ||= CONFIG.default_user_preferences.dark_mode
-
- thin_mode = env.params.body["thin_mode"]?.try &.as(String)
- thin_mode ||= "off"
- thin_mode = thin_mode == "on"
-
- max_results = env.params.body["max_results"]?.try &.as(String).to_i?
- max_results ||= CONFIG.default_user_preferences.max_results
-
- sort = env.params.body["sort"]?.try &.as(String)
- sort ||= CONFIG.default_user_preferences.sort
-
- latest_only = env.params.body["latest_only"]?.try &.as(String)
- latest_only ||= "off"
- latest_only = latest_only == "on"
-
- unseen_only = env.params.body["unseen_only"]?.try &.as(String)
- unseen_only ||= "off"
- unseen_only = unseen_only == "on"
-
- notifications_only = env.params.body["notifications_only"]?.try &.as(String)
- notifications_only ||= "off"
- notifications_only = notifications_only == "on"
-
- # Convert to JSON and back again to take advantage of converters used for compatability
- preferences = Preferences.from_json({
- annotations: annotations,
- annotations_subscribed: annotations_subscribed,
- autoplay: autoplay,
- captions: captions,
- comments: comments,
- continue: continue,
- continue_autoplay: continue_autoplay,
- dark_mode: dark_mode,
- latest_only: latest_only,
- listen: listen,
- local: local,
- locale: locale,
- max_results: max_results,
- notifications_only: notifications_only,
- player_style: player_style,
- quality: quality,
- default_home: default_home,
- feed_menu: feed_menu,
- related_videos: related_videos,
- sort: sort,
- speed: speed,
- thin_mode: thin_mode,
- unseen_only: unseen_only,
- video_loop: video_loop,
- volume: volume,
- }.to_json).to_json
-
- if user = env.get? "user"
- user = user.as(User)
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email)
-
- if config.admins.includes? user.email
- config.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || config.default_user_preferences.default_home
-
- admin_feed_menu = [] of String
- 5.times do |index|
- option = env.params.body["admin_feed_menu[#{index}]"]?.try &.as(String) || ""
- if !option.empty?
- admin_feed_menu << option
- end
- end
- config.default_user_preferences.feed_menu = admin_feed_menu
-
- captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String)
- captcha_enabled ||= "off"
- config.captcha_enabled = captcha_enabled == "on"
-
- login_enabled = env.params.body["login_enabled"]?.try &.as(String)
- login_enabled ||= "off"
- config.login_enabled = login_enabled == "on"
-
- registration_enabled = env.params.body["registration_enabled"]?.try &.as(String)
- registration_enabled ||= "off"
- config.registration_enabled = registration_enabled == "on"
-
- statistics_enabled = env.params.body["statistics_enabled"]?.try &.as(String)
- statistics_enabled ||= "off"
- config.statistics_enabled = statistics_enabled == "on"
-
- CONFIG.default_user_preferences = config.default_user_preferences
- File.write("config/config.yml", config.to_yaml)
- end
- else
- if Kemal.config.ssl || config.https_only
- secure = true
- else
- secure = false
- end
-
- if config.domain
- env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{config.domain}", value: preferences, expires: Time.utc + 2.years,
- secure: secure, http_only: true)
- else
- env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years,
- secure: secure, http_only: true)
- end
- end
-
- env.redirect referer
-end
-
-get "/toggle_theme" do |env|
- locale = LOCALES[env.get("preferences").as(Preferences).locale]?
- referer = get_referer(env, unroll: false)
-
- redirect = env.params.query["redirect"]?
- redirect ||= "true"
- redirect = redirect == "true"
-
- if user = env.get? "user"
- user = user.as(User)
- preferences = user.preferences
-
- case preferences.dark_mode
- when "dark"
- preferences.dark_mode = "light"
- else
- preferences.dark_mode = "dark"
- end
-
- preferences = preferences.to_json
-
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email)
- else
- preferences = env.get("preferences").as(Preferences)
-
- case preferences.dark_mode
- when "dark"
- preferences.dark_mode = "light"
- else
- preferences.dark_mode = "dark"
- end
-
- preferences = preferences.to_json
-
- if Kemal.config.ssl || config.https_only
- secure = true
- else
- secure = false
- end
-
- if config.domain
- env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{config.domain}", value: preferences, expires: Time.utc + 2.years,
- secure: secure, http_only: true)
- else
- env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years,
- secure: secure, http_only: true)
- end
- end
-
- if redirect
- env.redirect referer
- else
- env.response.content_type = "application/json"
- "{}"
- end
-end
-
post "/watch_ajax" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
@@ -1702,9 +348,7 @@ post "/watch_ajax" do |env|
if redirect
next env.redirect referer
else
- error_message = {"error" => "No such user"}.to_json
- env.response.status_code = 403
- next error_message
+ next error_json(403, "No such user")
end
end
@@ -1721,13 +365,10 @@ post "/watch_ajax" do |env|
begin
validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
- env.response.status_code = 400
if redirect
- error_message = ex.message
- next templated "error"
+ next error_template(400, ex)
else
- error_message = {"error" => ex.message}.to_json
- next error_message
+ next error_json(400, ex)
end
end
@@ -1747,9 +388,7 @@ post "/watch_ajax" do |env|
when "action_mark_unwatched"
PG_DB.exec("UPDATE users SET watched = array_remove(watched, $1) WHERE email = $2", id, user.email)
else
- error_message = {"error" => "Unsupported action #{action}"}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "Unsupported action #{action}")
end
if redirect
@@ -1779,9 +418,7 @@ get "/modify_notifications" do |env|
if redirect
next env.redirect referer
else
- error_message = {"error" => "No such user"}.to_json
- env.response.status_code = 403
- next error_message
+ next error_json(403, "No such user")
end
end
@@ -1854,9 +491,7 @@ post "/subscription_ajax" do |env|
if redirect
next env.redirect referer
else
- error_message = {"error" => "No such user"}.to_json
- env.response.status_code = 403
- next error_message
+ next error_json(403, "No such user")
end
end
@@ -1868,13 +503,9 @@ post "/subscription_ajax" do |env|
validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
if redirect
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
+ next error_template(400, ex)
else
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, ex)
end
end
@@ -1898,15 +529,13 @@ post "/subscription_ajax" do |env|
case action
when "action_create_subscription_to_channel"
if !user.subscriptions.includes? channel_id
- get_channel(channel_id, PG_DB, false, false)
+ get_channel(channel_id, PG_DB, logger, false, false)
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions, $1) WHERE email = $2", channel_id, email)
end
when "action_remove_subscriptions"
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", channel_id, email)
else
- error_message = {"error" => "Unsupported action #{action}"}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "Unsupported action #{action}")
end
if redirect
@@ -1935,7 +564,7 @@ get "/subscription_manager" do |env|
headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"]
- user, sid = get_user(sid, headers, PG_DB)
+ user, sid = get_user(sid, headers, PG_DB, logger)
end
action_takeout = env.params.query["action_takeout"]?.try &.to_i?
@@ -2059,7 +688,7 @@ post "/data_control" do |env|
user.subscriptions += body["subscriptions"].as_a.map { |a| a.as_s }
user.subscriptions.uniq!
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
+ user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false)
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
end
@@ -2089,7 +718,7 @@ post "/data_control" do |env|
PG_DB.exec("UPDATE playlists SET description = $1 WHERE id = $2", description, playlist.id)
videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
- raise "Playlist cannot have more than 500 videos" if idx > 500
+ raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
video_id = video_id.try &.as_s?
next if !video_id
@@ -2121,13 +750,14 @@ post "/data_control" do |env|
end
end
when "import_youtube"
- subscriptions = XML.parse(body)
- user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
- channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
+ subscriptions = JSON.parse(body)
+
+ user.subscriptions += subscriptions.as_a.compact_map do |entry|
+ entry["snippet"]["resourceId"]["channelId"].as_s
end
user.subscriptions.uniq!
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
+ user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false)
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
when "import_freetube"
@@ -2136,7 +766,7 @@ post "/data_control" do |env|
end
user.subscriptions.uniq!
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
+ user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false)
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
when "import_newpipe_subscriptions"
@@ -2155,7 +785,7 @@ post "/data_control" do |env|
end
user.subscriptions.uniq!
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
+ user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false)
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
when "import_newpipe"
@@ -2174,7 +804,7 @@ post "/data_control" do |env|
user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map { |url| url.lchop("https://www.youtube.com/channel/") }
user.subscriptions.uniq!
- user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false)
+ user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false)
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email)
@@ -2226,51 +856,37 @@ post "/change_password" do |env|
# We don't store passwords for Google accounts
if !user.password
- error_message = "Cannot change password for Google accounts"
- env.response.status_code = 400
- next templated "error"
+ next error_template(400, "Cannot change password for Google accounts")
end
begin
validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
+ next error_template(400, ex)
end
password = env.params.body["password"]?
if !password
- error_message = translate(locale, "Password is a required field")
- env.response.status_code = 401
- next templated "error"
+ next error_template(401, "Password is a required field")
end
new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
if new_passwords.size <= 1 || new_passwords.uniq.size != 1
- error_message = translate(locale, "New passwords must match")
- env.response.status_code = 400
- next templated "error"
+ next error_template(400, "New passwords must match")
end
new_password = new_passwords.uniq[0]
if new_password.empty?
- error_message = translate(locale, "Password cannot be empty")
- env.response.status_code = 401
- next templated "error"
+ next error_template(401, "Password cannot be empty")
end
if new_password.bytesize > 55
- error_message = translate(locale, "Password should not be longer than 55 characters")
- env.response.status_code = 400
- next templated "error"
+ next 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))
- error_message = translate(locale, "Incorrect password")
- env.response.status_code = 401
- next templated "error"
+ next error_template(401, "Incorrect password")
end
new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
@@ -2315,9 +931,7 @@ post "/delete_account" do |env|
begin
validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
+ next error_template(400, ex)
end
view_name = "subscriptions_#{sha256(user.email)}"
@@ -2369,9 +983,7 @@ post "/clear_watch_history" do |env|
begin
validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
+ next error_template(400, ex)
end
PG_DB.exec("UPDATE users SET watched = '{}' WHERE email = $1", user.email)
@@ -2424,9 +1036,7 @@ post "/authorize_token" do |env|
begin
validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
+ next error_template(400, ex)
end
scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
@@ -2489,9 +1099,7 @@ post "/token_ajax" do |env|
if redirect
next env.redirect referer
else
- error_message = {"error" => "No such user"}.to_json
- env.response.status_code = 403
- next error_message
+ next error_json(403, "No such user")
end
end
@@ -2503,13 +1111,9 @@ post "/token_ajax" do |env|
validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
rescue ex
if redirect
- error_message = ex.message
- env.response.status_code = 400
- next templated "error"
+ next error_template(400, ex)
else
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, ex)
end
end
@@ -2526,9 +1130,7 @@ post "/token_ajax" do |env|
when .starts_with? "action_revoke_token"
PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email)
else
- error_message = {"error" => "Unsupported action #{action}"}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "Unsupported action #{action}")
end
if redirect
@@ -2541,15 +1143,26 @@ end
# Feeds
+get "/feed/playlists" do |env|
+ env.redirect "/view_all_playlists"
+end
+
get "/feed/top" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
- env.redirect "/"
+
+ message = translate(locale, "The Top feed has been removed from Invidious.")
+ templated "message"
end
get "/feed/popular" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
- templated "popular"
+ if config.popular_enabled
+ templated "popular"
+ else
+ message = translate(locale, "The Popular feed has been disabled by the administrator.")
+ templated "message"
+ end
end
get "/feed/trending" do |env|
@@ -2564,9 +1177,7 @@ get "/feed/trending" do |env|
begin
trending, plid = fetch_trending(trending_type, region, locale)
rescue ex
- error_message = "#{ex.message}"
- env.response.status_code = 500
- next templated "error"
+ next error_template(500, ex)
end
templated "trending"
@@ -2596,7 +1207,7 @@ get "/feed/subscriptions" do |env|
headers["Cookie"] = env.request.headers["Cookie"]
if !user.password
- user, sid = get_user(sid, headers, PG_DB)
+ user, sid = get_user(sid, headers, PG_DB, logger)
end
max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE)
@@ -2661,9 +1272,7 @@ get "/feed/channel/:ucid" do |env|
rescue ex : ChannelRedirect
next env.redirect env.request.resource.gsub(ucid, ex.channel_id)
rescue ex
- error_message = ex.message
- env.response.status_code = 500
- next error_message
+ next error_atom(500, ex)
end
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}")
@@ -2704,6 +1313,7 @@ get "/feed/channel/:ucid" do |env|
xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
xml.element("id") { xml.text "yt:channel:#{channel.ucid}" }
xml.element("yt:channelId") { xml.text channel.ucid }
+ xml.element("icon") { xml.text channel.author_thumbnail }
xml.element("title") { xml.text channel.author }
xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}")
@@ -2901,7 +1511,7 @@ post "/feed/webhook/:token" do |env|
signature = env.request.headers["X-Hub-Signature"].lchop("sha1=")
if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body)
- logger.puts("#{token} : Invalid signature")
+ logger.error("/feed/webhook/#{token} : Invalid signature")
env.response.status_code = 200
next
end
@@ -3074,9 +1684,7 @@ get "/channel/:ucid" do |env|
rescue ex : ChannelRedirect
next env.redirect env.request.resource.gsub(ucid, ex.channel_id)
rescue ex
- error_message = ex.message
- env.response.status_code = 500
- next templated "error"
+ next error_template(500, ex)
end
if channel.auto_generated
@@ -3143,9 +1751,7 @@ get "/channel/:ucid/playlists" do |env|
rescue ex : ChannelRedirect
next env.redirect env.request.resource.gsub(ucid, ex.channel_id)
rescue ex
- error_message = ex.message
- env.response.status_code = 500
- next templated "error"
+ next error_template(500, ex)
end
if channel.auto_generated
@@ -3183,9 +1789,7 @@ get "/channel/:ucid/community" do |env|
rescue ex : ChannelRedirect
next env.redirect env.request.resource.gsub(ucid, ex.channel_id)
rescue ex
- error_message = ex.message
- env.response.status_code = 500
- next templated "error"
+ next error_template(500, ex)
end
if !channel.tabs.includes? "community"
@@ -3194,9 +1798,11 @@ get "/channel/:ucid/community" do |env|
begin
items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode))
- rescue ex
+ rescue ex : InfoException
env.response.status_code = 500
error_message = ex.message
+ rescue ex
+ next error_template(500, ex)
end
env.set "search", "channel:#{channel.ucid} "
@@ -3206,12 +1812,11 @@ end
# API Endpoints
get "/api/v1/stats" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json"
if !config.statistics_enabled
- error_message = {"error" => "Statistics are not enabled."}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "Statistics are not enabled.")
end
Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
@@ -3231,10 +1836,8 @@ get "/api/v1/storyboards/:id" do |env|
begin
video = get_video(id, PG_DB, region: region)
rescue ex : VideoRedirect
- error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json
- env.response.status_code = 302
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
- next error_message
+ next error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex
env.response.status_code = 500
next
@@ -3280,14 +1883,14 @@ get "/api/v1/storyboards/:id" do |env|
storyboard[:storyboard_count].times do |i|
url = storyboard[:url]
authority = /(i\d?).ytimg.com/.match(url).not_nil![1]?
- url = storyboard[:url].gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
+ url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
url = "#{HOST_URL}/sb/#{authority}/#{url}"
storyboard[:storyboard_height].times do |j|
storyboard[:storyboard_width].times do |k|
str << <<-END_CUE
#{start_time}.000 --> #{end_time}.000
- #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width]},#{storyboard[:height]}
+ #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}
END_CUE
@@ -3319,10 +1922,8 @@ get "/api/v1/captions/:id" do |env|
begin
video = get_video(id, PG_DB, region: region)
rescue ex : VideoRedirect
- error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json
- env.response.status_code = 302
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
- next error_message
+ next error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex
env.response.status_code = 500
next
@@ -3454,9 +2055,7 @@ get "/api/v1/comments/:id" do |env|
begin
comments = fetch_youtube_comments(id, PG_DB, continuation, format, locale, thin_mode, region, sort_by: sort_by)
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
next comments
@@ -3499,13 +2098,7 @@ end
get "/api/v1/insights/:id" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
-
- id = env.params.url["id"]
- env.response.content_type = "application/json"
-
- error_message = {"error" => "YouTube has removed publicly available analytics."}.to_json
- env.response.status_code = 410
- error_message
+ next error_json(410, "YouTube has removed publicly available analytics.")
end
get "/api/v1/annotations/:id" do |env|
@@ -3540,14 +2133,13 @@ get "/api/v1/annotations/:id" do |env|
file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
- client = make_client(ARCHIVE_URL)
- location = client.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")
+ location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
if !location.headers["Location"]?
env.response.status_code = location.status_code
end
- response = make_client(URI.parse(location.headers["Location"])).get(location.headers["Location"])
+ response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"]))
if response.body.empty?
env.response.status_code = 404
@@ -3594,14 +2186,10 @@ get "/api/v1/videos/:id" do |env|
begin
video = get_video(id, PG_DB, region: region)
rescue ex : VideoRedirect
- error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json
- env.response.status_code = 302
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
- next error_message
+ next error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
video.to_json(locale)
@@ -3618,9 +2206,7 @@ get "/api/v1/trending" do |env|
begin
trending, plid = fetch_trending(trending_type, region, locale)
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
videos = JSON.build do |json|
@@ -3639,6 +2225,12 @@ get "/api/v1/popular" do |env|
env.response.content_type = "application/json"
+ if !config.popular_enabled
+ error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
+ env.response.status_code = 400
+ next error_message
+ end
+
JSON.build do |json|
json.array do
popular_videos.each do |video|
@@ -3652,7 +2244,8 @@ get "/api/v1/top" do |env|
locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json"
- "[]"
+ env.response.status_code = 400
+ {"error" => "The Top feed has been removed from Invidious."}.to_json
end
get "/api/v1/channels/:ucid" do |env|
@@ -3667,14 +2260,10 @@ get "/api/v1/channels/:ucid" do |env|
begin
channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect
- error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json
- env.response.status_code = 302
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
- next error_message
+ next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
page = 1
@@ -3685,9 +2274,7 @@ get "/api/v1/channels/:ucid" do |env|
begin
count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
end
@@ -3802,22 +2389,16 @@ end
begin
channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect
- error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json
- env.response.status_code = 302
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
- next error_message
+ next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
begin
count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
JSON.build do |json|
@@ -3841,9 +2422,7 @@ end
begin
videos = get_latest_videos(ucid)
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
JSON.build do |json|
@@ -3871,14 +2450,10 @@ end
begin
channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect
- error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json
- env.response.status_code = 302
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
- next error_message
+ next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
items, continuation = fetch_channel_playlists(channel.ucid, channel.author, channel.auto_generated, continuation, sort_by)
@@ -3919,9 +2494,7 @@ end
begin
fetch_channel_community(ucid, continuation, locale, format, thin_mode)
rescue ex
- env.response.status_code = 400
- error_message = {"error" => ex.message}.to_json
- next error_message
+ next error_json(500, ex)
end
end
end
@@ -3979,9 +2552,7 @@ get "/api/v1/search" do |env|
begin
search_params = produce_search_params(sort_by, date, content_type, duration, features)
rescue ex
- env.response.status_code = 400
- error_message = {"error" => ex.message}.to_json
- next error_message
+ next error_json(400, ex)
end
count, search_results = search(query, page, search_params, region).as(Tuple)
@@ -4024,9 +2595,7 @@ get "/api/v1/search/suggestions" do |env|
end
end
rescue ex
- env.response.status_code = 500
- error_message = {"error" => ex.message}.to_json
- next error_message
+ next error_json(500, ex)
end
end
@@ -4053,16 +2622,12 @@ end
begin
playlist = get_playlist(PG_DB, plid, locale)
rescue ex
- env.response.status_code = 404
- error_message = {"error" => "Playlist does not exist."}.to_json
- next error_message
+ next 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
- env.response.status_code = 404
- error_message = {"error" => "Playlist does not exist."}.to_json
- next error_message
+ next error_json(404, "Playlist does not exist.")
end
response = playlist.to_json(offset, locale, continuation: continuation)
@@ -4106,9 +2671,7 @@ get "/api/v1/mixes/:rdid" do |env|
mix.videos = mix.videos[index..-1]
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
response = JSON.build do |json|
@@ -4264,7 +2827,7 @@ post "/api/v1/auth/subscriptions/:ucid" do |env|
ucid = env.params.url["ucid"]
if !user.subscriptions.includes? ucid
- get_channel(ucid, PG_DB, false, false)
+ get_channel(ucid, PG_DB, logger, false, false)
PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email)
end
@@ -4310,22 +2873,16 @@ post "/api/v1/auth/playlists" do |env|
title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150)
if !title
- error_message = {"error" => "Invalid title."}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "Invalid title.")
end
privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) }
if !privacy
- error_message = {"error" => "Invalid privacy setting."}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "Invalid privacy setting.")
end
if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100
- error_message = {"error" => "User cannot have more than 100 playlists."}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "User cannot have more than 100 playlists.")
end
playlist = create_playlist(PG_DB, title, privacy, user)
@@ -4347,15 +2904,11 @@ patch "/api/v1/auth/playlists/:plid" do |env|
playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
if !playlist || playlist.author != user.email && playlist.privacy.private?
- env.response.status_code = 404
- error_message = {"error" => "Playlist does not exist."}.to_json
- next error_message
+ next error_json(404, "Playlist does not exist.")
end
if playlist.author != user.email
- env.response.status_code = 403
- error_message = {"error" => "Invalid user"}.to_json
- next error_message
+ next error_json(403, "Invalid user")
end
title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title
@@ -4375,6 +2928,8 @@ patch "/api/v1/auth/playlists/:plid" do |env|
end
delete "/api/v1/auth/playlists/:plid" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
env.response.content_type = "application/json"
user = env.get("user").as(User)
@@ -4382,15 +2937,11 @@ delete "/api/v1/auth/playlists/:plid" do |env|
playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
if !playlist || playlist.author != user.email && playlist.privacy.private?
- env.response.status_code = 404
- error_message = {"error" => "Playlist does not exist."}.to_json
- next error_message
+ next error_json(404, "Playlist does not exist.")
end
if playlist.author != user.email
- env.response.status_code = 403
- error_message = {"error" => "Invalid user"}.to_json
- next error_message
+ next error_json(403, "Invalid user")
end
PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid)
@@ -4409,36 +2960,26 @@ post "/api/v1/auth/playlists/:plid/videos" do |env|
playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
if !playlist || playlist.author != user.email && playlist.privacy.private?
- env.response.status_code = 404
- error_message = {"error" => "Playlist does not exist."}.to_json
- next error_message
+ next error_json(404, "Playlist does not exist.")
end
if playlist.author != user.email
- env.response.status_code = 403
- error_message = {"error" => "Invalid user"}.to_json
- next error_message
+ next error_json(403, "Invalid user")
end
if playlist.index.size >= 500
- env.response.status_code = 400
- error_message = {"error" => "Playlist cannot have more than 500 videos"}.to_json
- next error_message
+ next error_json(400, "Playlist cannot have more than 500 videos")
end
video_id = env.params.json["videoId"].try &.as(String)
if !video_id
- env.response.status_code = 403
- error_message = {"error" => "Invalid videoId"}.to_json
- next error_message
+ next error_json(403, "Invalid videoId")
end
begin
video = get_video(video_id, PG_DB)
rescue ex
- error_message = {"error" => ex.message}.to_json
- env.response.status_code = 500
- next error_message
+ next error_json(500, ex)
end
playlist_video = PlaylistVideo.new({
@@ -4465,6 +3006,8 @@ post "/api/v1/auth/playlists/:plid/videos" do |env|
end
delete "/api/v1/auth/playlists/:plid/videos/:index" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
env.response.content_type = "application/json"
user = env.get("user").as(User)
@@ -4473,21 +3016,15 @@ delete "/api/v1/auth/playlists/:plid/videos/:index" do |env|
playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
if !playlist || playlist.author != user.email && playlist.privacy.private?
- env.response.status_code = 404
- error_message = {"error" => "Playlist does not exist."}.to_json
- next error_message
+ next error_json(404, "Playlist does not exist.")
end
if playlist.author != user.email
- env.response.status_code = 403
- error_message = {"error" => "Invalid user"}.to_json
- next error_message
+ next error_json(403, "Invalid user")
end
if !playlist.index.includes? index
- env.response.status_code = 404
- error_message = {"error" => "Playlist does not contain index"}.to_json
- next error_message
+ next error_json(404, "Playlist does not contain index")
end
PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index)
@@ -4533,9 +3070,7 @@ post "/api/v1/auth/tokens/register" do |env|
callback_url = env.params.json["callbackUrl"]?.try &.as(String)
expire = env.params.json["expire"]?.try &.as(Int64)
else
- error_message = {"error" => "Invalid or missing header 'Content-Type'"}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "Invalid or missing header 'Content-Type'")
end
if callback_url && callback_url.empty?
@@ -4585,6 +3120,7 @@ post "/api/v1/auth/tokens/register" do |env|
end
post "/api/v1/auth/tokens/unregister" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
env.response.content_type = "application/json"
user = env.get("user").as(User)
scopes = env.get("scopes").as(Array(String))
@@ -4598,9 +3134,7 @@ post "/api/v1/auth/tokens/unregister" do |env|
elsif scopes_include_scope(scopes, "GET:tokens")
PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session)
else
- error_message = {"error" => "Cannot revoke session #{session}"}.to_json
- env.response.status_code = 400
- next error_message
+ next error_json(400, "Cannot revoke session #{session}")
end
env.response.status_code = 204
@@ -4924,6 +3458,7 @@ get "/videoplayback/*" do |env|
end
get "/videoplayback" do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
query_params = env.params.query
fvip = query_params["fvip"]? || "3"
@@ -4962,8 +3497,12 @@ get "/videoplayback" do |env|
location = URI.parse(response.headers["Location"])
env.response.headers["Access-Control-Allow-Origin"] = "*"
- host = "#{location.scheme}://#{location.host}"
- client = make_client(URI.parse(host), region)
+ new_host = "#{location.scheme}://#{location.host}"
+ if new_host != host
+ host = new_host
+ client.close
+ client = make_client(URI.parse(new_host), region)
+ end
url = "#{location.full_path}&host=#{location.host}#{region ? "&region=#{region}" : ""}"
else
@@ -4990,13 +3529,10 @@ get "/videoplayback" do |env|
if url.includes? "&file=seg.ts"
if CONFIG.disabled?("livestreams")
- env.response.status_code = 403
- error_message = "Administrator has disabled this endpoint."
- next templated "error"
+ next error_template(403, "Administrator has disabled this endpoint.")
end
begin
- client = make_client(URI.parse(host), region)
client.get(url, headers) do |response|
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
@@ -5024,9 +3560,7 @@ get "/videoplayback" do |env|
else
if query_params["title"]? && CONFIG.disabled?("downloads") ||
CONFIG.disabled?("dash")
- env.response.status_code = 403
- error_message = "Administrator has disabled this endpoint."
- next templated "error"
+ next error_template(403, "Administrator has disabled this endpoint.")
end
content_length = nil
@@ -5039,8 +3573,6 @@ get "/videoplayback" do |env|
chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1
end
- client = make_client(URI.parse(host), region)
-
# TODO: Record bytes written so we can restart after a chunk fails
while true
if !range_end && content_length
@@ -5104,6 +3636,7 @@ get "/videoplayback" do |env|
if ex.message != "Error reading socket: Connection reset by peer"
break
else
+ client.close
client = make_client(URI.parse(host), region)
end
end
@@ -5113,6 +3646,7 @@ get "/videoplayback" do |env|
first_chunk = false
end
end
+ client.close
end
get "/ggpht/*" do |env|
@@ -5367,14 +3901,9 @@ error 404 do |env|
halt env, status_code: 302
end
-error 500 do |env|
- error_message = <<-END_HTML
- Looks like you've found a bug in Invidious. Feel free to open a new issue
- <a href="https://github.com/iv-org/invidious/issues">here</a>
- or send an email to
- <a href="mailto:#{CONFIG.admin_email}">#{CONFIG.admin_email}</a>.
- END_HTML
- templated "error"
+error 500 do |env, ex|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ error_template(500, ex)
end
static_headers do |response, filepath, filestat|
diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr
index 656b9953..6907ff3d 100644
--- a/src/invidious/channels.cr
+++ b/src/invidious/channels.cr
@@ -144,7 +144,7 @@ class ChannelRedirect < Exception
end
end
-def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10)
+def get_batch_channels(channels, db, logger, refresh = false, pull_all_videos = true, max_threads = 10)
finished_channel = Channel(String | Nil).new
spawn do
@@ -160,7 +160,7 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
active_threads += 1
spawn do
begin
- get_channel(ucid, db, refresh, pull_all_videos)
+ get_channel(ucid, db, logger, refresh, pull_all_videos)
finished_channel.send(ucid)
rescue ex
finished_channel.send(nil)
@@ -181,10 +181,10 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma
return final
end
-def get_channel(id, db, refresh = true, pull_all_videos = true)
+def get_channel(id, db, logger, refresh = true, pull_all_videos = true)
if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel)
if refresh && Time.utc - channel.updated > 10.minutes
- channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
+ channel = fetch_channel(id, db, logger, pull_all_videos: pull_all_videos)
channel_array = channel.to_a
args = arg_array(channel_array)
@@ -192,7 +192,7 @@ def get_channel(id, db, refresh = true, pull_all_videos = true)
ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array)
end
else
- channel = fetch_channel(id, db, pull_all_videos: pull_all_videos)
+ channel = fetch_channel(id, db, logger, pull_all_videos: pull_all_videos)
channel_array = channel.to_a
args = arg_array(channel_array)
@@ -202,13 +202,17 @@ def get_channel(id, db, refresh = true, pull_all_videos = true)
return channel
end
-def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
+def fetch_channel(ucid, db, logger, pull_all_videos = true, locale = nil)
+ logger.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}")
+
+ logger.trace("fetch_channel: #{ucid} : Downloading RSS feed")
rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body
+ logger.trace("fetch_channel: #{ucid} : Parsing RSS feed")
rss = XML.parse_html(rss)
author = rss.xpath_node(%q(//feed/title))
if !author
- raise translate(locale, "Deleted or invalid channel")
+ raise InfoException.new("Deleted or invalid channel")
end
author = author.content
@@ -219,22 +223,29 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
auto_generated = true
end
+ logger.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}")
+
page = 1
+ logger.trace("fetch_channel: #{ucid} : Downloading channel videos page")
response = get_channel_videos_response(ucid, page, auto_generated: auto_generated)
videos = [] of SearchVideo
begin
initial_data = JSON.parse(response.body).as_a.find &.["response"]?
- raise "Could not extract JSON" if !initial_data
+ raise InfoException.new("Could not extract channel JSON") if !initial_data
+
+ logger.trace("fetch_channel: #{ucid} : Extracting videos from channel videos page initial_data")
videos = extract_videos(initial_data.as_h, author, ucid)
rescue ex
if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") ||
response.body.includes?("https://www.google.com/sorry/index")
- raise "Could not extract channel info. Instance is likely blocked."
+ raise InfoException.new("Could not extract channel info. Instance is likely blocked.")
end
+ raise ex
end
+ logger.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed")
rss.xpath_nodes("//feed/entry").each do |entry|
video_id = entry.xpath_node("videoid").not_nil!.content
title = entry.xpath_node("title").not_nil!.content
@@ -268,6 +279,8 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
views: views,
})
+ logger.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video")
+
# We don't include the 'premiere_timestamp' here because channel pages don't include them,
# meaning the above timestamp is always null
was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
@@ -275,8 +288,13 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
updated = $4, ucid = $5, author = $6, length_seconds = $7, \
live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool)
- db.exec("UPDATE users SET notifications = array_append(notifications, $1), \
- feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert
+ if was_insert
+ logger.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions")
+ db.exec("UPDATE users SET notifications = array_append(notifications, $1), \
+ feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid)
+ else
+ logger.trace("fetch_channel: #{ucid} : video #{video_id} : Updated")
+ end
end
if pull_all_videos
@@ -287,7 +305,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
loop do
response = get_channel_videos_response(ucid, page, auto_generated: auto_generated)
initial_data = JSON.parse(response.body).as_a.find &.["response"]?
- raise "Could not extract JSON" if !initial_data
+ raise InfoException.new("Could not extract channel JSON") if !initial_data
videos = extract_videos(initial_data.as_h, author, ucid)
count = videos.size
@@ -507,8 +525,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end
if response.status_code != 200
- error_message = translate(locale, "This channel does not exist.")
- raise error_message
+ raise InfoException.new("This channel does not exist.")
end
ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
@@ -518,7 +535,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]?
if !body
- raise "Could not extract community tab."
+ raise InfoException.new("Could not extract community tab.")
end
body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
@@ -540,7 +557,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
body["response"]["continuationContents"]["backstageCommentsContinuation"]?
if !body
- raise "Could not extract continuation."
+ raise InfoException.new("Could not extract continuation.")
end
end
@@ -551,7 +568,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
error_message = (message["text"]["simpleText"]? ||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
.try &.as_s || ""
- raise error_message
+ raise InfoException.new(error_message)
end
response = JSON.build do |json|
@@ -634,7 +651,8 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
else
video_id = attachment["videoId"].as_s
- json.field "title", attachment["title"]["simpleText"].as_s
+ video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?
+ json.field "title", video_title
json.field "videoId", video_id
json.field "videoThumbnails" do
generate_thumbnails(json, video_id)
@@ -656,7 +674,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
json.field "published", published.to_unix
json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale))
- view_count = attachment["viewCountText"]["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64
+ view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64
json.field "viewCount", view_count
json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count))
@@ -775,46 +793,41 @@ def extract_channel_community_cursor(continuation)
cursor
end
-INITDATA_PREQUERY = "window[\"ytInitialData\"] = {"
-
def get_about_info(ucid, locale)
- about = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en")
- if about.status_code != 200
- about = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en")
+ result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en")
+ if result.status_code != 200
+ result = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en")
end
- if md = about.headers["location"]?.try &.match(/\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/)
+ if md = result.headers["location"]?.try &.match(/\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/)
raise ChannelRedirect.new(channel_id: md["ucid"])
end
- if about.status_code != 200
- error_message = translate(locale, "This channel does not exist.")
- raise error_message
+ if result.status_code != 200
+ raise InfoException.new("This channel does not exist.")
+ end
+
+ about = XML.parse_html(result.body)
+ if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")]))
+ raise InfoException.new("This channel does not exist.")
end
- initdata_pre = about.body.index(INITDATA_PREQUERY)
- initdata_post = initdata_pre.nil? ? nil : about.body.index("};", initdata_pre)
- if initdata_post.nil?
- about = XML.parse_html(about.body)
+ initdata = extract_initial_data(result.body)
+ if initdata.empty?
error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip
error_message ||= translate(locale, "Could not get channel info.")
- raise error_message
+ raise InfoException.new(error_message)
end
- initdata_pre = initdata_pre.not_nil! + INITDATA_PREQUERY.size - 1
-
- initdata = JSON.parse(about.body[initdata_pre, initdata_post - initdata_pre + 1])
- about = XML.parse_html(about.body)
- if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")]))
- error_message = translate(locale, "This channel does not exist.")
- raise error_message
+ if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]?
+ raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s)
end
- author = about.xpath_node(%q(//meta[@name="title"])).not_nil!["content"]
- author_url = about.xpath_node(%q(//link[@rel="canonical"])).not_nil!["href"]
- author_thumbnail = about.xpath_node(%q(//link[@rel="image_src"])).not_nil!["href"]
+ author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
+ author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
+ author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
- ucid = about.xpath_node(%q(//meta[@itemprop="channelId"])).not_nil!["content"]
+ ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
# Raises a KeyError on failure.
banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 407cef78..8849c87f 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -88,11 +88,11 @@ def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, so
"cookie" => video.cookie,
}
- response = YT_POOL.client(region, &.post("/comment_service_ajax?action_get_comments=1&hl=en&gl=US", headers, form: post_req))
+ response = YT_POOL.client(region, &.post("/comment_service_ajax?action_get_comments=1&hl=en&gl=US&pbj=1", headers, form: post_req))
response = JSON.parse(response.body)
if !response["response"]["continuationContents"]?
- raise translate(locale, "Could not fetch comments")
+ raise InfoException.new("Could not fetch comments")
end
response = response["response"]["continuationContents"]
@@ -266,9 +266,11 @@ def fetch_reddit_comments(id, sort_by = "confidence")
thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink)
else
- raise "Got error code #{search_results.status_code}"
+ raise InfoException.new("Could not fetch comments")
end
+ client.close
+
comments = result[1].data.as(RedditListing).children
return comments, thread
end
@@ -581,13 +583,17 @@ def produce_comment_continuation(video_id, cursor = "", sort_by = "top")
object = {
"2:embedded" => {
"2:string" => video_id,
- "24:varint" => 1_i64,
- "25:varint" => 1_i64,
+ "25:varint" => 0_i64,
"28:varint" => 1_i64,
"36:embedded" => {
"5:varint" => -1_i64,
"8:varint" => 0_i64,
},
+ "40:embedded" => {
+ "1:varint" => 4_i64,
+ "3:string" => "https://www.youtube.com",
+ "4:string" => "",
+ },
},
"3:varint" => 6_i64,
"6:embedded" => {
diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr
new file mode 100644
index 00000000..4487ff8c
--- /dev/null
+++ b/src/invidious/helpers/errors.cr
@@ -0,0 +1,103 @@
+# InfoExceptions are for displaying information to the user.
+#
+# An InfoException might or might not indicate that something went wrong.
+# Historically Invidious didn't differentiate between these two options, so to
+# maintain previous functionality InfoExceptions do not print backtraces.
+class InfoException < Exception
+end
+
+macro error_template(*args)
+ error_template_helper(env, config, locale, {{*args}})
+end
+
+def github_details(summary : String, content : String)
+ details = %(\n<details>)
+ details += %(\n<summary>#{summary}</summary>)
+ details += %(\n<p>)
+ details += %(\n \n```\n)
+ details += content.strip
+ details += %(\n```)
+ details += %(\n</p>)
+ details += %(\n</details>)
+ return HTML.escape(details)
+end
+
+def error_template_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception)
+ if exception.is_a?(InfoException)
+ return error_template_helper(env, config, locale, status_code, exception.message || "")
+ end
+ env.response.status_code = status_code
+ issue_template = %(Title: `#{exception.message} (#{exception.class})`)
+ issue_template += %(\nDate: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`)
+ issue_template += %(\nRoute: `#{env.request.resource}`)
+ issue_template += %(\nVersion: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}`)
+ # issue_template += github_details("Preferences", env.get("preferences").as(Preferences).to_pretty_json)
+ issue_template += github_details("Backtrace", exception.inspect_with_backtrace)
+ error_message = <<-END_HTML
+ Looks like you've found a bug in Invidious. Please open a new issue
+ <a href="https://github.com/iv-org/invidious/issues">on GitHub</a>
+ and include the following text in your message:
+ <pre style="padding: 20px; background: rgba(0, 0, 0, 0.12345);">#{issue_template}</pre>
+ END_HTML
+ return templated "error"
+end
+
+def error_template_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String)
+ env.response.status_code = status_code
+ error_message = translate(locale, message)
+ return templated "error"
+end
+
+macro error_atom(*args)
+ error_atom_helper(env, config, locale, {{*args}})
+end
+
+def error_atom_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception)
+ if exception.is_a?(InfoException)
+ return error_atom_helper(env, config, locale, status_code, exception.message || "")
+ end
+ env.response.content_type = "application/atom+xml"
+ env.response.status_code = status_code
+ return "<error>#{exception.inspect_with_backtrace}</error>"
+end
+
+def error_atom_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String)
+ env.response.content_type = "application/atom+xml"
+ env.response.status_code = status_code
+ return "<error>#{message}</error>"
+end
+
+macro error_json(*args)
+ error_json_helper(env, config, locale, {{*args}})
+end
+
+def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil)
+ if exception.is_a?(InfoException)
+ return error_json_helper(env, config, locale, status_code, exception.message || "", additional_fields)
+ end
+ env.response.content_type = "application/json"
+ env.response.status_code = status_code
+ error_message = {"error" => exception.message, "errorBacktrace" => exception.inspect_with_backtrace}
+ if additional_fields
+ error_message = error_message.merge(additional_fields)
+ end
+ return error_message.to_json
+end
+
+def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception)
+ return error_json_helper(env, config, locale, status_code, exception, nil)
+end
+
+def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil)
+ env.response.content_type = "application/json"
+ env.response.status_code = status_code
+ error_message = {"error" => message}
+ if additional_fields
+ error_message = error_message.merge(additional_fields)
+ end
+ return error_message.to_json
+end
+
+def error_json_helper(env : HTTP::Server::Context, config : Config, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String)
+ error_json_helper(env, config, locale, status_code, message, nil)
+end
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index 62c24f3e..2da49abb 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -41,6 +41,7 @@ struct ConfigPreferences
property notifications_only : Bool = false
property player_style : String = "invidious"
property quality : String = "hd720"
+ property quality_dash : String = "auto"
property default_home : String = "Popular"
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
property related_videos : Bool = true
@@ -60,7 +61,7 @@ struct ConfigPreferences
end
end
-struct Config
+class Config
include YAML::Serializable
property channel_threads : Int32 # Number of threads to use for crawling videos from channels (for updating subscriptions)
@@ -71,6 +72,7 @@ struct Config
property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required
property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
+ property popular_enabled : Bool = true
property captcha_enabled : Bool = true
property login_enabled : Bool = true
property registration_enabled : Bool = true
@@ -93,8 +95,9 @@ struct Config
property admin_email : String = "omarroth@protonmail.com" # Email for bug reports
@[YAML::Field(converter: Preferences::StringToCookies)]
- property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format
- property captcha_key : String? = nil # Key for Anti-Captcha
+ property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format
+ property captcha_key : String? = nil # Key for Anti-Captcha
+ property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha
def disabled?(option)
case disabled = CONFIG.disable_proxy
@@ -334,7 +337,7 @@ def check_enum(db, logger, enum_name, struct_type = nil)
return # TODO
if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool)
- logger.puts("CREATE TYPE #{enum_name}")
+ logger.info("check_enum: CREATE TYPE #{enum_name}")
db.using_connection do |conn|
conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql"))
@@ -347,7 +350,7 @@ def check_table(db, logger, table_name, struct_type = nil)
begin
db.exec("SELECT * FROM #{table_name} LIMIT 0")
rescue ex
- logger.puts("CREATE TABLE #{table_name}")
+ logger.info("check_table: check_table: CREATE TABLE #{table_name}")
db.using_connection do |conn|
conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql"))
@@ -367,7 +370,7 @@ def check_table(db, logger, table_name, struct_type = nil)
if name != column_array[i]?
if !column_array[i]?
new_column = column_types.select { |line| line.starts_with? name }[0]
- logger.puts("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
+ logger.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
next
end
@@ -385,29 +388,29 @@ def check_table(db, logger, table_name, struct_type = nil)
# There's a column we didn't expect
if !new_column
- logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}")
+ logger.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}")
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
column_array = get_column_array(db, table_name)
next
end
- logger.puts("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
+ logger.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}")
- logger.puts("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
+ logger.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}")
- logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
+ logger.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
- logger.puts("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
+ logger.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}")
column_array = get_column_array(db, table_name)
end
else
- logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
+ logger.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE")
end
end
@@ -417,7 +420,7 @@ def check_table(db, logger, table_name, struct_type = nil)
column_array.each do |column|
if !struct_array.includes? column
- logger.puts("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE")
+ logger.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE")
db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE")
end
end
@@ -597,12 +600,7 @@ def create_notification_stream(env, topics, connection_channel)
end
def extract_initial_data(body) : Hash(String, JSON::Any)
- initial_data = body.match(/(window\["ytInitialData"\]|var\s+ytInitialData)\s*=\s*(?<info>.*?);+\s*\n/).try &.["info"] || "{}"
- if initial_data.starts_with?("JSON.parse(\"")
- return JSON.parse(JSON.parse(%({"initial_data":"#{initial_data[12..-3]}"}))["initial_data"].as_s).as_h
- else
- return JSON.parse(initial_data).as_h
- end
+ return JSON.parse(body.match(/(window\["ytInitialData"\]|var\s*ytInitialData)\s*=\s*(?<info>\{.*?\});/mx).try &.["info"] || "{}").as_h
end
def proxy_file(response, env)
diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr
index 52f0a22c..4e4d7306 100644
--- a/src/invidious/helpers/logger.cr
+++ b/src/invidious/helpers/logger.cr
@@ -1,66 +1,52 @@
require "logger"
enum LogLevel
+ All
+ Trace
Debug
Info
Warn
Error
+ Fatal
+ Off
end
class Invidious::LogHandler < Kemal::BaseLogHandler
- def initialize(@io : IO = STDOUT, @level = LogLevel::Warn)
+ def initialize(@io : IO = STDOUT, @level = LogLevel::Debug)
end
def call(context : HTTP::Server::Context)
- time = Time.utc
- call_next(context)
- elapsed_text = elapsed_text(Time.utc - time)
+ elapsed_time = Time.measure { call_next(context) }
+ elapsed_text = elapsed_text(elapsed_time)
- @io << time << ' ' << context.response.status_code << ' ' << context.request.method << ' ' << context.request.resource << ' ' << elapsed_text << '\n'
-
- if @io.is_a? File
- @io.flush
- end
+ info("#{context.response.status_code} #{context.request.method} #{context.request.resource} #{elapsed_text}")
context
end
def puts(message : String)
@io << message << '\n'
-
- if @io.is_a? File
- @io.flush
- end
+ @io.flush
end
- def write(message : String, level = @level)
+ def write(message : String)
@io << message
-
- if @io.is_a? File
- @io.flush
- end
+ @io.flush
end
def set_log_level(level : String)
- case level.downcase
- when "debug"
- set_log_level(LogLevel::Debug)
- when "info"
- set_log_level(LogLevel::Info)
- when "warn"
- set_log_level(LogLevel::Warn)
- when "error"
- set_log_level(LogLevel::Error)
- end
+ @level = LogLevel.parse(level)
end
def set_log_level(level : LogLevel)
@level = level
end
- {% for level in %w(debug info warn error) %}
+ {% for level in %w(trace debug info warn error fatal) %}
def {{level.id}}(message : String)
- puts(message, LogLevel::{{level.id.capitalize}})
+ if LogLevel::{{level.id.capitalize}} >= @level
+ puts("#{Time.utc} [{{level.id}}] #{message}")
+ end
end
{% end %}
diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr
index 4f415ba0..7a42ef41 100644
--- a/src/invidious/helpers/proxy.cr
+++ b/src/invidious/helpers/proxy.cr
@@ -108,7 +108,9 @@ def filter_proxies(proxies)
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
client.set_proxy(proxy)
- client.head("/").status_code == 200
+ status_ok = client.head("/").status_code == 200
+ client.close
+ status_ok
rescue ex
false
end
@@ -132,6 +134,7 @@ def get_nova_proxies(country_code = "US")
headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/"
response = client.get("/proxy-server-list/country-#{country_code}/", headers)
+ client.close
document = XML.parse_html(response.body)
proxies = [] of {ip: String, port: Int32, score: Float64}
@@ -177,6 +180,7 @@ def get_spys_proxies(country_code = "US")
}
response = client.post("/free-proxy-list/#{country_code}/", headers, form: body)
+ client.close
20.times do
if response.status_code == 200
break
diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr
index 39aae367..a09ce90b 100644
--- a/src/invidious/helpers/tokens.cr
+++ b/src/invidious/helpers/tokens.cr
@@ -70,33 +70,33 @@ def validate_request(token, session, request, key, db, locale = nil)
when JSON::Any
token = token.as_h
when Nil
- raise translate(locale, "Hidden field \"token\" is a required field")
+ raise InfoException.new("Hidden field \"token\" is a required field")
end
expire = token["expire"]?.try &.as_i
if expire.try &.< Time.utc.to_unix
- raise translate(locale, "Token is expired, please try again")
+ raise InfoException.new("Token is expired, please try again")
end
if token["session"] != session
- raise translate(locale, "Erroneous token")
+ raise InfoException.new("Erroneous token")
end
scopes = token["scopes"].as_a.map { |v| v.as_s }
scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}"
if !scopes_include_scope(scopes, scope)
- raise translate(locale, "Invalid scope")
+ raise InfoException.new("Invalid scope")
end
if !Crypto::Subtle.constant_time_compare(token["signature"].to_s, sign_token(key, token))
- raise translate(locale, "Invalid signature")
+ raise InfoException.new("Invalid signature")
end
if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time}))
if nonce[1] > Time.utc
db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0])
else
- raise translate(locale, "Erroneous token")
+ raise InfoException.new("Erroneous token")
end
end
diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr
index a51f15ce..f068b5f2 100644
--- a/src/invidious/helpers/utils.cr
+++ b/src/invidious/helpers/utils.cr
@@ -83,6 +83,7 @@ def make_client(url : URI, region = nil)
# TODO: Migrate any applicable endpoints to QUIC
client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure)
client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
+ client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds
@@ -100,6 +101,15 @@ def make_client(url : URI, region = nil)
return client
end
+def make_client(url : URI, region = nil, &block)
+ client = make_client(url, region)
+ begin
+ yield client
+ ensure
+ client.close
+ end
+end
+
def decode_length_seconds(string)
length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i
length_seconds = [0] * (3 - length_seconds.size) + length_seconds
@@ -360,7 +370,7 @@ def subscribe_pubsub(topic, key, config)
"hub.secret" => key.to_s,
}
- return make_client(PUBSUB_URL).post("/subscribe", form: body)
+ return make_client(PUBSUB_URL, &.post("/subscribe", form: body))
end
def parse_range(range)
diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr
index 8b69e01a..61f8eaf3 100644
--- a/src/invidious/jobs/bypass_captcha_job.cr
+++ b/src/invidious/jobs/bypass_captcha_job.cr
@@ -23,7 +23,8 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob
headers = response.cookies.add_request_headers(HTTP::Headers.new)
- response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/createTask", body: {
+ response = JSON.parse(HTTP::Client.post(config.captcha_api_url + "/createTask",
+ headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {
"clientKey" => config.captcha_key,
"task" => {
"type" => "NoCaptchaTaskProxyless",
@@ -39,7 +40,8 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob
loop do
sleep 10.seconds
- response = JSON.parse(HTTP::Client.post("https://api.anti-captcha.com/getTaskResult", body: {
+ response = JSON.parse(HTTP::Client.post(config.captcha_api_url + "/getTaskResult",
+ headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {
"clientKey" => config.captcha_key,
"taskId" => task_id,
}.to_json).body)
@@ -76,9 +78,10 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob
inputs[node["name"]] = node["value"]
end
- captcha_client = HTTPClient.new(URI.parse("https://api.anti-captcha.com"))
+ captcha_client = HTTPClient.new(URI.parse(config.captcha_api_url))
captcha_client.family = config.force_resolve || Socket::Family::INET
- response = JSON.parse(captcha_client.post("/createTask", body: {
+ response = JSON.parse(captcha_client.post("/createTask",
+ headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {
"clientKey" => config.captcha_key,
"task" => {
"type" => "NoCaptchaTaskProxyless",
@@ -88,13 +91,16 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob
},
}.to_json).body)
+ captcha_client.close
+
raise response["error"].as_s if response["error"]?
task_id = response["taskId"].as_i
loop do
sleep 10.seconds
- response = JSON.parse(captcha_client.post("/getTaskResult", body: {
+ response = JSON.parse(captcha_client.post("/getTaskResult",
+ headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {
"clientKey" => config.captcha_key,
"taskId" => task_id,
}.to_json).body)
@@ -121,7 +127,7 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob
end
end
rescue ex
- logger.puts("Exception: #{ex.message}")
+ logger.error("BypassCaptchaJob: #{ex.message}")
ensure
sleep 1.minute
Fiber.yield
diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr
index 75fc474d..6c858afa 100644
--- a/src/invidious/jobs/refresh_channels_job.cr
+++ b/src/invidious/jobs/refresh_channels_job.cr
@@ -7,37 +7,44 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
end
def begin
- max_threads = config.channel_threads
- lim_threads = max_threads
- active_threads = 0
+ max_fibers = config.channel_threads
+ lim_fibers = max_fibers
+ active_fibers = 0
active_channel = Channel(Bool).new
backoff = 1.seconds
loop do
+ logger.debug("RefreshChannelsJob: Refreshing all channels")
db.query("SELECT id FROM channels ORDER BY updated") do |rs|
rs.each do
id = rs.read(String)
- if active_threads >= lim_threads
+ if active_fibers >= lim_fibers
+ logger.trace("RefreshChannelsJob: Fiber limit reached, waiting...")
if active_channel.receive
- active_threads -= 1
+ logger.trace("RefreshChannelsJob: Fiber limit ok, continuing")
+ active_fibers -= 1
end
end
- active_threads += 1
+ logger.trace("RefreshChannelsJob: #{id} : Spawning fiber")
+ active_fibers += 1
spawn do
begin
- channel = fetch_channel(id, db, config.full_refresh)
+ logger.trace("RefreshChannelsJob: #{id} fiber : Fetching channel")
+ channel = fetch_channel(id, db, logger, config.full_refresh)
- lim_threads = max_threads
+ lim_fibers = max_fibers
+
+ logger.trace("RefreshChannelsJob: #{id} fiber : Updating DB")
db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id)
rescue ex
- logger.puts("#{id} : #{ex.message}")
+ logger.error("RefreshChannelsJob: #{id} : #{ex.message}")
if ex.message == "Deleted or invalid channel"
db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id)
else
- lim_threads = 1
- logger.puts("#{id} : backing off for #{backoff}s")
+ lim_fibers = 1
+ logger.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s")
sleep backoff
if backoff < 1.days
backoff += backoff
@@ -45,13 +52,15 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
backoff = 1.days
end
end
+ ensure
+ logger.trace("RefreshChannelsJob: #{id} fiber : Done")
+ active_channel.send(true)
end
-
- active_channel.send(true)
end
end
end
+ logger.debug("RefreshChannelsJob: Done, sleeping for one minute")
sleep 1.minute
Fiber.yield
end
diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr
index eebdf0f3..208569b8 100644
--- a/src/invidious/jobs/refresh_feeds_job.cr
+++ b/src/invidious/jobs/refresh_feeds_job.cr
@@ -7,8 +7,8 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
end
def begin
- max_threads = config.feed_threads
- active_threads = 0
+ max_fibers = config.feed_threads
+ active_fibers = 0
active_channel = Channel(Bool).new
loop do
@@ -17,27 +17,27 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
email = rs.read(String)
view_name = "subscriptions_#{sha256(email)}"
- if active_threads >= max_threads
+ if active_fibers >= max_fibers
if active_channel.receive
- active_threads -= 1
+ active_fibers -= 1
end
end
- active_threads += 1
+ active_fibers += 1
spawn do
begin
# Drop outdated views
column_array = get_column_array(db, view_name)
ChannelVideo.type_array.each_with_index do |name, i|
if name != column_array[i]?
- logger.puts("DROP MATERIALIZED VIEW #{view_name}")
+ logger.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}")
db.exec("DROP MATERIALIZED VIEW #{view_name}")
raise "view does not exist"
end
end
if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))"
- logger.puts("Materialized view #{view_name} is out-of-date, recreating...")
+ logger.info("RefreshFeedsJob: Materialized view #{view_name} is out-of-date, recreating...")
db.exec("DROP MATERIALIZED VIEW #{view_name}")
end
@@ -49,18 +49,18 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob
legacy_view_name = "subscriptions_#{sha256(email)[0..7]}"
db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0")
- logger.puts("RENAME MATERIALIZED VIEW #{legacy_view_name}")
+ logger.info("RefreshFeedsJob: RENAME MATERIALIZED VIEW #{legacy_view_name}")
db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}")
rescue ex
begin
# 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.puts("CREATE #{view_name}")
+ logger.info("RefreshFeedsJob: CREATE #{view_name}")
db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}")
db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email)
end
rescue ex
- logger.puts("REFRESH #{email} : #{ex.message}")
+ logger.error("RefreshFeedJobs: REFRESH #{email} : #{ex.message}")
end
end
end
diff --git a/src/invidious/jobs/subscribe_to_feeds_job.cr b/src/invidious/jobs/subscribe_to_feeds_job.cr
index 3d3b2218..2255730d 100644
--- a/src/invidious/jobs/subscribe_to_feeds_job.cr
+++ b/src/invidious/jobs/subscribe_to_feeds_job.cr
@@ -8,12 +8,12 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob
end
def begin
- max_threads = 1
+ max_fibers = 1
if config.use_pubsub_feeds.is_a?(Int32)
- max_threads = config.use_pubsub_feeds.as(Int32)
+ max_fibers = config.use_pubsub_feeds.as(Int32)
end
- active_threads = 0
+ active_fibers = 0
active_channel = Channel(Bool).new
loop do
@@ -21,23 +21,23 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob
rs.each do
ucid = rs.read(String)
- if active_threads >= max_threads.as(Int32)
+ if active_fibers >= max_fibers.as(Int32)
if active_channel.receive
- active_threads -= 1
+ active_fibers -= 1
end
end
- active_threads += 1
+ active_fibers += 1
spawn do
begin
response = subscribe_pubsub(ucid, hmac_key, config)
if response.status_code >= 400
- logger.puts("#{ucid} : #{response.body}")
+ logger.error("SubscribeToFeedsJob: #{ucid} : #{response.body}")
end
rescue ex
- logger.puts("#{ucid} : #{ex.message}")
+ logger.error("SubscribeToFeedsJob: #{ucid} : #{ex.message}")
end
active_channel.send(true)
diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr
index c69eb0c4..55b01174 100644
--- a/src/invidious/mixes.cr
+++ b/src/invidious/mixes.cr
@@ -30,7 +30,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
initial_data = extract_initial_data(response.body)
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?
- raise translate(locale, "Could not create mix.")
+ raise InfoException.new("Could not create mix.")
end
playlist = initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index c984a12a..d5b41caa 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -220,6 +220,11 @@ struct InvidiousPlaylist
json.field "videos" do
json.array do
+ if !offset || offset == 0
+ index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, continuation, as: Int64)
+ offset = self.index.index(index) || 0
+ end
+
videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation)
videos.each_with_index do |video, index|
video.to_json(locale, json, offset + index)
@@ -338,7 +343,7 @@ def get_playlist(db, plid, locale, refresh = true, force_refresh = false)
if playlist = db.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
return playlist
else
- raise "Playlist does not exist."
+ raise InfoException.new("Playlist does not exist.")
end
else
return fetch_playlist(plid, locale)
@@ -353,16 +358,16 @@ def fetch_playlist(plid, locale)
response = YT_POOL.client &.get("/playlist?list=#{plid}&hl=en")
if response.status_code != 200
if response.headers["location"]?.try &.includes? "/sorry/index"
- raise "Could not extract playlist info. Instance is likely blocked."
+ raise InfoException.new("Could not extract playlist info. Instance is likely blocked.")
else
- raise translate(locale, "Not a playlist.")
+ raise InfoException.new("Not a playlist.")
end
end
initial_data = extract_initial_data(response.body)
playlist_info = initial_data["sidebar"]?.try &.["playlistSidebarRenderer"]?.try &.["items"]?.try &.[0]["playlistSidebarPrimaryInfoRenderer"]?
- raise "Could not extract playlist info" if !playlist_info
+ raise InfoException.new("Could not extract playlist info") if !playlist_info
title = playlist_info["title"]?.try &.["runs"][0]?.try &.["text"]?.try &.as_s || ""
desc_item = playlist_info["description"]?
@@ -390,7 +395,7 @@ def fetch_playlist(plid, locale)
author_info = initial_data["sidebar"]?.try &.["playlistSidebarRenderer"]?.try &.["items"]?.try &.[1]["playlistSidebarSecondaryInfoRenderer"]?
.try &.["videoOwner"]["videoOwnerRenderer"]?
- raise "Could not extract author info" if !author_info
+ raise InfoException.new("Could not extract author info") if !author_info
author_thumbnail = author_info["thumbnail"]["thumbnails"][0]["url"]?.try &.as_s || ""
author = author_info["title"]["runs"][0]["text"]?.try &.as_s || ""
@@ -412,11 +417,6 @@ end
def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil)
if playlist.is_a? InvidiousPlaylist
- if !offset
- index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", playlist.id, continuation, as: Int64)
- offset = playlist.index.index(index) || 0
- end
-
db.query_all("SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3", playlist.id, playlist.index, offset, as: PlaylistVideo)
else
fetch_playlist_videos(playlist.id, playlist.video_count, offset, locale, continuation)
diff --git a/src/invidious/routes/base_route.cr b/src/invidious/routes/base_route.cr
index c6e6667e..2852cb04 100644
--- a/src/invidious/routes/base_route.cr
+++ b/src/invidious/routes/base_route.cr
@@ -4,6 +4,4 @@ abstract class Invidious::Routes::BaseRoute
def initialize(@config, @logger)
end
-
- abstract def handle(env)
end
diff --git a/src/invidious/routes/embed/index.cr b/src/invidious/routes/embed/index.cr
index 79c91d86..32a4966b 100644
--- a/src/invidious/routes/embed/index.cr
+++ b/src/invidious/routes/embed/index.cr
@@ -8,9 +8,7 @@ class Invidious::Routes::Embed::Index < Invidious::Routes::BaseRoute
offset = env.params.query["index"]?.try &.to_i? || 0
videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale)
rescue ex
- error_message = ex.message
- env.response.status_code = 500
- return templated "error"
+ return error_template(500, ex)
end
url = "/embed/#{videos[0].id}?#{env.params.query}"
diff --git a/src/invidious/routes/embed/show.cr b/src/invidious/routes/embed/show.cr
index 23c2b86f..8a655556 100644
--- a/src/invidious/routes/embed/show.cr
+++ b/src/invidious/routes/embed/show.cr
@@ -38,9 +38,7 @@ class Invidious::Routes::Embed::Show < Invidious::Routes::BaseRoute
offset = env.params.query["index"]?.try &.to_i? || 0
videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale)
rescue ex
- error_message = ex.message
- env.response.status_code = 500
- return templated "error"
+ return error_template(500, ex)
end
url = "/embed/#{videos[0].id}"
@@ -63,8 +61,7 @@ class Invidious::Routes::Embed::Show < Invidious::Routes::BaseRoute
env.params.query.delete_all("channel")
if !video_id || video_id == "live_stream"
- error_message = "Video is unavailable."
- return templated "error"
+ return error_template(500, "Video is unavailable.")
end
url = "/embed/#{video_id}"
@@ -100,9 +97,7 @@ class Invidious::Routes::Embed::Show < Invidious::Routes::BaseRoute
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex
- error_message = ex.message
- env.response.status_code = 500
- return templated "error"
+ return error_template(500, ex)
end
if preferences.annotations_subscribed &&
diff --git a/src/invidious/routes/home.cr b/src/invidious/routes/home.cr
index 9b1bf61b..486a7344 100644
--- a/src/invidious/routes/home.cr
+++ b/src/invidious/routes/home.cr
@@ -5,30 +5,24 @@ class Invidious::Routes::Home < Invidious::Routes::BaseRoute
user = env.get? "user"
case preferences.default_home
- when ""
- templated "empty"
when "Popular"
- templated "popular"
+ env.redirect "/feed/popular"
when "Trending"
env.redirect "/feed/trending"
when "Subscriptions"
if user
env.redirect "/feed/subscriptions"
else
- templated "popular"
+ env.redirect "/feed/popular"
end
when "Playlists"
if user
env.redirect "/view_all_playlists"
else
- templated "popular"
+ env.redirect "/feed/popular"
end
else
templated "empty"
end
end
-
- private def popular_videos
- Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get
- end
end
diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr
new file mode 100644
index 00000000..42fb4676
--- /dev/null
+++ b/src/invidious/routes/login.cr
@@ -0,0 +1,508 @@
+class Invidious::Routes::Login < Invidious::Routes::BaseRoute
+ def login_page(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+
+ return env.redirect "/feed/subscriptions" if user
+
+ if !config.login_enabled
+ return error_template(400, "Login has been disabled by administrator.")
+ end
+
+ referer = get_referer(env, "/feed/subscriptions")
+
+ email = nil
+ password = nil
+ captcha = nil
+
+ account_type = env.params.query["type"]?
+ account_type ||= "invidious"
+
+ captcha_type = env.params.query["captcha"]?
+ captcha_type ||= "image"
+
+ tfa = env.params.query["tfa"]?
+ prompt = nil
+
+ templated "login"
+ end
+
+ def login(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ referer = get_referer(env, "/feed/subscriptions")
+
+ if !config.login_enabled
+ return error_template(403, "Login has been disabled by administrator.")
+ end
+
+ # https://stackoverflow.com/a/574698
+ email = env.params.body["email"]?.try &.downcase.byte_slice(0, 254)
+ password = env.params.body["password"]?
+
+ account_type = env.params.query["type"]?
+ account_type ||= "invidious"
+
+ case account_type
+ when "google"
+ tfa_code = env.params.body["tfa"]?.try &.lchop("G-")
+ traceback = IO::Memory.new
+
+ # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82
+ begin
+ client = QUIC::Client.new(LOGIN_URL)
+ headers = HTTP::Headers.new
+
+ login_page = client.get("/ServiceLogin")
+ headers = login_page.cookies.add_request_headers(headers)
+
+ lookup_req = {
+ email, nil, [] of String, nil, "US", nil, nil, 2, false, true,
+ {nil, nil,
+ {2, 1, nil, 1,
+ "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn",
+ nil, [] of String, 4},
+ 1,
+ {nil, nil, [] of String},
+ nil, nil, nil, true,
+ },
+ email,
+ }.to_json
+
+ traceback << "Getting lookup..."
+
+ headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8"
+ headers["Google-Accounts-XSRF"] = "1"
+
+ response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req))
+ lookup_results = JSON.parse(response.body[5..-1])
+
+ traceback << "done, returned #{response.status_code}.<br/>"
+
+ user_hash = lookup_results[0][2]
+
+ if token = env.params.body["token"]?
+ answer = env.params.body["answer"]?
+ captcha = {token, answer}
+ else
+ captcha = nil
+ end
+
+ challenge_req = {
+ user_hash, nil, 1, nil,
+ {1, nil, nil, nil,
+ {password, captcha, true},
+ },
+ {nil, nil,
+ {2, 1, nil, 1,
+ "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn",
+ nil, [] of String, 4},
+ 1,
+ {nil, nil, [] of String},
+ nil, nil, nil, true,
+ },
+ }.to_json
+
+ traceback << "Getting challenge..."
+
+ response = client.post("/_/signin/sl/challenge", headers, login_req(challenge_req))
+ headers = response.cookies.add_request_headers(headers)
+ challenge_results = JSON.parse(response.body[5..-1])
+
+ traceback << "done, returned #{response.status_code}.<br/>"
+
+ headers["Cookie"] = URI.decode_www_form(headers["Cookie"])
+
+ if challenge_results[0][3]?.try &.== 7
+ return error_template(423, "Account has temporarily been disabled")
+ end
+
+ if token = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a?.try &.[-1].as_s
+ account_type = "google"
+ captcha_type = "image"
+ prompt = nil
+ tfa = tfa_code
+ captcha = {tokens: [token], question: ""}
+
+ return templated "login"
+ end
+
+ if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED"
+ return error_template(401, "Incorrect password")
+ end
+
+ prompt_type = challenge_results[0][-1]?.try &.[0].as_a?.try &.[0][2]?
+ if {"TWO_STEP_VERIFICATION", "LOGIN_CHALLENGE"}.includes? prompt_type
+ traceback << "Handling prompt #{prompt_type}.<br/>"
+ case prompt_type
+ when "TWO_STEP_VERIFICATION"
+ prompt_type = 2
+ else # "LOGIN_CHALLENGE"
+ prompt_type = 4
+ end
+
+ # Prefer Authenticator app and SMS over unsupported protocols
+ if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2
+ tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0]
+
+ traceback << "Selecting challenge #{tfa[8]}..."
+ select_challenge = {prompt_type, nil, nil, nil, {tfa[8]}}.to_json
+
+ tl = challenge_results[1][2]
+
+ tfa = client.post("/_/signin/selectchallenge?TL=#{tl}", headers, login_req(select_challenge)).body
+ tfa = tfa[5..-1]
+ tfa = JSON.parse(tfa)[0][-1]
+
+ traceback << "done.<br/>"
+ else
+ traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.<br/>"
+ tfa = challenge_results[0][-1][0][0]
+ end
+
+ if tfa[5] == "QUOTA_EXCEEDED"
+ return error_template(423, "Quota exceeded, try again in a few hours")
+ end
+
+ if !tfa_code
+ account_type = "google"
+ captcha_type = "image"
+
+ case tfa[8]
+ when 6, 9
+ prompt = "Google verification code"
+ when 12
+ prompt = "Login verification, recovery email: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}"
+ when 15
+ prompt = "Login verification, security question: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}"
+ else
+ prompt = "Google verification code"
+ end
+
+ tfa = nil
+ captcha = nil
+ return templated "login"
+ end
+
+ tl = challenge_results[1][2]
+
+ request_type = tfa[8]
+ case request_type
+ when 6 # Authenticator app
+ tfa_req = {
+ user_hash, nil, 2, nil,
+ {6, nil, nil, nil, nil,
+ {tfa_code, false},
+ },
+ }.to_json
+ when 9 # Voice or text message
+ tfa_req = {
+ user_hash, nil, 2, nil,
+ {9, nil, nil, nil, nil, nil, nil, nil,
+ {nil, tfa_code, false, 2},
+ },
+ }.to_json
+ when 12 # Recovery email
+ tfa_req = {
+ user_hash, nil, 4, nil,
+ {12, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
+ {tfa_code},
+ },
+ }.to_json
+ when 15 # Security question
+ tfa_req = {
+ user_hash, nil, 5, nil,
+ {15, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
+ {tfa_code},
+ },
+ }.to_json
+ else
+ return error_template(500, "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.")
+ end
+
+ traceback << "Submitting challenge..."
+
+ response = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(tfa_req))
+ headers = response.cookies.add_request_headers(headers)
+ challenge_results = JSON.parse(response.body[5..-1])
+
+ if (challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED") ||
+ (challenge_results[0][-1]?.try &.[5] == "INVALID_INPUT")
+ return error_template(401, "Invalid TFA code")
+ end
+
+ traceback << "done.<br/>"
+ end
+
+ traceback << "Logging in..."
+
+ location = URI.parse(challenge_results[0][-1][2].to_s)
+ cookies = HTTP::Cookies.from_headers(headers)
+
+ headers.delete("Content-Type")
+ headers.delete("Google-Accounts-XSRF")
+
+ loop do
+ if !location || location.path == "/ManageAccount"
+ break
+ end
+
+ # Occasionally there will be a second page after login confirming
+ # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle.
+
+ if location.path.starts_with? "/b/0/SmsAuthInterstitial"
+ traceback << "Unhandled dialog /b/0/SmsAuthInterstitial."
+ end
+
+ login = client.get(location.full_path, headers)
+
+ headers = login.cookies.add_request_headers(headers)
+ location = login.headers["Location"]?.try { |u| URI.parse(u) }
+ end
+
+ cookies = HTTP::Cookies.from_headers(headers)
+ sid = cookies["SID"]?.try &.value
+ if !sid
+ raise "Couldn't get SID."
+ end
+
+ user, sid = get_user(sid, headers, PG_DB, logger)
+
+ # We are now logged in
+ traceback << "done.<br/>"
+
+ host = URI.parse(env.request.headers["Host"]).host
+
+ if Kemal.config.ssl || config.https_only
+ secure = true
+ else
+ secure = false
+ end
+
+ cookies.each do |cookie|
+ if Kemal.config.ssl || config.https_only
+ cookie.secure = secure
+ else
+ cookie.secure = secure
+ end
+
+ if cookie.extension
+ cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host)
+ cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "")
+ end
+ env.response.cookies << cookie
+ end
+
+ if env.request.cookies["PREFS"]?
+ preferences = env.get("preferences").as(Preferences)
+ PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
+
+ cookie = env.request.cookies["PREFS"]
+ cookie.expires = Time.utc(1990, 1, 1)
+ env.response.cookies << cookie
+ end
+
+ env.redirect referer
+ rescue ex
+ traceback.rewind
+ # error_message = translate(locale, "Login failed. This may be because two-factor authentication is not turned on for your account.")
+ error_message = %(#{ex.message}<br/>Traceback:<br/><div style="padding-left:2em" id="traceback">#{traceback.gets_to_end}</div>)
+ return error_template(500, error_message)
+ end
+ when "invidious"
+ if !email
+ return error_template(401, "User ID is a required field")
+ end
+
+ if !password
+ return error_template(401, "Password is a required field")
+ end
+
+ user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User)
+
+ if user
+ if !user.password
+ return error_template(400, "Please sign in using 'Log in with Google'")
+ end
+
+ if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
+ sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
+ PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc)
+
+ if Kemal.config.ssl || config.https_only
+ secure = true
+ else
+ secure = false
+ end
+
+ if config.domain
+ env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{config.domain}", value: sid, expires: Time.utc + 2.years,
+ secure: secure, http_only: true)
+ else
+ env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
+ secure: secure, http_only: true)
+ end
+ else
+ return error_template(401, "Wrong username or password")
+ end
+
+ # Since this user has already registered, we don't want to overwrite their preferences
+ if env.request.cookies["PREFS"]?
+ cookie = env.request.cookies["PREFS"]
+ cookie.expires = Time.utc(1990, 1, 1)
+ env.response.cookies << cookie
+ end
+ else
+ if !config.registration_enabled
+ return error_template(400, "Registration has been disabled by administrator.")
+ end
+
+ if password.empty?
+ return 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")
+ end
+
+ password = password.byte_slice(0, 55)
+
+ if config.captcha_enabled
+ captcha_type = env.params.body["captcha_type"]?
+ answer = env.params.body["answer"]?
+ change_type = env.params.body["change_type"]?
+
+ if !captcha_type || change_type
+ if change_type
+ captcha_type = change_type
+ end
+ captcha_type ||= "image"
+
+ account_type = "invidious"
+ tfa = false
+ prompt = ""
+
+ if captcha_type == "image"
+ captcha = generate_captcha(HMAC_KEY, PG_DB)
+ else
+ captcha = generate_text_captcha(HMAC_KEY, PG_DB)
+ end
+
+ return templated "login"
+ end
+
+ tokens = env.params.body.select { |k, v| k.match(/^token\[\d+\]$/) }.map { |k, v| v }
+
+ answer ||= ""
+ captcha_type ||= "image"
+
+ case captcha_type
+ when "image"
+ answer = answer.lstrip('0')
+ answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
+
+ begin
+ validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale)
+ rescue ex
+ return error_template(400, ex)
+ end
+ else # "text"
+ answer = Digest::MD5.hexdigest(answer.downcase.strip)
+
+ if tokens.empty?
+ return error_template(500, "Erroneous CAPTCHA")
+ end
+
+ found_valid_captcha = false
+ error_exception = Exception.new
+ tokens.each_with_index do |token, i|
+ begin
+ validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale)
+ found_valid_captcha = true
+ rescue ex
+ error_exception = ex
+ end
+ end
+
+ if !found_valid_captcha
+ return error_template(500, error_exception)
+ end
+ end
+ end
+
+ sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
+ user, sid = create_user(sid, email, password)
+ user_array = user.to_a
+ user_array[4] = user_array[4].to_json # User preferences
+
+ args = arg_array(user_array)
+
+ PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array)
+ PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc)
+
+ view_name = "subscriptions_#{sha256(user.email)}"
+ PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
+
+ if Kemal.config.ssl || config.https_only
+ secure = true
+ else
+ secure = false
+ end
+
+ if config.domain
+ env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{config.domain}", value: sid, expires: Time.utc + 2.years,
+ secure: secure, http_only: true)
+ else
+ env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
+ secure: secure, http_only: true)
+ end
+
+ if env.request.cookies["PREFS"]?
+ preferences = env.get("preferences").as(Preferences)
+ PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
+
+ cookie = env.request.cookies["PREFS"]
+ cookie.expires = Time.utc(1990, 1, 1)
+ env.response.cookies << cookie
+ end
+ end
+
+ env.redirect referer
+ else
+ env.redirect referer
+ end
+ end
+
+ def signout(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ if !user
+ return env.redirect referer
+ end
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ rescue ex
+ return error_template(400, ex)
+ end
+
+ PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid)
+
+ env.request.cookies.each do |cookie|
+ cookie.expires = Time.utc(1990, 1, 1)
+ env.response.cookies << cookie
+ end
+
+ env.redirect referer
+ end
+end
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
new file mode 100644
index 00000000..6c899054
--- /dev/null
+++ b/src/invidious/routes/playlists.cr
@@ -0,0 +1,472 @@
+class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute
+ def index(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+
+ items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist)
+ items_created.map! do |item|
+ item.author = ""
+ item
+ end
+
+ items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist)
+ items_saved.map! do |item|
+ item.author = ""
+ item
+ end
+
+ templated "view_all_playlists"
+ end
+
+ def new(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+ csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB)
+
+ templated "create_playlist"
+ end
+
+ def create(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ rescue ex
+ return 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.")
+ end
+
+ privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "")
+ if !privacy
+ return error_template(400, "Invalid privacy setting.")
+ end
+
+ if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100
+ return error_template(400, "User cannot have more than 100 playlists.")
+ end
+
+ playlist = create_playlist(PG_DB, title, privacy, user)
+
+ env.redirect "/playlist?list=#{playlist.id}"
+ end
+
+ def subscribe(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+
+ playlist_id = env.params.query["list"]
+ playlist = get_playlist(PG_DB, playlist_id, locale)
+ subscribe_playlist(PG_DB, user, playlist)
+
+ env.redirect "/playlist?list=#{playlist.id}"
+ end
+
+ def delete_page(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+
+ plid = env.params.query["list"]?
+ playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ if !playlist || playlist.author != user.email
+ return env.redirect referer
+ end
+
+ csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB)
+
+ templated "delete_playlist"
+ end
+
+ def delete(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ plid = env.params.query["list"]?
+ return env.redirect referer if plid.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ rescue ex
+ return error_template(400, ex)
+ end
+
+ playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ if !playlist || playlist.author != user.email
+ return env.redirect referer
+ end
+
+ PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid)
+ PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid)
+
+ env.redirect "/view_all_playlists"
+ end
+
+ def edit(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+
+ plid = env.params.query["list"]?
+ if !plid || !plid.starts_with?("IV")
+ return env.redirect referer
+ end
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ begin
+ playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ if !playlist || playlist.author != user.email
+ return env.redirect referer
+ end
+ rescue ex
+ return env.redirect referer
+ end
+
+ begin
+ videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale)
+ rescue ex
+ videos = [] of PlaylistVideo
+ end
+
+ csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB)
+
+ templated "edit_playlist"
+ end
+
+ def update(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ plid = env.params.query["list"]?
+ return env.redirect referer if plid.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ rescue ex
+ return error_template(400, ex)
+ end
+
+ playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ if !playlist || playlist.author != user.email
+ return env.redirect referer
+ end
+
+ title = env.params.body["title"]?.try &.delete("<>") || ""
+ privacy = PlaylistPrivacy.parse(env.params.body["privacy"]? || "Public")
+ description = env.params.body["description"]?.try &.delete("\r") || ""
+
+ if title != playlist.title ||
+ privacy != playlist.privacy ||
+ description != playlist.description
+ updated = Time.utc
+ else
+ updated = playlist.updated
+ end
+
+ PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid)
+
+ env.redirect "/playlist?list=#{plid}"
+ end
+
+ def add_playlist_items_page(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+
+ plid = env.params.query["list"]?
+ if !plid || !plid.starts_with?("IV")
+ return env.redirect referer
+ end
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ begin
+ playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
+ if !playlist || playlist.author != user.email
+ return env.redirect referer
+ end
+ rescue ex
+ return env.redirect referer
+ end
+
+ query = env.params.query["q"]?
+ if query
+ begin
+ search_query, count, items = process_search_query(query, page, user, region: nil)
+ videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) }
+ rescue ex
+ videos = [] of SearchVideo
+ count = 0
+ end
+ else
+ videos = [] of SearchVideo
+ count = 0
+ end
+
+ env.set "add_playlist_items", plid
+ templated "add_playlist_items"
+ end
+
+ def playlist_ajax(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env, "/")
+
+ redirect = env.params.query["redirect"]?
+ redirect ||= "true"
+ redirect = redirect == "true"
+
+ if !user
+ if redirect
+ return env.redirect referer
+ else
+ return error_json(403, "No such user")
+ end
+ end
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale)
+ rescue ex
+ if redirect
+ return error_template(400, ex)
+ else
+ return error_json(400, ex)
+ end
+ end
+
+ if env.params.query["action_create_playlist"]?
+ action = "action_create_playlist"
+ elsif env.params.query["action_delete_playlist"]?
+ action = "action_delete_playlist"
+ elsif env.params.query["action_edit_playlist"]?
+ action = "action_edit_playlist"
+ elsif env.params.query["action_add_video"]?
+ action = "action_add_video"
+ video_id = env.params.query["video_id"]
+ elsif env.params.query["action_remove_video"]?
+ action = "action_remove_video"
+ elsif env.params.query["action_move_video_before"]?
+ action = "action_move_video_before"
+ else
+ return env.redirect referer
+ end
+
+ begin
+ playlist_id = env.params.query["playlist_id"]
+ playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist)
+ raise "Invalid user" if playlist.author != user.email
+ rescue ex
+ if redirect
+ return error_template(400, ex)
+ else
+ return error_json(400, ex)
+ end
+ end
+
+ if !user.password
+ # TODO: Playlist stub, sync with YouTube for Google accounts
+ # playlist_ajax(playlist_id, action, env.request.headers)
+ end
+ email = user.email
+
+ case action
+ when "action_edit_playlist"
+ # TODO: Playlist stub
+ when "action_add_video"
+ if playlist.index.size >= 500
+ if redirect
+ return error_template(400, "Playlist cannot have more than 500 videos")
+ else
+ return error_json(400, "Playlist cannot have more than 500 videos")
+ end
+ end
+
+ video_id = env.params.query["video_id"]
+
+ begin
+ video = get_video(video_id, PG_DB)
+ rescue ex
+ if redirect
+ return error_template(500, ex)
+ else
+ return error_json(500, ex)
+ end
+ end
+
+ playlist_video = PlaylistVideo.new({
+ title: video.title,
+ id: video.id,
+ author: video.author,
+ ucid: video.ucid,
+ length_seconds: video.length_seconds,
+ published: video.published,
+ plid: playlist_id,
+ live_now: video.live_now,
+ index: Random::Secure.rand(0_i64..Int64::MAX),
+ })
+
+ video_array = playlist_video.to_a
+ args = arg_array(video_array)
+
+ PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array)
+ PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id)
+ when "action_remove_video"
+ index = env.params.query["set_video_id"]
+ PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index)
+ PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id)
+ when "action_move_video_before"
+ # TODO: Playlist stub
+ else
+ return error_json(400, "Unsupported action #{action}")
+ end
+
+ if redirect
+ env.redirect referer
+ else
+ env.response.content_type = "application/json"
+ "{}"
+ end
+ end
+
+ def show(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ user = env.get?("user").try &.as(User)
+ referer = get_referer(env)
+
+ plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
+ if !plid
+ return env.redirect "/"
+ end
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ if plid.starts_with? "RD"
+ return env.redirect "/mix?list=#{plid}"
+ end
+
+ begin
+ playlist = get_playlist(PG_DB, plid, locale)
+ rescue ex
+ return error_template(500, ex)
+ end
+
+ if playlist.privacy == PlaylistPrivacy::Private && playlist.author != user.try &.email
+ return error_template(403, "This playlist is private.")
+ end
+
+ begin
+ videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale)
+ rescue ex
+ videos = [] of PlaylistVideo
+ end
+
+ if playlist.author == user.try &.email
+ env.set "remove_playlist_items", plid
+ end
+
+ templated "playlist"
+ end
+
+ def mix(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ rdid = env.params.query["list"]?
+ if !rdid
+ return env.redirect "/"
+ end
+
+ continuation = env.params.query["continuation"]?
+ continuation ||= rdid.lchop("RD")
+
+ begin
+ mix = fetch_mix(rdid, continuation, locale: locale)
+ rescue ex
+ return error_template(500, ex)
+ end
+
+ templated "mix"
+ end
+end
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
new file mode 100644
index 00000000..48446161
--- /dev/null
+++ b/src/invidious/routes/search.cr
@@ -0,0 +1,59 @@
+class Invidious::Routes::Search < Invidious::Routes::BaseRoute
+ def opensearch(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ env.response.content_type = "application/opensearchdescription+xml"
+
+ XML.build(indent: " ", encoding: "UTF-8") do |xml|
+ xml.element("OpenSearchDescription", xmlns: "http://a9.com/-/spec/opensearch/1.1/") do
+ xml.element("ShortName") { xml.text "Invidious" }
+ xml.element("LongName") { xml.text "Invidious Search" }
+ xml.element("Description") { xml.text "Search for videos, channels, and playlists on Invidious" }
+ xml.element("InputEncoding") { xml.text "UTF-8" }
+ xml.element("Image", width: 48, height: 48, type: "image/x-icon") { xml.text "#{HOST_URL}/favicon.ico" }
+ xml.element("Url", type: "text/html", method: "get", template: "#{HOST_URL}/search?q={searchTerms}")
+ end
+ end
+ end
+
+ def results(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ query = env.params.query["search_query"]?
+ query ||= env.params.query["q"]?
+ query ||= ""
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ if query
+ env.redirect "/search?q=#{URI.encode_www_form(query)}&page=#{page}"
+ else
+ env.redirect "/"
+ end
+ end
+
+ def search(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ region = env.params.query["region"]?
+
+ query = env.params.query["search_query"]?
+ query ||= env.params.query["q"]?
+ query ||= ""
+
+ return env.redirect "/" if query.empty?
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ user = env.get? "user"
+
+ begin
+ search_query, count, videos = process_search_query(query, page, user, region: nil)
+ rescue ex
+ return error_template(500, ex)
+ end
+
+ env.set "search", query
+ templated "search"
+ end
+end
diff --git a/src/invidious/routes/user_preferences.cr b/src/invidious/routes/user_preferences.cr
new file mode 100644
index 00000000..7f334115
--- /dev/null
+++ b/src/invidious/routes/user_preferences.cr
@@ -0,0 +1,259 @@
+class Invidious::Routes::UserPreferences < Invidious::Routes::BaseRoute
+ def show(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+
+ referer = get_referer(env)
+
+ preferences = env.get("preferences").as(Preferences)
+
+ templated "preferences"
+ end
+
+ def update(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ referer = get_referer(env)
+
+ video_loop = env.params.body["video_loop"]?.try &.as(String)
+ video_loop ||= "off"
+ video_loop = video_loop == "on"
+
+ annotations = env.params.body["annotations"]?.try &.as(String)
+ annotations ||= "off"
+ annotations = annotations == "on"
+
+ annotations_subscribed = env.params.body["annotations_subscribed"]?.try &.as(String)
+ annotations_subscribed ||= "off"
+ annotations_subscribed = annotations_subscribed == "on"
+
+ autoplay = env.params.body["autoplay"]?.try &.as(String)
+ autoplay ||= "off"
+ autoplay = autoplay == "on"
+
+ continue = env.params.body["continue"]?.try &.as(String)
+ continue ||= "off"
+ continue = continue == "on"
+
+ continue_autoplay = env.params.body["continue_autoplay"]?.try &.as(String)
+ continue_autoplay ||= "off"
+ continue_autoplay = continue_autoplay == "on"
+
+ listen = env.params.body["listen"]?.try &.as(String)
+ listen ||= "off"
+ listen = listen == "on"
+
+ local = env.params.body["local"]?.try &.as(String)
+ local ||= "off"
+ local = local == "on"
+
+ speed = env.params.body["speed"]?.try &.as(String).to_f32?
+ speed ||= CONFIG.default_user_preferences.speed
+
+ player_style = env.params.body["player_style"]?.try &.as(String)
+ player_style ||= CONFIG.default_user_preferences.player_style
+
+ quality = env.params.body["quality"]?.try &.as(String)
+ quality ||= CONFIG.default_user_preferences.quality
+
+ quality_dash = env.params.body["quality_dash"]?.try &.as(String)
+ quality_dash ||= CONFIG.default_user_preferences.quality_dash
+
+ volume = env.params.body["volume"]?.try &.as(String).to_i?
+ volume ||= CONFIG.default_user_preferences.volume
+
+ comments = [] of String
+ 2.times do |i|
+ comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i])
+ end
+
+ captions = [] of String
+ 3.times do |i|
+ captions << (env.params.body["captions[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.captions[i])
+ end
+
+ related_videos = env.params.body["related_videos"]?.try &.as(String)
+ related_videos ||= "off"
+ related_videos = related_videos == "on"
+
+ default_home = env.params.body["default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home
+
+ feed_menu = [] of String
+ 4.times do |index|
+ option = env.params.body["feed_menu[#{index}]"]?.try &.as(String) || ""
+ if !option.empty?
+ feed_menu << option
+ end
+ end
+
+ locale = env.params.body["locale"]?.try &.as(String)
+ locale ||= CONFIG.default_user_preferences.locale
+
+ dark_mode = env.params.body["dark_mode"]?.try &.as(String)
+ dark_mode ||= CONFIG.default_user_preferences.dark_mode
+
+ thin_mode = env.params.body["thin_mode"]?.try &.as(String)
+ thin_mode ||= "off"
+ thin_mode = thin_mode == "on"
+
+ max_results = env.params.body["max_results"]?.try &.as(String).to_i?
+ max_results ||= CONFIG.default_user_preferences.max_results
+
+ sort = env.params.body["sort"]?.try &.as(String)
+ sort ||= CONFIG.default_user_preferences.sort
+
+ latest_only = env.params.body["latest_only"]?.try &.as(String)
+ latest_only ||= "off"
+ latest_only = latest_only == "on"
+
+ unseen_only = env.params.body["unseen_only"]?.try &.as(String)
+ unseen_only ||= "off"
+ unseen_only = unseen_only == "on"
+
+ notifications_only = env.params.body["notifications_only"]?.try &.as(String)
+ notifications_only ||= "off"
+ notifications_only = notifications_only == "on"
+
+ # Convert to JSON and back again to take advantage of converters used for compatability
+ preferences = Preferences.from_json({
+ annotations: annotations,
+ annotations_subscribed: annotations_subscribed,
+ autoplay: autoplay,
+ captions: captions,
+ comments: comments,
+ continue: continue,
+ continue_autoplay: continue_autoplay,
+ dark_mode: dark_mode,
+ latest_only: latest_only,
+ listen: listen,
+ local: local,
+ locale: locale,
+ max_results: max_results,
+ notifications_only: notifications_only,
+ player_style: player_style,
+ quality: quality,
+ quality_dash: quality_dash,
+ default_home: default_home,
+ feed_menu: feed_menu,
+ related_videos: related_videos,
+ sort: sort,
+ speed: speed,
+ thin_mode: thin_mode,
+ unseen_only: unseen_only,
+ video_loop: video_loop,
+ volume: volume,
+ }.to_json).to_json
+
+ if user = env.get? "user"
+ user = user.as(User)
+ PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email)
+
+ if config.admins.includes? user.email
+ config.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || config.default_user_preferences.default_home
+
+ admin_feed_menu = [] of String
+ 4.times do |index|
+ option = env.params.body["admin_feed_menu[#{index}]"]?.try &.as(String) || ""
+ if !option.empty?
+ admin_feed_menu << option
+ end
+ end
+ config.default_user_preferences.feed_menu = admin_feed_menu
+
+ popular_enabled = env.params.body["popular_enabled"]?.try &.as(String)
+ popular_enabled ||= "off"
+ config.popular_enabled = popular_enabled == "on"
+
+ captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String)
+ captcha_enabled ||= "off"
+ config.captcha_enabled = captcha_enabled == "on"
+
+ login_enabled = env.params.body["login_enabled"]?.try &.as(String)
+ login_enabled ||= "off"
+ config.login_enabled = login_enabled == "on"
+
+ registration_enabled = env.params.body["registration_enabled"]?.try &.as(String)
+ registration_enabled ||= "off"
+ config.registration_enabled = registration_enabled == "on"
+
+ statistics_enabled = env.params.body["statistics_enabled"]?.try &.as(String)
+ statistics_enabled ||= "off"
+ config.statistics_enabled = statistics_enabled == "on"
+
+ CONFIG.default_user_preferences = config.default_user_preferences
+ File.write("config/config.yml", config.to_yaml)
+ end
+ else
+ if Kemal.config.ssl || config.https_only
+ secure = true
+ else
+ secure = false
+ end
+
+ if config.domain
+ env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{config.domain}", value: preferences, expires: Time.utc + 2.years,
+ secure: secure, http_only: true)
+ else
+ env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years,
+ secure: secure, http_only: true)
+ end
+ end
+
+ env.redirect referer
+ end
+
+ def toggle_theme(env)
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
+ referer = get_referer(env, unroll: false)
+
+ redirect = env.params.query["redirect"]?
+ redirect ||= "true"
+ redirect = redirect == "true"
+
+ if user = env.get? "user"
+ user = user.as(User)
+ preferences = user.preferences
+
+ case preferences.dark_mode
+ when "dark"
+ preferences.dark_mode = "light"
+ else
+ preferences.dark_mode = "dark"
+ end
+
+ preferences = preferences.to_json
+
+ PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email)
+ else
+ preferences = env.get("preferences").as(Preferences)
+
+ case preferences.dark_mode
+ when "dark"
+ preferences.dark_mode = "light"
+ else
+ preferences.dark_mode = "dark"
+ end
+
+ preferences = preferences.to_json
+
+ if Kemal.config.ssl || config.https_only
+ secure = true
+ else
+ secure = false
+ end
+
+ if config.domain
+ env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{config.domain}", value: preferences, expires: Time.utc + 2.years,
+ secure: secure, http_only: true)
+ else
+ env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years,
+ secure: secure, http_only: true)
+ end
+ end
+
+ if redirect
+ env.redirect referer
+ else
+ env.response.content_type = "application/json"
+ "{}"
+ end
+ end
+end
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 4eee7793..a5c05c00 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -12,9 +12,7 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute
id = env.params.query["v"]
if env.params.query["v"].empty?
- error_message = "Invalid parameters."
- env.response.status_code = 400
- return templated "error"
+ return error_template(400, "Invalid parameters.")
end
if id.size > 11
@@ -30,6 +28,14 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute
return env.redirect "/"
end
+ embed_link = "/embed/#{id}"
+ if env.params.query.size > 1
+ embed_params = HTTP::Params.parse(env.params.query.to_s)
+ embed_params.delete_all("v")
+ embed_link += "?"
+ embed_link += embed_params.to_s
+ end
+
plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
continuation = process_continuation(PG_DB, env.params.query, plid, id)
@@ -56,10 +62,8 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
rescue ex
- error_message = ex.message
- env.response.status_code = 500
- logger.puts("#{id} : #{ex.message}")
- return templated "error"
+ logger.error("get_video: #{id} : #{ex.message}")
+ return error_template(500, ex)
end
if preferences.annotations_subscribed &&
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index c09dda38..602e6ae5 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -1,8 +1,15 @@
module Invidious::Routing
- macro get(path, controller)
+ macro get(path, controller, method = :handle)
get {{ path }} do |env|
controller_instance = {{ controller }}.new(config, logger)
- controller_instance.handle(env)
+ controller_instance.{{ method.id }}(env)
+ end
+ end
+
+ macro post(path, controller, method = :handle)
+ post {{ path }} do |env|
+ controller_instance = {{ controller }}.new(config, logger)
+ controller_instance.{{ method.id }}(env)
end
end
end
diff --git a/src/invidious/users.cr b/src/invidious/users.cr
index 46bf8865..5dc16edd 100644
--- a/src/invidious/users.cr
+++ b/src/invidious/users.cr
@@ -66,6 +66,8 @@ struct Preferences
@[JSON::Field(converter: Preferences::ProcessString)]
property quality : String = CONFIG.default_user_preferences.quality
+ @[JSON::Field(converter: Preferences::ProcessString)]
+ property quality_dash : String = CONFIG.default_user_preferences.quality_dash
property default_home : String = CONFIG.default_user_preferences.default_home
property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu
property related_videos : Bool = CONFIG.default_user_preferences.related_videos
@@ -267,12 +269,12 @@ struct Preferences
end
end
-def get_user(sid, headers, db, refresh = true)
+def get_user(sid, headers, db, logger, refresh = true)
if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
if refresh && Time.utc - user.updated > 1.minute
- user, sid = fetch_user(sid, headers, db)
+ user, sid = fetch_user(sid, headers, db, logger)
user_array = user.to_a
user_array[4] = user_array[4].to_json # User preferences
args = arg_array(user_array)
@@ -290,7 +292,7 @@ def get_user(sid, headers, db, refresh = true)
end
end
else
- user, sid = fetch_user(sid, headers, db)
+ user, sid = fetch_user(sid, headers, db, logger)
user_array = user.to_a
user_array[4] = user_array[4].to_json # User preferences
args = arg_array(user.to_a)
@@ -311,7 +313,7 @@ def get_user(sid, headers, db, refresh = true)
return user, sid
end
-def fetch_user(sid, headers, db)
+def fetch_user(sid, headers, db, logger)
feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
feed = XML.parse_html(feed.body)
@@ -324,7 +326,7 @@ def fetch_user(sid, headers, db)
end
end
- channels = get_batch_channels(channels, db, false, false)
+ channels = get_batch_channels(channels, db, logger, false, false)
email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"]))
if email
@@ -425,7 +427,7 @@ def generate_captcha(key, db)
end
def generate_text_captcha(key, db)
- response = make_client(TEXTCAPTCHA_URL).get("/omarroth@protonmail.com.json").body
+ response = make_client(TEXTCAPTCHA_URL, &.get("/omarroth@protonmail.com.json").body)
response = JSON.parse(response)
tokens = response["a"].as_a.map do |answer|
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index 8e314fe0..4a831110 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -235,6 +235,7 @@ struct VideoPreferences
property preferred_captions : Array(String)
property player_style : String
property quality : String
+ property quality_dash : String
property raw : Bool
property region : String?
property related_videos : Bool
@@ -816,7 +817,7 @@ end
def extract_polymer_config(body)
params = {} of String => JSON::Any
- player_response = body.match(/window\["ytInitialPlayerResponse"\]\s*=\s*(?<info>.*?);\n/)
+ player_response = body.match(/(window\["ytInitialPlayerResponse"\]|var\sytInitialPlayerResponse)\s*=\s*(?<info>{.*?});/m)
.try { |r| JSON.parse(r["info"]).as_h }
if body.includes?("To continue with your YouTube experience, please fill out the form below.") ||
@@ -830,7 +831,8 @@ def extract_polymer_config(body)
params["reason"] = JSON::Any.new(reason)
end
- params["sessionToken"] = JSON::Any.new(body.match(/"XSRF_TOKEN":"(?<session_token>[^"]+)"/).try &.["session_token"]?)
+ session_token_json_encoded = body.match(/"XSRF_TOKEN":"(?<session_token>[^"]+)"/).try &.["session_token"]? || ""
+ params["sessionToken"] = JSON.parse(%({"key": "#{session_token_json_encoded}"}))["key"]
params["shortDescription"] = JSON::Any.new(body.match(/"og:description" content="(?<description>[^"]+)"/).try &.["description"]?)
return params if !player_response
@@ -839,8 +841,7 @@ def extract_polymer_config(body)
params[f] = player_response[f] if player_response[f]?
end
- yt_initial_data = body.match(/(window\["ytInitialData"\]|var\s+ytInitialData)\s*=\s*(?<info>.*?);\s*\n/)
- .try { |r| JSON.parse(r["info"]).as_h }
+ yt_initial_data = extract_initial_data(body)
params["relatedVideos"] = yt_initial_data.try &.["playerOverlays"]?.try &.["playerOverlayRenderer"]?
.try &.["endScreen"]?.try &.["watchNextEndScreenRenderer"]?.try &.["results"]?.try &.as_a.compact_map { |r|
@@ -915,10 +916,14 @@ def extract_polymer_config(body)
.try { |r| JSON.parse(r["info"]) }.try &.["args"]["player_response"]?
.try &.as_s?.try &.try { |r| JSON.parse(r).as_h }
- return params if !initial_data
-
- {"playabilityStatus", "streamingData"}.each do |f|
- params[f] = initial_data[f] if initial_data[f]?
+ if initial_data
+ {"playabilityStatus", "streamingData"}.each do |f|
+ params[f] = initial_data[f] if initial_data[f]?
+ end
+ else
+ {"playabilityStatus", "streamingData"}.each do |f|
+ params[f] = player_response[f] if player_response[f]?
+ end
end
params
@@ -999,7 +1004,7 @@ def fetch_video(id, region)
}.try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any)
end
- raise info["reason"]?.try &.as_s || "" if !info["videoDetails"]?
+ raise InfoException.new(info["reason"]?.try &.as_s || "") if !info["videoDetails"]?
video = Video.new({
id: id,
@@ -1039,6 +1044,7 @@ def process_video_params(query, preferences)
player_style = query["player_style"]?
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]?
+ quality_dash = query["quality_dash"]?
region = query["region"]?
related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
speed = query["speed"]?.try &.rchop("x").to_f?
@@ -1057,6 +1063,7 @@ def process_video_params(query, preferences)
player_style ||= preferences.player_style
preferred_captions ||= preferences.captions
quality ||= preferences.quality
+ quality_dash ||= preferences.quality_dash
related_videos ||= preferences.related_videos.to_unsafe
speed ||= preferences.speed
video_loop ||= preferences.video_loop.to_unsafe
@@ -1073,6 +1080,7 @@ def process_video_params(query, preferences)
player_style ||= CONFIG.default_user_preferences.player_style
preferred_captions ||= CONFIG.default_user_preferences.captions
quality ||= CONFIG.default_user_preferences.quality
+ quality_dash ||= CONFIG.default_user_preferences.quality_dash
related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe
speed ||= CONFIG.default_user_preferences.speed
video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe
@@ -1125,6 +1133,7 @@ def process_video_params(query, preferences)
player_style: player_style,
preferred_captions: preferred_captions,
quality: quality,
+ quality_dash: quality_dash,
raw: raw,
region: region,
related_videos: related_videos,
diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr
index 0e6664fa..625c6fee 100644
--- a/src/invidious/views/components/player.ecr
+++ b/src/invidious/views/components/player.ecr
@@ -4,7 +4,7 @@
<% if params.video_loop %>loop<% end %>
<% if params.controls %>controls<% end %>>
<% if (hlsvp = video.hls_manifest_url) && !CONFIG.disabled?("livestreams") %>
- <source src="<%= URI.parse(hlsvp).full_path %>?local=true" type="application/x-mpegURL" label="livestream">
+ <source src="<%= URI.parse(hlsvp).full_path %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream">
<% else %>
<% if params.listen %>
<% audio_streams.each_with_index do |fmt, i| %>
diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr
index d02f82d2..8162546e 100644
--- a/src/invidious/views/components/player_sources.ecr
+++ b/src/invidious/views/components/player_sources.ecr
@@ -3,7 +3,6 @@
<link rel="stylesheet" href="/css/videojs.markers.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/videojs-share.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/videojs-vtt-thumbnails.css?v=<%= ASSET_COMMIT %>">
-<link rel="stylesheet" href="/css/videojs-vtt-thumbnails-fix.css?v=<%= ASSET_COMMIT %>">
<script src="/js/global.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/video.min.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/videojs-contrib-quality-levels.min.js?v=<%= ASSET_COMMIT %>"></script>
diff --git a/src/invidious/views/data_control.ecr b/src/invidious/views/data_control.ecr
index e3edb9ea..74ccc06c 100644
--- a/src/invidious/views/data_control.ecr
+++ b/src/invidious/views/data_control.ecr
@@ -14,7 +14,7 @@
<div class="pure-control-group">
<label for="import_youtube">
- <a rel="noopener" target="_blank" href="https://support.google.com/youtube/answer/6224202?hl=en">
+ <a rel="noopener" target="_blank" href="https://github.com/iv-org/documentation/blob/master/Export-YouTube-subscriptions.md">
<%= translate(locale, "Import YouTube subscriptions") %>
</a>
</label>
diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr
index 48dbc55f..dbb86009 100644
--- a/src/invidious/views/embed.ecr
+++ b/src/invidious/views/embed.ecr
@@ -9,12 +9,11 @@
<link rel="stylesheet" href="/css/videojs-overlay.css?v=<%= ASSET_COMMIT %>">
<script src="/js/videojs-overlay.min.js?v=<%= ASSET_COMMIT %>"></script>
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
- <link rel="stylesheet" href="/css/darktheme.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title>
</head>
-<body>
+<body class="dark-theme">
<script id="video_data" type="application/json">
<%=
{
diff --git a/src/invidious/views/message.ecr b/src/invidious/views/message.ecr
new file mode 100644
index 00000000..8c7bf611
--- /dev/null
+++ b/src/invidious/views/message.ecr
@@ -0,0 +1,12 @@
+<% content_for "header" do %>
+<meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>">
+<title>
+ Invidious
+</title>
+<% end %>
+
+<%= rendered "components/feed_menu" %>
+
+<p>
+ <%= message %>
+</p>
diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr
index 0c48be96..a77d106d 100644
--- a/src/invidious/views/playlists.ecr
+++ b/src/invidious/views/playlists.ecr
@@ -27,7 +27,7 @@
</div>
<div class="h-box">
- <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %></span></p>
+ <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %></span></p>
</div>
<div class="h-box">
diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr
index fb5bd44b..1ef080be 100644
--- a/src/invidious/views/preferences.ecr
+++ b/src/invidious/views/preferences.ecr
@@ -57,6 +57,17 @@
</select>
</div>
+ <% if !CONFIG.disabled?("dash") %>
+ <div class="pure-control-group">
+ <label for="quality_dash"><%= translate(locale, "Preferred dash video quality: ") %></label>
+ <select name="quality_dash" id="quality_dash">
+ <% {"auto", "best", "4320p", "2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p", "worst"}.each do |option| %>
+ <option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <% end %>
+ </select>
+ </div>
+ <% end %>
+
<div class="pure-control-group">
<label for="volume"><%= translate(locale, "Player volume: ") %></label>
<input name="volume" id="volume" data-onrange="update_volume_value" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>">
@@ -68,7 +79,7 @@
<% preferences.comments.each_with_index do |comments, index| %>
<select name="comments[<%= index %>]" id="comments[<%= index %>]">
<% {"", "youtube", "reddit"}.each do |option| %>
- <option value="<%= option %>" <% if preferences.comments[index] == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if preferences.comments[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
@@ -79,7 +90,7 @@
<% preferences.captions.each_with_index do |caption, index| %>
<select class="pure-u-1-6" name="captions[<%= index %>]" id="captions[<%= index %>]">
<% CAPTION_LANGUAGES.each do |option| %>
- <option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
@@ -119,7 +130,7 @@
<label for="dark_mode"><%= translate(locale, "Theme: ") %></label>
<select name="dark_mode" id="dark_mode">
<% {"", "light", "dark"}.each do |option| %>
- <option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= translate(locale, option.blank? ? "auto" : option) %></option>
<% end %>
</select>
</div>
@@ -130,16 +141,16 @@
</div>
<% if env.get?("user") %>
- <% feed_options = {"", "Popular", "Top", "Trending", "Subscriptions", "Playlists"} %>
+ <% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %>
<% else %>
- <% feed_options = {"", "Popular", "Top", "Trending"} %>
+ <% feed_options = {"", "Popular", "Trending"} %>
<% end %>
<div class="pure-control-group">
<label for="default_home"><%= translate(locale, "Default homepage: ") %></label>
<select name="default_home" id="default_home">
<% feed_options.each do |option| %>
- <option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
</div>
@@ -149,7 +160,7 @@
<% (feed_options.size - 1).times do |index| %>
<select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
- <option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
@@ -211,7 +222,7 @@
<label for="admin_default_home"><%= translate(locale, "Default homepage: ") %></label>
<select name="admin_default_home" id="admin_default_home">
<% feed_options.each do |option| %>
- <option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
</div>
@@ -221,13 +232,19 @@
<% (feed_options.size - 1).times do |index| %>
<select name="admin_feed_menu[<%= index %>]" id="admin_feed_menu[<%= index %>]">
<% feed_options.each do |option| %>
- <option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <option value="<%= option %>" <% if CONFIG.default_user_preferences.feed_menu[index]? == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option>
<% end %>
</select>
<% end %>
</div>
<div class="pure-control-group">
+ <label for="popular_enabled"><%= translate(locale, "Popular enabled: ") %></label>
+ <input name="popular_enabled" id="popular_enabled" type="checkbox" <% if config.popular_enabled %>checked<% end %>>
+ </div>
+
+
+ <div class="pure-control-group">
<label for="captcha_enabled"><%= translate(locale, "CAPTCHA enabled: ") %></label>
<input name="captcha_enabled" id="captcha_enabled" type="checkbox" <% if config.captcha_enabled %>checked<% end %>>
</div>
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 61cf5c3a..f6e5262d 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -4,7 +4,6 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="referrer" content="no-referrer">
<%= yield_content "header" %>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=<%= ASSET_COMMIT %>">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=<%= ASSET_COMMIT %>">
@@ -18,13 +17,12 @@
<link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
- <link rel="stylesheet" href="/css/darktheme.css?v=<%= ASSET_COMMIT %>" id="dark_theme" <% if env.get("preferences").as(Preferences).dark_mode != "dark" %>media="none"<% end %>>
- <link rel="stylesheet" href="/css/lighttheme.css?v=<%= ASSET_COMMIT %>" id="light_theme" <% if env.get("preferences").as(Preferences).dark_mode == "dark" %>media="none"<% end %>>
</head>
<% locale = LOCALES[env.get("preferences").as(Preferences).locale]? %>
+<% dark_mode = env.get("preferences").as(Preferences).dark_mode %>
-<body>
+<body class="<%= dark_mode.blank? ? "no" : dark_mode %>-theme">
<span style="display:none" id="dark_mode_pref"><%= env.get("preferences").as(Preferences).dark_mode %></span>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-2-24"></div>
@@ -116,16 +114,15 @@
</a>
</div>
<div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-logo-bitcoin"></i>
- BTC: <a href="bitcoin:356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY">356DpZyMXu6rYd55Yqzjs29n79kGKWcYrY</a>
+ <i class="icon ion-ios-wallet"></i>
+ BTC: <a href="bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr">bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr</a>
</div>
<div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-logo-bitcoin"></i>
- BCH: <a href="bitcoincash:qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk">qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk</a>
+ <i class="icon ion-ios-wallet"></i>
+ XMR: <a href="monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR">Click here</a>
</div>
<div class="pure-u-1 pure-u-md-1-3">
- <i class="icon ion-logo-usd"></i>
- <a href="https://liberapay.com/omarroth">Liberapay</a>
+ <a href="https://github.com/iv-org/documentation">Documentation</a>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<i class="icon ion-logo-javascript"></i>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 9a1e6c32..786a88b6 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -88,7 +88,11 @@
<div class="h-box">
<span id="watch-on-youtube">
<a href="https://www.youtube.com/watch?v=<%= video.id %>"><%= translate(locale, "Watch on YouTube") %></a>
+ (<a href="https://www.youtube.com/embed/<%= video.id %>"><%= translate(locale, "Embed") %></a>)
</span>
+ <p id="embed-link">
+ <a href="<%= embed_link %>"><%= translate(locale, "Embed Link") %></a>
+ </p>
<p id="annotations">
<% if params.annotations %>
<a href="/watch?<%= env.params.query %>&iv_load_policy=3">