summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/invidious.cr10
-rw-r--r--src/invidious/channels/playlists.cr18
-rw-r--r--src/invidious/channels/videos.cr2
-rw-r--r--src/invidious/config.cr14
-rw-r--r--src/invidious/frontend/channel_page.cr2
-rw-r--r--src/invidious/frontend/pagination.cr97
-rw-r--r--src/invidious/helpers/helpers.cr25
-rw-r--r--src/invidious/helpers/i18n.cr9
-rw-r--r--src/invidious/helpers/utils.cr2
-rw-r--r--src/invidious/http_server/utils.cr20
-rw-r--r--src/invidious/jobs.cr2
-rw-r--r--src/invidious/routes/account.cr7
-rw-r--r--src/invidious/routes/api/v1/authenticated.cr4
-rw-r--r--src/invidious/routes/api/v1/channels.cr62
-rw-r--r--src/invidious/routes/before_all.cr60
-rw-r--r--src/invidious/routes/channels.cr44
-rw-r--r--src/invidious/routes/feeds.cr12
-rw-r--r--src/invidious/routes/login.cr281
-rw-r--r--src/invidious/routes/notifications.cr44
-rw-r--r--src/invidious/routes/playlists.cr38
-rw-r--r--src/invidious/routes/search.cr26
-rw-r--r--src/invidious/routes/subscriptions.cr13
-rw-r--r--src/invidious/routing.cr6
-rw-r--r--src/invidious/user/imports.cr8
-rw-r--r--src/invidious/users.cr101
-rw-r--r--src/invidious/videos.cr4
-rw-r--r--src/invidious/videos/parser.cr6
-rw-r--r--src/invidious/views/add_playlist_items.ecr30
-rw-r--r--src/invidious/views/channel.ecr27
-rw-r--r--src/invidious/views/components/channel_info.ecr27
-rw-r--r--src/invidious/views/components/item.ecr235
-rw-r--r--src/invidious/views/components/items_paginated.ecr11
-rw-r--r--src/invidious/views/components/subscribe_widget.ecr6
-rw-r--r--src/invidious/views/components/video-context-buttons.ecr4
-rw-r--r--src/invidious/views/edit_playlist.ecr89
-rw-r--r--src/invidious/views/feeds/history.ecr52
-rw-r--r--src/invidious/views/feeds/subscriptions.ecr25
-rw-r--r--src/invidious/views/hashtag.ecr35
-rw-r--r--src/invidious/views/playlist.ecr97
-rw-r--r--src/invidious/views/privacy.ecr3
-rw-r--r--src/invidious/views/search.ecr37
-rw-r--r--src/invidious/views/template.ecr8
-rw-r--r--src/invidious/views/user/login.ecr36
-rw-r--r--src/invidious/views/watch.ecr62
-rw-r--r--src/invidious/yt_backend/connection_pool.cr6
-rw-r--r--src/invidious/yt_backend/extractors.cr5
-rw-r--r--src/invidious/yt_backend/youtube_api.cr16
47 files changed, 673 insertions, 1055 deletions
diff --git a/src/invidious.cr b/src/invidious.cr
index 27c4775e..84e1895d 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -57,13 +57,11 @@ end
# Simple alias to make code easier to read
alias IV = Invidious
-CONFIG = Config.load
-HMAC_KEY_CONFIGURED = CONFIG.hmac_key != nil
-HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
+CONFIG = Config.load
+HMAC_KEY = CONFIG.hmac_key
PG_DB = DB.open CONFIG.database_url
ARCHIVE_URL = URI.parse("https://archive.org")
-LOGIN_URL = URI.parse("https://accounts.google.com")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com")
YT_URL = URI.parse("https://www.youtube.com")
@@ -231,10 +229,6 @@ Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.confi
Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port
Kemal.config.app_name = "Invidious"
-if !HMAC_KEY_CONFIGURED
- LOGGER.warn("Please configure hmac_key by July 1st, see more here: https://github.com/iv-org/invidious/issues/3854")
-end
-
# Use in kemal's production mode.
# Users can also set the KEMAL_ENV environmental variable for this to be set automatically.
{% if flag?(:release) || flag?(:production) %}
diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr
index 8dc824b2..91029fe3 100644
--- a/src/invidious/channels/playlists.cr
+++ b/src/invidious/channels/playlists.cr
@@ -26,3 +26,21 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by)
return extract_items(initial_data, author, ucid)
end
+
+def fetch_channel_podcasts(ucid, author, continuation)
+ if continuation
+ initial_data = YoutubeAPI.browse(continuation)
+ else
+ initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA")
+ end
+ return extract_items(initial_data, author, ucid)
+end
+
+def fetch_channel_releases(ucid, author, continuation)
+ if continuation
+ initial_data = YoutubeAPI.browse(continuation)
+ else
+ initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA")
+ end
+ return extract_items(initial_data, author, ucid)
+end
diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr
index 12ed4a7d..beb86e08 100644
--- a/src/invidious/channels/videos.cr
+++ b/src/invidious/channels/videos.cr
@@ -20,7 +20,7 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so
case sort_by
when "newest" then 1_i64
when "popular" then 2_i64
- when "oldest" then 3_i64 # Broken as of 10/2022 :c
+ when "oldest" then 4_i64
else 1_i64 # Fallback to "newest"
end
diff --git a/src/invidious/config.cr b/src/invidious/config.cr
index 9fc58409..e5f1e822 100644
--- a/src/invidious/config.cr
+++ b/src/invidious/config.cr
@@ -85,7 +85,7 @@ class Config
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
property https_only : Bool?
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
- property hmac_key : String?
+ property hmac_key : String = ""
# Domain to be used for links to resources on the site where an absolute URL is required
property domain : String?
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
@@ -204,6 +204,16 @@ class Config
end
{% end %}
+ # HMAC_key is mandatory
+ # See: https://github.com/iv-org/invidious/issues/3854
+ if config.hmac_key.empty?
+ puts "Config: 'hmac_key' is required/can't be empty"
+ exit(1)
+ elsif config.hmac_key == "CHANGE_ME!!"
+ puts "Config: The value of 'hmac_key' needs to be changed!!"
+ exit(1)
+ end
+
# Build database_url from db.* if it's not set directly
if config.database_url.to_s.empty?
if db = config.db
@@ -216,7 +226,7 @@ class Config
path: db.dbname,
)
else
- puts "Config : Either database_url or db.* is required"
+ puts "Config: Either database_url or db.* is required"
exit(1)
end
end
diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr
index 53745dd5..fe7d6d6e 100644
--- a/src/invidious/frontend/channel_page.cr
+++ b/src/invidious/frontend/channel_page.cr
@@ -5,6 +5,8 @@ module Invidious::Frontend::ChannelPage
Videos
Shorts
Streams
+ Podcasts
+ Releases
Playlists
Community
Channels
diff --git a/src/invidious/frontend/pagination.cr b/src/invidious/frontend/pagination.cr
new file mode 100644
index 00000000..3f931f4e
--- /dev/null
+++ b/src/invidious/frontend/pagination.cr
@@ -0,0 +1,97 @@
+require "uri"
+
+module Invidious::Frontend::Pagination
+ extend self
+
+ private def previous_page(str : String::Builder, locale : String?, url : String)
+ # Link
+ str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
+
+ if locale_is_rtl?(locale)
+ # Inverted arrow ("previous" points to the right)
+ str << translate(locale, "Previous page")
+ str << "&nbsp;&nbsp;"
+ str << %(<i class="icon ion-ios-arrow-forward"></i>)
+ else
+ # Regular arrow ("previous" points to the left)
+ str << %(<i class="icon ion-ios-arrow-back"></i>)
+ str << "&nbsp;&nbsp;"
+ str << translate(locale, "Previous page")
+ end
+
+ str << "</a>"
+ end
+
+ private def next_page(str : String::Builder, locale : String?, url : String)
+ # Link
+ str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">)
+
+ if locale_is_rtl?(locale)
+ # Inverted arrow ("next" points to the left)
+ str << %(<i class="icon ion-ios-arrow-back"></i>)
+ str << "&nbsp;&nbsp;"
+ str << translate(locale, "Next page")
+ else
+ # Regular arrow ("next" points to the right)
+ str << translate(locale, "Next page")
+ str << "&nbsp;&nbsp;"
+ str << %(<i class="icon ion-ios-arrow-forward"></i>)
+ end
+
+ str << "</a>"
+ end
+
+ def nav_numeric(locale : String?, *, base_url : String | URI, current_page : Int, show_next : Bool = true)
+ return String.build do |str|
+ str << %(<div class="h-box">\n)
+ str << %(<div class="page-nav-container flexible">\n)
+
+ str << %(<div class="page-prev-container flex-left">)
+
+ if current_page > 1
+ params_prev = URI::Params{"page" => (current_page - 1).to_s}
+ url_prev = HttpServer::Utils.add_params_to_url(base_url, params_prev)
+
+ self.previous_page(str, locale, url_prev.to_s)
+ end
+
+ str << %(</div>\n)
+ str << %(<div class="page-next-container flex-right">)
+
+ if show_next
+ params_next = URI::Params{"page" => (current_page + 1).to_s}
+ url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
+
+ self.next_page(str, locale, url_next.to_s)
+ end
+
+ str << %(</div>\n)
+
+ str << %(</div>\n)
+ str << %(</div>\n\n)
+ end
+ end
+
+ def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?)
+ return String.build do |str|
+ str << %(<div class="h-box">\n)
+ str << %(<div class="page-nav-container flexible">\n)
+
+ str << %(<div class="page-prev-container flex-left"></div>\n)
+
+ str << %(<div class="page-next-container flex-right">)
+
+ if !ctoken.nil?
+ params_next = URI::Params{"continuation" => ctoken}
+ url_next = HttpServer::Utils.add_params_to_url(base_url, params_next)
+
+ self.next_page(str, locale, url_next.to_s)
+ end
+
+ str << %(</div>\n)
+
+ str << %(</div>\n)
+ str << %(</div>\n\n)
+ end
+ end
+end
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index c3b53339..23ff0da9 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -22,31 +22,6 @@ struct Annotation
property annotations : String
end
-def login_req(f_req)
- data = {
- # Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard
- # Generally this is much longer (>1250 characters), see also
- # https://github.com/ytdl-org/youtube-dl/commit/baf67a604d912722b0fe03a40e9dc5349a2208cb .
- # For now this can be empty.
- "bgRequest" => %|["identifier",""]|,
- "pstMsg" => "1",
- "checkConnection" => "youtube",
- "checkedDomains" => "youtube",
- "hl" => "en",
- "deviceinfo" => %|[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|,
- "f.req" => f_req,
- "flowName" => "GlifWebSignIn",
- "flowEntry" => "ServiceLogin",
- # "cookiesDisabled" => "false",
- # "gmscoreversion" => "undefined",
- # "continue" => "https://accounts.google.com/ManageAccount",
- # "azt" => "",
- # "bgHash" => "",
- }
-
- return HTTP::Params.encode(data)
-end
-
def html_to_content(description_html : String)
description = description_html.gsub(/(<br>)|(<br\/>)/, {
"<br>": "\n",
diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr
index a9ed1f64..76e477a4 100644
--- a/src/invidious/helpers/i18n.cr
+++ b/src/invidious/helpers/i18n.cr
@@ -165,3 +165,12 @@ def translate_bool(locale : String?, translation : Bool)
return translate(locale, "No")
end
end
+
+def locale_is_rtl?(locale : String?)
+ # Fallback to en-US
+ return false if locale.nil?
+
+ # Arabic, Persian, Hebrew
+ # See https://en.wikipedia.org/wiki/Right-to-left_script#List_of_RTL_scripts
+ return {"ar", "fa", "he"}.includes? locale
+end
diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr
index 48bf769f..a006d602 100644
--- a/src/invidious/helpers/utils.cr
+++ b/src/invidious/helpers/utils.cr
@@ -440,7 +440,7 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
# - https://github.com/iv-org/invidious/issues/3062
text = %(<a href="#{url}">#{text}</a>)
else
- text = %(<a href="#{url}">#{reduce_uri(url)}</a>)
+ text = %(<a href="#{url}">#{reduce_uri(text)}</a>)
end
end
return text
diff --git a/src/invidious/http_server/utils.cr b/src/invidious/http_server/utils.cr
index e3f1fa0f..222dfc4a 100644
--- a/src/invidious/http_server/utils.cr
+++ b/src/invidious/http_server/utils.cr
@@ -1,3 +1,5 @@
+require "uri"
+
module Invidious::HttpServer
module Utils
extend self
@@ -16,5 +18,23 @@ module Invidious::HttpServer
return "#{url.request_target}?#{params}"
end
end
+
+ def add_params_to_url(url : String | URI, params : URI::Params) : URI
+ url = URI.parse(url) if url.is_a?(String)
+
+ url_query = url.query || ""
+
+ # Append the parameters
+ url.query = String.build do |str|
+ if !url_query.empty?
+ str << url_query
+ str << '&'
+ end
+
+ str << params
+ end
+
+ return url
+ end
end
end
diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr
index 524a3624..b6b673f7 100644
--- a/src/invidious/jobs.cr
+++ b/src/invidious/jobs.cr
@@ -2,7 +2,7 @@ module Invidious::Jobs
JOBS = [] of BaseJob
# Automatically generate a structure that wraps the various
- # jobs' configs, so that the follwing YAML config can be used:
+ # jobs' configs, so that the following YAML config can be used:
#
# jobs:
# job_name:
diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr
index 5aa4452c..9d930841 100644
--- a/src/invidious/routes/account.cr
+++ b/src/invidious/routes/account.cr
@@ -42,11 +42,6 @@ module Invidious::Routes::Account
sid = sid.as(String)
token = env.params.body["csrf_token"]?
- # We don't store passwords for Google accounts
- if !user.password
- return error_template(400, "Cannot change password for Google accounts")
- end
-
begin
validate_request(token, sid, env.request, HMAC_KEY, locale)
rescue ex
@@ -54,7 +49,7 @@ module Invidious::Routes::Account
end
password = env.params.body["password"]?
- if !password
+ if password.nil? || password.empty?
return error_template(401, "Password is a required field")
end
diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr
index ce2ee812..a35d2f2b 100644
--- a/src/invidious/routes/api/v1/authenticated.cr
+++ b/src/invidious/routes/api/v1/authenticated.cr
@@ -178,10 +178,6 @@ module Invidious::Routes::API::V1::Authenticated
Invidious::Database::Users.subscribe_channel(user, ucid)
end
- # For Google accounts, access tokens don't have enough information to
- # make a request on the user's behalf, which is why we don't sync with
- # YouTube.
-
env.response.status_code = 204
end
diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr
index bcb4db2c..adf05d30 100644
--- a/src/invidious/routes/api/v1/channels.cr
+++ b/src/invidious/routes/api/v1/channels.cr
@@ -245,7 +245,7 @@ module Invidious::Routes::API::V1::Channels
channel = nil # Make the compiler happy
get_channel()
- items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
+ items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
JSON.build do |json|
json.object do
@@ -257,7 +257,65 @@ module Invidious::Routes::API::V1::Channels
end
end
- json.field "continuation", continuation
+ json.field "continuation", next_continuation if next_continuation
+ end
+ end
+ end
+
+ def self.podcasts(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ env.response.content_type = "application/json"
+
+ ucid = env.params.url["ucid"]
+ continuation = env.params.query["continuation"]?
+
+ # Use the macro defined above
+ channel = nil # Make the compiler happy
+ get_channel()
+
+ items, next_continuation = fetch_channel_podcasts(channel.ucid, channel.author, continuation)
+
+ JSON.build do |json|
+ json.object do
+ json.field "playlists" do
+ json.array do
+ items.each do |item|
+ item.to_json(locale, json) if item.is_a?(SearchPlaylist)
+ end
+ end
+ end
+
+ json.field "continuation", next_continuation if next_continuation
+ end
+ end
+ end
+
+ def self.releases(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ env.response.content_type = "application/json"
+
+ ucid = env.params.url["ucid"]
+ continuation = env.params.query["continuation"]?
+
+ # Use the macro defined above
+ channel = nil # Make the compiler happy
+ get_channel()
+
+ items, next_continuation = fetch_channel_releases(channel.ucid, channel.author, continuation)
+
+ JSON.build do |json|
+ json.object do
+ json.field "playlists" do
+ json.array do
+ items.each do |item|
+ item.to_json(locale, json) if item.is_a?(SearchPlaylist)
+ end
+ end
+ end
+
+ json.field "continuation", next_continuation if next_continuation
end
end
end
diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr
index 8e2a253f..396840a4 100644
--- a/src/invidious/routes/before_all.cr
+++ b/src/invidious/routes/before_all.cr
@@ -80,49 +80,23 @@ module Invidious::Routes::BeforeAll
raise "Cannot use token as SID"
end
- # Invidious users only have SID
- if !env.request.cookies.has_key? "SSID"
- if email = Invidious::Database::SessionIDs.select_email(sid)
- user = Invidious::Database::Users.select!(email: email)
- csrf_token = generate_response(sid, {
- ":authorize_token",
- ":playlist_ajax",
- ":signout",
- ":subscription_ajax",
- ":token_ajax",
- ":watch_ajax",
- }, HMAC_KEY, 1.week)
-
- preferences = user.preferences
- env.set "preferences", preferences
-
- env.set "sid", sid
- env.set "csrf_token", csrf_token
- env.set "user", user
- end
- else
- headers = HTTP::Headers.new
- headers["Cookie"] = env.request.headers["Cookie"]
-
- begin
- user, sid = get_user(sid, headers, false)
- csrf_token = generate_response(sid, {
- ":authorize_token",
- ":playlist_ajax",
- ":signout",
- ":subscription_ajax",
- ":token_ajax",
- ":watch_ajax",
- }, HMAC_KEY, 1.week)
-
- preferences = user.preferences
- env.set "preferences", preferences
-
- env.set "sid", sid
- env.set "csrf_token", csrf_token
- env.set "user", user
- rescue ex
- end
+ if email = Database::SessionIDs.select_email(sid)
+ user = Database::Users.select!(email: email)
+ csrf_token = generate_response(sid, {
+ ":authorize_token",
+ ":playlist_ajax",
+ ":signout",
+ ":subscription_ajax",
+ ":token_ajax",
+ ":watch_ajax",
+ }, HMAC_KEY, 1.week)
+
+ preferences = user.preferences
+ env.set "preferences", preferences
+
+ env.set "sid", sid
+ env.set "csrf_token", csrf_token
+ env.set "user", user
end
end
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index 16621994..9892ae2a 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -27,7 +27,7 @@ module Invidious::Routes::Channels
item.author
end
end
- items = items.select(SearchPlaylist).map(&.as(SearchPlaylist))
+ items = items.select(SearchPlaylist)
items.each(&.author = "")
else
sort_options = {"newest", "oldest", "popular"}
@@ -105,13 +105,53 @@ module Invidious::Routes::Channels
channel.ucid, channel.author, continuation, (sort_by || "last")
)
- items = items.select(SearchPlaylist).map(&.as(SearchPlaylist))
+ items = items.select(SearchPlaylist)
items.each(&.author = "")
selected_tab = Frontend::ChannelPage::TabsAvailable::Playlists
templated "channel"
end
+ def self.podcasts(env)
+ data = self.fetch_basic_information(env)
+ return data if !data.is_a?(Tuple)
+
+ locale, user, subscriptions, continuation, ucid, channel = data
+
+ sort_by = ""
+ sort_options = [] of String
+
+ items, next_continuation = fetch_channel_podcasts(
+ channel.ucid, channel.author, continuation
+ )
+
+ items = items.select(SearchPlaylist)
+ items.each(&.author = "")
+
+ selected_tab = Frontend::ChannelPage::TabsAvailable::Podcasts
+ templated "channel"
+ end
+
+ def self.releases(env)
+ data = self.fetch_basic_information(env)
+ return data if !data.is_a?(Tuple)
+
+ locale, user, subscriptions, continuation, ucid, channel = data
+
+ sort_by = ""
+ sort_options = [] of String
+
+ items, next_continuation = fetch_channel_releases(
+ channel.ucid, channel.author, continuation
+ )
+
+ items = items.select(SearchPlaylist)
+ items.each(&.author = "")
+
+ selected_tab = Frontend::ChannelPage::TabsAvailable::Releases
+ templated "channel"
+ end
+
def self.community(env)
data = self.fetch_basic_information(env)
if !data.is_a?(Tuple)
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index fb482e33..a8246b2e 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -83,10 +83,6 @@ module Invidious::Routes::Feeds
headers = HTTP::Headers.new
headers["Cookie"] = env.request.headers["Cookie"]
- if !user.password
- user, sid = get_user(sid, headers)
- end
-
max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE)
max_results ||= user.preferences.max_results
max_results ||= CONFIG.default_user_preferences.max_results
@@ -106,6 +102,10 @@ module Invidious::Routes::Feeds
end
env.set "user", user
+ # Used for pagination links
+ base_url = "/feed/subscriptions"
+ base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results")
+
templated "feeds/subscriptions"
end
@@ -133,6 +133,10 @@ module Invidious::Routes::Feeds
end
watched ||= [] of String
+ # Used for pagination links
+ base_url = "/feed/history"
+ base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results")
+
templated "feeds/history"
end
diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr
index 6454131a..d0f7ac22 100644
--- a/src/invidious/routes/login.cr
+++ b/src/invidious/routes/login.cr
@@ -24,9 +24,6 @@ module Invidious::Routes::Login
captcha_type = env.params.query["captcha"]?
captcha_type ||= "image"
- tfa = env.params.query["tfa"]?
- prompt = nil
-
templated "user/login"
end
@@ -47,283 +44,18 @@ module Invidious::Routes::Login
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 = nil # Declare variable
- {% unless flag?(:disable_quic) %}
- client = CONFIG.use_quic ? QUIC::Client.new(LOGIN_URL) : HTTP::Client.new(LOGIN_URL)
- {% else %}
- client = HTTP::Client.new(LOGIN_URL)
- {% end %}
-
- 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 "user/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 "user/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_client_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.request_target, headers)
-
- headers = login.cookies.add_request_headers(headers)
- location = login.headers["Location"]?.try { |u| URI.parse(u) }
- end
-
- cookies = HTTP::Cookies.from_client_headers(headers)
- sid = cookies["SID"]?.try &.value
- if !sid
- raise "Couldn't get SID."
- end
-
- user, sid = get_user(sid, headers)
-
- # We are now logged in
- traceback << "done.<br/>"
-
- host = URI.parse(env.request.headers["Host"]).host
-
- cookies.each do |cookie|
- cookie.secure = Invidious::User::Cookies::SECURE
-
- 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"]?
- user.preferences = env.get("preferences").as(Preferences)
- Invidious::Database::Users.update_preferences(user)
-
- 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
+ if email.nil? || email.empty?
return error_template(401, "User ID is a required field")
end
- if !password
+ if password.nil? || password.empty?
return error_template(401, "Password is a required field")
end
user = Invidious::Database::Users.select(email: email)
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))
Invidious::Database::SessionIDs.insert(sid, email)
@@ -367,8 +99,6 @@ module Invidious::Routes::Login
captcha_type ||= "image"
account_type = "invidious"
- tfa = false
- prompt = ""
if captcha_type == "image"
captcha = Invidious::User::Captcha.generate_image(HMAC_KEY)
@@ -481,11 +211,4 @@ module Invidious::Routes::Login
env.redirect referer
end
-
- def self.captcha(env)
- headers = HTTP::Headers{":authority" => "accounts.google.com"}
- response = YT_POOL.client &.get(env.request.resource, headers)
- env.response.headers["Content-Type"] = response.headers["Content-Type"]
- response.body
- end
end
diff --git a/src/invidious/routes/notifications.cr b/src/invidious/routes/notifications.cr
index 272a3dc7..8922b740 100644
--- a/src/invidious/routes/notifications.cr
+++ b/src/invidious/routes/notifications.cr
@@ -24,50 +24,6 @@ module Invidious::Routes::Notifications
user = user.as(User)
- if !user.password
- channel_req = {} of String => String
-
- channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true"
- channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || ""
- channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true"
-
- channel_req.reject! { |k, v| v != "true" && v != "false" }
-
- headers = HTTP::Headers.new
- headers["Cookie"] = env.request.headers["Cookie"]
-
- html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
-
- cookies = HTTP::Cookies.from_client_headers(headers)
- html.cookies.each do |cookie|
- if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name
- if cookies[cookie.name]?
- cookies[cookie.name] = cookie
- else
- cookies << cookie
- end
- end
- end
- headers = cookies.add_request_headers(headers)
-
- if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/)
- session_token = match["session_token"]
- else
- return env.redirect referer
- end
-
- headers["content-type"] = "application/x-www-form-urlencoded"
- channel_req["session_token"] = session_token
-
- subs = XML.parse_html(html.body)
- subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel|
- channel_id = channel.content.lstrip("/channel/").not_nil!
- channel_req["channel_id"] = channel_id
-
- YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req)
- end
- end
-
if redirect
env.redirect referer
else
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
index a65ff64c..9c6843e9 100644
--- a/src/invidious/routes/playlists.cr
+++ b/src/invidious/routes/playlists.cr
@@ -163,13 +163,20 @@ module Invidious::Routes::Playlists
end
begin
- videos = get_playlist_videos(playlist, offset: (page - 1) * 100)
+ items = get_playlist_videos(playlist, offset: (page - 1) * 100)
rescue ex
- videos = [] of PlaylistVideo
+ items = [] of PlaylistVideo
end
csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY)
+ # Pagination
+ page_nav_html = Frontend::Pagination.nav_numeric(locale,
+ base_url: "/playlist?list=#{playlist.id}",
+ current_page: page,
+ show_next: (items.size == 100)
+ )
+
templated "edit_playlist"
end
@@ -247,11 +254,19 @@ module Invidious::Routes::Playlists
begin
query = Invidious::Search::Query.new(env.params.query, :playlist, region)
- videos = query.process.select(SearchVideo).map(&.as(SearchVideo))
+ items = query.process.select(SearchVideo).map(&.as(SearchVideo))
rescue ex
- videos = [] of SearchVideo
+ items = [] of SearchVideo
end
+ # Pagination
+ query_encoded = URI.encode_www_form(query.try &.text || "", space_to_plus: true)
+ page_nav_html = Frontend::Pagination.nav_numeric(locale,
+ base_url: "/add_playlist_items?list=#{playlist.id}&q=#{query_encoded}",
+ current_page: page,
+ show_next: (items.size >= 20)
+ )
+
env.set "add_playlist_items", plid
templated "add_playlist_items"
end
@@ -320,10 +335,6 @@ module Invidious::Routes::Playlists
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
@@ -428,9 +439,9 @@ module Invidious::Routes::Playlists
begin
if playlist.is_a? InvidiousPlaylist
- videos = get_playlist_videos(playlist, offset: (page - 1) * 100)
+ items = get_playlist_videos(playlist, offset: (page - 1) * 100)
else
- videos = get_playlist_videos(playlist, offset: (page - 1) * 200)
+ items = get_playlist_videos(playlist, offset: (page - 1) * 200)
end
rescue ex
return error_template(500, "Error encountered while retrieving playlist videos.<br>#{ex.message}")
@@ -440,6 +451,13 @@ module Invidious::Routes::Playlists
env.set "remove_playlist_items", plid
end
+ # Pagination
+ page_nav_html = Frontend::Pagination.nav_numeric(locale,
+ base_url: "/playlist?list=#{playlist.id}",
+ current_page: page,
+ show_next: (page_count != 1 && page < page_count)
+ )
+
templated "playlist"
end
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
index 6c3088de..5be33533 100644
--- a/src/invidious/routes/search.cr
+++ b/src/invidious/routes/search.cr
@@ -52,24 +52,28 @@ module Invidious::Routes::Search
user = env.get? "user"
begin
- videos = query.process
+ items = query.process
rescue ex : ChannelSearchException
return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
rescue ex
return error_template(500, ex)
end
- params = query.to_http_params
- url_prev_page = "/search?#{params}&page=#{query.page - 1}"
- url_next_page = "/search?#{params}&page=#{query.page + 1}"
-
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
+ # Pagination
+ page_nav_html = Frontend::Pagination.nav_numeric(locale,
+ base_url: "/search?#{query.to_http_params}",
+ current_page: query.page,
+ show_next: (items.size >= 20)
+ )
+
if query.type == Invidious::Search::Query::Type::Channel
env.set "search", "channel:#{query.channel} #{query.text}"
else
env.set "search", query.text
end
+
templated "search"
end
end
@@ -91,16 +95,18 @@ module Invidious::Routes::Search
end
begin
- videos = Invidious::Hashtag.fetch(hashtag, page)
+ items = Invidious::Hashtag.fetch(hashtag, page)
rescue ex
return error_template(500, ex)
end
- params = env.params.query.empty? ? "" : "&#{env.params.query}"
-
+ # Pagination
hashtag_encoded = URI.encode_www_form(hashtag, space_to_plus: false)
- url_prev_page = "/hashtag/#{hashtag_encoded}?page=#{page - 1}#{params}"
- url_next_page = "/hashtag/#{hashtag_encoded}?page=#{page + 1}#{params}"
+ page_nav_html = Frontend::Pagination.nav_numeric(locale,
+ base_url: "/hashtag/#{hashtag_encoded}",
+ current_page: page,
+ show_next: (items.size >= 60)
+ )
templated "hashtag"
end
diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr
index 0704c05e..7f9ec592 100644
--- a/src/invidious/routes/subscriptions.cr
+++ b/src/invidious/routes/subscriptions.cr
@@ -43,11 +43,6 @@ module Invidious::Routes::Subscriptions
channel_id = env.params.query["c"]?
channel_id ||= ""
- if !user.password
- # Sync subscriptions with YouTube
- subscribe_ajax(channel_id, action, env.request.headers)
- end
-
case action
when "action_create_subscription_to_channel"
if !user.subscriptions.includes? channel_id
@@ -82,14 +77,6 @@ module Invidious::Routes::Subscriptions
user = user.as(User)
sid = sid.as(String)
- if !user.password
- # Refresh account
- headers = HTTP::Headers.new
- headers["Cookie"] = env.request.headers["Cookie"]
-
- user, sid = get_user(sid, headers)
- end
-
action_takeout = env.params.query["action_takeout"]?.try &.to_i?
action_takeout ||= 0
action_takeout = action_takeout == 1
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index 72ee9194..9c43171c 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -57,7 +57,6 @@ module Invidious::Routing
get "/login", Routes::Login, :login_page
post "/login", Routes::Login, :login
post "/signout", Routes::Login, :signout
- get "/Captcha", Routes::Login, :captcha
# User preferences
get "/preferences", Routes::PreferencesRoute, :show
@@ -119,6 +118,8 @@ module Invidious::Routing
get "/channel/:ucid/videos", Routes::Channels, :videos
get "/channel/:ucid/shorts", Routes::Channels, :shorts
get "/channel/:ucid/streams", Routes::Channels, :streams
+ get "/channel/:ucid/podcasts", Routes::Channels, :podcasts
+ get "/channel/:ucid/releases", Routes::Channels, :releases
get "/channel/:ucid/playlists", Routes::Channels, :playlists
get "/channel/:ucid/community", Routes::Channels, :community
get "/channel/:ucid/channels", Routes::Channels, :channels
@@ -229,6 +230,9 @@ module Invidious::Routing
get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts
get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams
+ get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts
+ get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases
+
get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels
{% for route in {"videos", "latest", "playlists", "community", "search"} %}
diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr
index e4b25156..0a2fe1e2 100644
--- a/src/invidious/user/imports.cr
+++ b/src/invidious/user/imports.cr
@@ -6,7 +6,7 @@ struct Invidious::User
# Parse a youtube CSV subscription file
def parse_subscription_export_csv(csv_content : String)
- rows = CSV.new(csv_content, headers: true)
+ rows = CSV.new(csv_content.strip('\n'), headers: true)
subscriptions = Array(String).new
# Counter to limit the amount of imports.
@@ -32,10 +32,10 @@ struct Invidious::User
def parse_playlist_export_csv(user : User, raw_input : String)
# Split the input into head and body content
- raw_head, raw_body = raw_input.split("\n\n", limit: 2, remove_empty: true)
+ raw_head, raw_body = raw_input.strip('\n').split("\n\n", limit: 2, remove_empty: true)
# Create the playlist from the head content
- csv_head = CSV.new(raw_head, headers: true)
+ csv_head = CSV.new(raw_head.strip('\n'), headers: true)
csv_head.next
title = csv_head[4]
description = csv_head[5]
@@ -51,7 +51,7 @@ struct Invidious::User
Invidious::Database::Playlists.update_description(playlist.id, description)
# Add each video to the playlist from the body content
- csv_body = CSV.new(raw_body, headers: true)
+ csv_body = CSV.new(raw_body.strip('\n'), headers: true)
csv_body.each do |row|
video_id = row[0]
if playlist
diff --git a/src/invidious/users.cr b/src/invidious/users.cr
index b763596b..65566d20 100644
--- a/src/invidious/users.cr
+++ b/src/invidious/users.cr
@@ -3,75 +3,6 @@ require "crypto/bcrypt/password"
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
-def get_user(sid, headers, refresh = true)
- if email = Invidious::Database::SessionIDs.select_email(sid)
- user = Invidious::Database::Users.select!(email: email)
-
- if refresh && Time.utc - user.updated > 1.minute
- user, sid = fetch_user(sid, headers)
-
- Invidious::Database::Users.insert(user, update_on_conflict: true)
- Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true)
-
- begin
- view_name = "subscriptions_#{sha256(user.email)}"
- PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
- rescue ex
- end
- end
- else
- user, sid = fetch_user(sid, headers)
-
- Invidious::Database::Users.insert(user, update_on_conflict: true)
- Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true)
-
- begin
- view_name = "subscriptions_#{sha256(user.email)}"
- PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
- rescue ex
- end
- end
-
- return user, sid
-end
-
-def fetch_user(sid, headers)
- feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
- feed = XML.parse_html(feed.body)
-
- channels = feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).compact_map do |channel|
- if {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"]
- nil
- else
- channel["href"].lstrip("/channel/")
- end
- end
-
- channels = get_batch_channels(channels)
-
- email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"]))
- if email
- email = email.content.strip
- else
- email = ""
- end
-
- token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
-
- user = Invidious::User.new({
- updated: Time.utc,
- notifications: [] of String,
- subscriptions: channels,
- email: email,
- preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple),
- password: nil,
- token: token,
- watched: [] of String,
- feed_needs_update: true,
- })
- return user, sid
-end
-
def create_user(sid, email, password)
password = Crypto::Bcrypt::Password.create(password, cost: 10)
token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
@@ -91,38 +22,6 @@ def create_user(sid, email, password)
return user, sid
end
-def subscribe_ajax(channel_id, action, env_headers)
- headers = HTTP::Headers.new
- headers["Cookie"] = env_headers["Cookie"]
-
- html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers)
-
- cookies = HTTP::Cookies.from_client_headers(headers)
- html.cookies.each do |cookie|
- if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name
- if cookies[cookie.name]?
- cookies[cookie.name] = cookie
- else
- cookies << cookie
- end
- end
- end
- headers = cookies.add_request_headers(headers)
-
- if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/)
- session_token = match["session_token"]
-
- headers["content-type"] = "application/x-www-form-urlencoded"
-
- post_req = {
- session_token: session_token,
- }
- post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}"
-
- YT_POOL.client &.post(post_url, headers, form: post_req)
- end
-end
-
def get_subscription_feed(user, max_results = 40, page = 1)
limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE)
offset = (page - 1) * limit
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index 0038a97a..f38b33e5 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -394,7 +394,9 @@ def fetch_video(id, region)
if reason = info["reason"]?
if reason == "Video unavailable"
raise NotFoundException.new(reason.as_s || "")
- else
+ elsif !reason.as_s.starts_with? "Premieres"
+ # dont error when it's a premiere.
+ # we already parsed most of the data and display the premiere date
raise InfoException.new(reason.as_s || "")
end
end
diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr
index 2e8eecc3..9cc0ffdc 100644
--- a/src/invidious/videos/parser.cr
+++ b/src/invidious/videos/parser.cr
@@ -78,7 +78,11 @@ def extract_video_info(video_id : String, proxy_region : String? = nil)
elsif video_id != player_response.dig("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
# See: https://github.com/TeamNewPipe/NewPipe/issues/8713
- raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)")
+ # Line to be reverted if one day we solve the video not available issue.
+ return {
+ "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
+ "reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. <a href=\"https://github.com/iv-org/invidious/issues/3822\">Click here for more info about the issue.</a>"),
+ }
else
reason = nil
end
diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr
index bcba74cf..6aea82ae 100644
--- a/src/invidious/views/add_playlist_items.ecr
+++ b/src/invidious/views/add_playlist_items.ecr
@@ -31,33 +31,5 @@
</script>
<script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
-<div class="pure-g">
- <% videos.each_slice(4) do |slice| %>
- <% slice.each do |item| %>
- <%= rendered "components/item" %>
- <% end %>
- <% end %>
-</div>
-<script src="/js/watched_indicator.js"></script>
-
-<% if query %>
- <%- query_encoded = URI.encode_www_form(query.text, space_to_plus: true) -%>
- <div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if query.page > 1 %>
- <a href="/add_playlist_items?list=<%= plid %>&q=<%= query_encoded %>&page=<%= page - 1 %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if videos.size >= 20 %>
- <a href="/add_playlist_items?list=<%= plid %>&q=<%= query_encoded %>&page=<%= page + 1 %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
- </div>
-<% end %>
+<%= rendered "components/items_paginated" %>
diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr
index 6e62a471..09df106d 100644
--- a/src/invidious/views/channel.ecr
+++ b/src/invidious/views/channel.ecr
@@ -9,13 +9,20 @@
when .streams? then "/channel/#{ucid}/streams"
when .playlists? then "/channel/#{ucid}/playlists"
when .channels? then "/channel/#{ucid}/channels"
+ when .podcasts? then "/channel/#{ucid}/podcasts"
+ when .releases? then "/channel/#{ucid}/releases"
else
"/channel/#{ucid}"
end
youtube_url = "https://www.youtube.com#{relative_url}"
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
--%>
+
+ page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale,
+ base_url: relative_url,
+ ctoken: next_continuation
+ )
+%>
<% content_for "header" do %>
<%- if selected_tab.videos? -%>
@@ -43,21 +50,5 @@
<hr>
</div>
-<div class="pure-g">
-<% items.each do |item| %>
- <%= rendered "components/item" %>
-<% end %>
-</div>
-<script src="/js/watched_indicator.js"></script>
-
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-md-4-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if next_continuation %>
- <a href="<%= relative_url %>?continuation=<%= next_continuation %><% if sort_options.any? sort_by %>&sort_by=<%= sort_by %><% end %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%= rendered "components/items_paginated" %>
diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr
index 59888760..f4164f31 100644
--- a/src/invidious/views/components/channel_info.ecr
+++ b/src/invidious/views/components/channel_info.ecr
@@ -8,29 +8,30 @@
</div>
<% end %>
-<div class="pure-g h-box">
- <div class="pure-u-2-3">
+<div class="pure-g h-box flexible title">
+ <div class="pure-u-1-2 flex-left flexible">
<div class="channel-profile">
<img src="/ggpht<%= channel_profile_pic %>" alt="" />
<span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
</div>
</div>
- <div class="pure-u-1-3">
- <h3 style="text-align:right">
- <a href="/feed/channel/<%= ucid %>"><i class="icon ion-logo-rss"></i></a>
- </h3>
- </div>
-</div>
-<div class="h-box">
- <div id="descriptionWrapper">
- <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p>
+ <div class="pure-u-1-2 flex-right flexible button-container">
+ <div class="pure-u">
+ <% sub_count_text = number_to_short_text(channel.sub_count) %>
+ <%= rendered "components/subscribe_widget" %>
+ </div>
+
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary" dir="auto" href="/feed/channel/<%= ucid %>">
+ <i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
+ </a>
+ </div>
</div>
</div>
<div class="h-box">
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
- <%= rendered "components/subscribe_widget" %>
+ <div id="descriptionWrapper"><p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p></div>
</div>
<div class="pure-g h-box">
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index 7cfd38db..7ffd2d93 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -1,157 +1,146 @@
-<% item_watched = !item.is_a?(SearchChannel | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil %>
+<%-
+ thin_mode = env.get("preferences").as(Preferences).thin_mode
+ item_watched = !item.is_a?(SearchChannel | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil
+ author_verified = item.responds_to?(:author_verified) && item.author_verified
+-%>
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<% case item when %>
<% when SearchChannel %>
- <a href="/channel/<%= item.ucid %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
+ <% if !thin_mode %>
+ <a tabindex="-1" href="/channel/<%= item.ucid %>">
<center>
- <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" />
+ <img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" />
</center>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></p>
- </a>
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder" style="width:56.25%"></div>
+ <% end %>
+
+ <div class="video-card-row flexible">
+ <div class="flex-left"><a href="/channel/<%= item.ucid %>">
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
+ </a></div>
+ </div>
+
<p><%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %></p>
<% if !item.auto_generated %><p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p><% end %>
<h5><%= item.description_html %></h5>
<% when SearchPlaylist, InvidiousPlaylist %>
- <% if item.id.starts_with? "RD" %>
- <% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}" %>
- <% else %>
- <% url = "/playlist?list=#{item.id}" %>
- <% end %>
-
- <a style="width:100%" href="<%= url %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>" alt="" />
- <p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
- </div>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.title) %></p>
- </a>
- <a href="/channel/<%= item.ucid %>">
- <p dir="auto"><b><%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></b></p>
- </a>
- <% when MixVideo %>
- <a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
- <% if item.length_seconds != 0 %>
- <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
- <% end %>
+ <%-
+ if item.id.starts_with? "RD"
+ link_url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}"
+ else
+ link_url = "/playlist?list=#{item.id}"
+ end
+ -%>
- <% if item_watched %>
- <div class="watched-overlay"></div>
- <div class="watched-indicator" data-length="<%= item.length_seconds %>" data-id="<%= item.id %>"></div>
- <% end %>
- </div>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.title) %></p>
- </a>
- <a href="/channel/<%= item.ucid %>">
- <p dir="auto"><b><%= HTML.escape(item.author) %></b></p>
- </a>
- <% when PlaylistVideo %>
- <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
-
- <% if plid_form = env.get?("remove_playlist_items") %>
- <form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <p class="watched">
- <button type="submit" style="all:unset" data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>"><i class="icon ion-md-trash"></i></button>
- </p>
- </form>
- <% end %>
+ <div class="thumbnail">
+ <%- if !thin_mode %>
+ <a tabindex="-1" href="<%= link_url %>">
+ <img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>" alt="" />
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder"></div>
+ <%- end -%>
- <% if item.responds_to?(:live_now) && item.live_now %>
- <p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p>
- <% elsif item.length_seconds != 0 %>
- <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
- <% end %>
+ <div class="bottom-right-overlay">
+ <p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
+ </div>
+ </div>
- <% if item_watched %>
- <div class="watched-overlay"></div>
- <div class="watched-indicator" data-length="<%= item.length_seconds %>" data-id="<%= item.id %>"></div>
- <% end %>
- </div>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.title) %></p>
- </a>
+ <div class="video-card-row">
+ <a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
+ </div>
<div class="video-card-row flexible">
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
- <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %></p>
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
</a></div>
- <% endpoint_params = "?v=#{item.id}&list=#{item.plid}" %>
- <%= rendered "components/video-context-buttons" %>
- </div>
-
- <div class="video-card-row flexible">
- <div class="flex-left">
- <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
- <p dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
- <% elsif Time.utc - item.published > 1.minute %>
- <p dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
- <% end %>
- </div>
-
- <% if item.responds_to?(:views) && item.views %>
- <div class="flex-right">
- <p dir="auto"><%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %></p>
- </div>
- <% end %>
</div>
<% when Category %>
<% else %>
- <a style="width:100%" href="/watch?v=<%= item.id %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
- <% if env.get? "show_watched" %>
- <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <p class="watched">
- <button type="submit" style="all:unset" data-onclick="mark_watched" data-id="<%= item.id %>">
- <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i>
- </button>
- </p>
- </form>
- <% elsif plid_form = env.get? "add_playlist_items" %>
- <form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid_form %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <p class="watched">
- <button type="submit" style="all:unset" data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
- </p>
- </form>
- <% end %>
+ <%-
+ # `endpoint_params` is used for the "video-context-buttons" component
+ if item.is_a?(PlaylistVideo)
+ link_url = "/watch?v=#{item.id}&list=#{item.plid}&index=#{item.index}"
+ endpoint_params = "?v=#{item.id}&list=#{item.plid}"
+ elsif item.is_a?(MixVideo)
+ link_url = "/watch?v=#{item.id}&list=#{item.rdid}"
+ endpoint_params = "?v=#{item.id}&list=#{item.rdid}"
+ else
+ link_url = "/watch?v=#{item.id}"
+ endpoint_params = "?v=#{item.id}"
+ end
+ -%>
- <% if item.responds_to?(:live_now) && item.live_now %>
- <p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p>
- <% elsif item.length_seconds != 0 %>
- <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
- <% end %>
+ <div class="thumbnail">
+ <%- if !thin_mode -%>
+ <a tabindex="-1" href="<%= link_url %>">
+ <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" />
<% if item_watched %>
<div class="watched-overlay"></div>
<div class="watched-indicator" data-length="<%= item.length_seconds %>" data-id="<%= item.id %>"></div>
<% end %>
- </div>
- <% end %>
- <p dir="auto"><%= HTML.escape(item.title) %></p>
- </a>
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder"></div>
+ <%- end -%>
+
+ <div class="top-left-overlay">
+ <%- if env.get? "show_watched" -%>
+ <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
+ <button type="submit" class="pure-button pure-button-secondary low-profile"
+ data-onclick="mark_watched" data-id="<%= item.id %>">
+ <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i>
+ </button>
+ </form>
+ <%- end -%>
+
+ <%- if plid_form = env.get?("add_playlist_items") -%>
+ <%- form_parameters = "action_add_video=1&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
+ <form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
+ <button type="submit" class="pure-button pure-button-secondary low-profile"
+ data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button>
+ </form>
+ <%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%>
+ <%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
+ <form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
+ <button type="submit" class="pure-button pure-button-secondary low-profile"
+ data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>"><i class="icon ion-md-trash"></i></button>
+ </form>
+ <%- end -%>
+ </div>
+
+ <div class="bottom-right-overlay">
+ <%- if item.responds_to?(:live_now) && item.live_now -%>
+ <p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i>&nbsp;<%= translate(locale, "LIVE") %></p>
+ <%- elsif item.length_seconds != 0 -%>
+ <p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
+ <%- end -%>
+ </div>
+ </div>
+
+ <div class="video-card-row">
+ <a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a>
+ </div>
<div class="video-card-row flexible">
<div class="flex-left"><a href="/channel/<%= item.ucid %>">
- <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %><% if !item.is_a?(ChannelVideo) && !item.author_verified.nil? && item.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></p>
+ <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %>
+ <%- if author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end -%>
+ </p>
</a></div>
- <% endpoint_params = "?v=#{item.id}" %>
<%= rendered "components/video-context-buttons" %>
</div>
@@ -159,7 +148,7 @@
<div class="flex-left">
<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>
<p class="video-data" dir="auto"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></p>
- <% elsif Time.utc - item.published > 1.minute %>
+ <% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %>
<p class="video-data" dir="auto"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></p>
<% end %>
</div>
diff --git a/src/invidious/views/components/items_paginated.ecr b/src/invidious/views/components/items_paginated.ecr
new file mode 100644
index 00000000..4534a0a3
--- /dev/null
+++ b/src/invidious/views/components/items_paginated.ecr
@@ -0,0 +1,11 @@
+<%= page_nav_html %>
+
+<div class="pure-g">
+ <%- items.each do |item| -%>
+ <%= rendered "components/item" %>
+ <%- end -%>
+</div>
+
+<%= page_nav_html %>
+
+<script src="/js/watched_indicator.js"></script>
diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr
index b9d5f783..05e4e253 100644
--- a/src/invidious/views/components/subscribe_widget.ecr
+++ b/src/invidious/views/components/subscribe_widget.ecr
@@ -1,22 +1,18 @@
<% if user %>
<% if subscriptions.includes? ucid %>
- <p>
<form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b>
</button>
</form>
- </p>
<% else %>
- <p>
<form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary">
<b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b>
</button>
</form>
- </p>
<% end %>
<script id="subscribe_data" type="application/json">
@@ -33,10 +29,8 @@
</script>
<script src="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% else %>
- <p>
<a id="subscribe" class="pure-button pure-button-primary"
href="/login?referer=<%= env.get("current_page") %>">
<b><%= translate(locale, "Subscribe") %> | <%= sub_count_text %></b>
</a>
- </p>
<% end %>
diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr
index ddb6c983..385ed6b3 100644
--- a/src/invidious/views/components/video-context-buttons.ecr
+++ b/src/invidious/views/components/video-context-buttons.ecr
@@ -1,4 +1,4 @@
-<div class="flex-right">
+<div class="flex-right flexible">
<div class="icon-buttons">
<a title="<%=translate(locale, "videoinfo_watch_on_youTube")%>" href="https://www.youtube.com/watch<%=endpoint_params%>">
<i class="icon ion-logo-youtube"></i>
@@ -6,7 +6,7 @@
<a title="<%=translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1">
<i class="icon ion-md-headset"></i>
</a>
-
+
<% if env.get("preferences").as(Preferences).automatic_instance_redirect%>
<a title="<%=translate(locale, "Switch Invidious Instance")%>" href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>">
<i class="icon ion-md-jet"></i>
diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr
index 548104c8..34157c67 100644
--- a/src/invidious/views/edit_playlist.ecr
+++ b/src/invidious/views/edit_playlist.ecr
@@ -6,35 +6,43 @@
<% end %>
<form class="pure-form" action="/edit_playlist?list=<%= plid %>" method="post">
- <div class="pure-g h-box">
- <div class="pure-u-2-3">
+ <div class="h-box flexible">
+ <div class="flex-right button-container">
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/playlist?list=<%= plid %>">
+ <i class="icon ion-md-close"></i>&nbsp;<%= translate(locale, "generic_button_cancel") %>
+ </a>
+ </div>
+ <div class="pure-u">
+ <button class="pure-button pure-button-secondary low-profile" dir="auto" type="submit">
+ <i class="icon ion-md-save"></i>&nbsp;<%= translate(locale, "generic_button_save") %>
+ </button>
+ </div>
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
+ <i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "generic_button_delete") %>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <div class="h-box flexible title">
+ <div>
<h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= title %>"></h3>
+ </div>
+ </div>
+
+ <div class="h-box">
+ <div class="pure-u-1-1">
<b>
<%= HTML.escape(playlist.author) %> |
<%= translate_count(locale, "generic_videos_count", playlist.video_count) %> |
- <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> |
- <i class="icon <%= {"ion-md-globe", "ion-ios-unlock", "ion-ios-lock"}[playlist.privacy.value] %>"></i>
- <select name="privacy">
- <% {"Public", "Unlisted", "Private"}.each do |option| %>
- <option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option>
- <% end %>
- </select>
</b>
- </div>
- <div class="pure-u-1-3" style="text-align:right">
- <h3>
- <div class="pure-g user-field">
- <div class="pure-u-1-3">
- <a href="javascript:void(0)">
- <button type="submit" style="all:unset">
- <i class="icon ion-md-save"></i>
- </button>
- </a>
- </div>
- <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div>
- <div class="pure-u-1-3"><a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a></div>
- </div>
- </h3>
+ <select name="privacy">
+ <%- {"Public", "Unlisted", "Private"}.each do |option| -%>
+ <option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option>
+ <%- end -%>
+ </select>
</div>
</div>
@@ -44,40 +52,9 @@
<input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</form>
-<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
-<div class="h-box" style="text-align:right">
- <h3>
- <a href="/add_playlist_items?list=<%= plid %>"><i class="icon ion-md-add"></i></a>
- </h3>
-</div>
-<% end %>
-
<div class="h-box">
<hr>
</div>
-<div class="pure-g">
-<% videos.each do |item| %>
- <%= rendered "components/item" %>
-<% end %>
-</div>
-
-<script src="/js/watched_indicator.js"></script>
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if videos.size == 100 %>
- <a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%= rendered "components/items_paginated" %>
diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr
index 2234b297..bda4e1f3 100644
--- a/src/invidious/views/feeds/history.ecr
+++ b/src/invidious/views/feeds/history.ecr
@@ -31,39 +31,29 @@
<% watched.each do |item| %>
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
- <a style="width:100%" href="/watch?v=<%= item %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
- <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" />
- <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
- <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
- <p class="watched">
- <button type="submit" style="all:unset" data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
- </p>
- </form>
- </div>
- <p></p>
- <% end %>
- </a>
+ <div class="thumbnail">
+ <a style="width:100%" href="/watch?v=<%= item %>">
+ <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" />
+ </a>
+
+ <div class="top-left-overlay"><div class="watched">
+ <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post">
+ <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
+ <button type="submit" class="pure-button pure-button-secondary low-profile"
+ data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
+ </form>
+ </div></div>
+ </div>
+ <p></p>
</div>
</div>
<% end %>
</div>
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/feed/history?page=<%= page - 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if watched.size >= max_results %>
- <a href="/feed/history?page=<%= page + 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%=
+ IV::Frontend::Pagination.nav_numeric(locale,
+ base_url: base_url,
+ current_page: page,
+ show_next: (watched.size >= max_results)
+ )
+%>
diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr
index 9c69c5b0..c36bd00f 100644
--- a/src/invidious/views/feeds/subscriptions.ecr
+++ b/src/invidious/views/feeds/subscriptions.ecr
@@ -56,6 +56,7 @@
</script>
<script src="/js/watched_widget.js"></script>
+
<div class="pure-g">
<% videos.each do |item| %>
<%= rendered "components/item" %>
@@ -64,20 +65,10 @@
<script src="/js/watched_indicator.js"></script>
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/feed/subscriptions?page=<%= page - 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if (videos.size + notifications.size) == max_results %>
- <a href="/feed/subscriptions?page=<%= page + 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%=
+ IV::Frontend::Pagination.nav_numeric(locale,
+ base_url: base_url,
+ current_page: page,
+ show_next: ((videos.size + notifications.size) == max_results)
+ )
+%>
diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr
index 3351c21c..2000337e 100644
--- a/src/invidious/views/hashtag.ecr
+++ b/src/invidious/views/hashtag.ecr
@@ -4,38 +4,5 @@
<hr/>
-<div class="pure-g h-box v-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <%- if page > 1 -%>
- <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
- <%- end -%>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <%- if videos.size >= 60 -%>
- <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
- <%- end -%>
- </div>
-</div>
-<div class="pure-g">
- <%- videos.each do |item| -%>
- <%= rendered "components/item" %>
- <%- end -%>
-</div>
-
-<script src="/js/watched_indicator.js"></script>
-
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <%- if page > 1 -%>
- <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
- <%- end -%>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <%- if videos.size >= 60 -%>
- <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
- <%- end -%>
- </div>
-</div>
+<%= rendered "components/items_paginated" %>
diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr
index a04acf4c..ee9ba87b 100644
--- a/src/invidious/views/playlist.ecr
+++ b/src/invidious/views/playlist.ecr
@@ -6,9 +6,50 @@
<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" />
<% end %>
-<div class="pure-g h-box">
- <div class="pure-u-2-3">
- <h3><%= title %></h3>
+<div class="h-box flexible title">
+ <div class="flex-left"><h3><%= title %></h3></div>
+
+ <div class="flex-right button-container">
+ <%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%>
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/add_playlist_items?list=<%= plid %>">
+ <i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "playlist_button_add_items") %>
+ </a>
+ </div>
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/edit_playlist?list=<%= plid %>">
+ <i class="icon ion-md-create"></i>&nbsp;<%= translate(locale, "generic_button_edit") %>
+ </a>
+ </div>
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
+ <i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "generic_button_delete") %>
+ </a>
+ </div>
+ <%- else -%>
+ <div class="pure-u">
+ <%- if IV::Database::Playlists.exists?(playlist.id) -%>
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/subscribe_playlist?list=<%= plid %>">
+ <i class="icon ion-md-add"></i>&nbsp;<%= translate(locale, "Subscribe") %>
+ </a>
+ <%- else -%>
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>">
+ <i class="icon ion-md-trash"></i>&nbsp;<%= translate(locale, "Unsubscribe") %>
+ </a>
+ <%- end -%>
+ </div>
+ <%- end -%>
+
+ <div class="pure-u">
+ <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/feed/playlist/<%= plid %>">
+ <i class="icon ion-logo-rss"></i>&nbsp;<%= translate(locale, "generic_button_rss") %>
+ </a>
+ </div>
+ </div>
+</div>
+
+<div class="h-box">
+ <div class="pure-u-1-1">
<% if playlist.is_a? InvidiousPlaylist %>
<b>
<% if playlist.author == user.try &.email %>
@@ -54,37 +95,12 @@
</div>
<% end %>
</div>
- <div class="pure-u-1-3" style="text-align:right">
- <h3>
- <div class="pure-g user-field">
- <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
- <div class="pure-u-1-3"><a href="/edit_playlist?list=<%= plid %>"><i class="icon ion-md-create"></i></a></div>
- <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div>
- <% else %>
- <% if Invidious::Database::Playlists.exists?(playlist.id) %>
- <div class="pure-u-1-3"><a href="/subscribe_playlist?list=<%= plid %>"><i class="icon ion-md-add"></i></a></div>
- <% else %>
- <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div>
- <% end %>
- <% end %>
- <div class="pure-u-1-3"><a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a></div>
- </div>
- </h3>
- </div>
</div>
<div class="h-box">
<div id="descriptionWrapper"><%= playlist.description_html %></div>
</div>
-<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %>
-<div class="h-box" style="text-align:right">
- <h3>
- <a href="/add_playlist_items?list=<%= plid %>"><i class="icon ion-md-add"></i></a>
- </h3>
-</div>
-<% end %>
-
<div class="h-box">
<hr>
</div>
@@ -100,28 +116,5 @@
<script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script>
<% end %>
-<div class="pure-g">
-<% videos.each do |item| %>
- <%= rendered "components/item" %>
-<% end %>
-</div>
-
-<script src="/js/watched_indicator.js"></script>
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <% if page > 1 %>
- <a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>">
- <%= translate(locale, "Previous page") %>
- </a>
- <% end %>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <% if page_count != 1 && page < page_count %>
- <a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>">
- <%= translate(locale, "Next page") %>
- </a>
- <% end %>
- </div>
-</div>
+<%= rendered "components/items_paginated" %>
diff --git a/src/invidious/views/privacy.ecr b/src/invidious/views/privacy.ecr
index 643f880b..bc5ff40b 100644
--- a/src/invidious/views/privacy.ecr
+++ b/src/invidious/views/privacy.ecr
@@ -16,12 +16,11 @@
<li>a list of channel UCIDs the user is subscribed to</li>
<li>a user ID (for persistent storage of subscriptions and preferences)</li>
<li>a json object containing user preferences</li>
- <li>a hashed password if applicable (not present on google accounts)</li>
+ <li>a hashed password</li>
<li>a randomly generated token for providing an RSS feed of a user's subscriptions</li>
<li>a list of video IDs identifying watched videos</li>
</ul>
<p>Users can clear their watch history using the <a href="/clear_watch_history">clear watch history</a> page.</p>
- <p>If a user is logged in with a Google account, no password will ever be stored. This website uses the session token provided by Google to identify a user, but does not store the information required to make requests on a user's behalf without their knowledge or consent.</p>
<h3>Data you passively provide</h3>
<p>When you request any resource from this website (for example: a page, a font, an image, or an API endpoint) information about the request may be logged.</p>
diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr
index a7469e36..b1300214 100644
--- a/src/invidious/views/search.ecr
+++ b/src/invidious/views/search.ecr
@@ -7,21 +7,8 @@
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<hr/>
-<div class="pure-g h-box v-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <%- if query.page > 1 -%>
- <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
- <%- end -%>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <%- if videos.size >= 20 -%>
- <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
- <%- end -%>
- </div>
-</div>
-<%- if videos.empty? -%>
+<%- if items.empty? -%>
<div class="h-box no-results-error">
<div>
<%= translate(locale, "search_message_no_results") %><br/><br/>
@@ -30,25 +17,5 @@
</div>
</div>
<%- else -%>
-<div class="pure-g">
- <%- videos.each do |item| -%>
- <%= rendered "components/item" %>
- <%- end -%>
-</div>
+ <%= rendered "components/items_paginated" %>
<%- end -%>
-
-<script src="/js/watched_indicator.js"></script>
-
-<div class="pure-g h-box">
- <div class="pure-u-1 pure-u-lg-1-5">
- <%- if query.page > 1 -%>
- <a href="<%= url_prev_page %>"><%= translate(locale, "Previous page") %></a>
- <%- end -%>
- </div>
- <div class="pure-u-1 pure-u-lg-3-5"></div>
- <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right">
- <%- if videos.size >= 20 -%>
- <a href="<%= url_next_page %>"><%= translate(locale, "Next page") %></a>
- <%- end -%>
- </div>
-</div>
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index aa0fc15f..77265679 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -111,14 +111,6 @@
</div>
<% end %>
- <% if env.get? "user" %>
- <% if !HMAC_KEY_CONFIGURED && CONFIG.admins.includes? env.get("user").as(Invidious::User).email %>
- <div class="h-box">
- <h3><p>Message for admin: please configure hmac_key, <a href="https://github.com/iv-org/invidious/issues/3854">see more here</a>.</p></h3>
- </div>
- <% end %>
- <% end %>
-
<%= content %>
<footer>
diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr
index 01d7a210..2b03d280 100644
--- a/src/invidious/views/user/login.ecr
+++ b/src/invidious/views/user/login.ecr
@@ -7,42 +7,6 @@
<div class="pure-u-1 pure-u-lg-3-5">
<div class="h-box">
<% case account_type when %>
- <% when "google" %>
- <form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=google" method="post">
- <fieldset>
- <% if email %>
- <input name="email" type="hidden" value="<%= HTML.escape(email) %>">
- <% else %>
- <label for="email"><%= translate(locale, "E-mail") %> :</label>
- <input required class="pure-input-1" name="email" type="email" placeholder="<%= translate(locale, "E-mail") %>">
- <% end %>
-
- <% if password %>
- <input name="password" type="hidden" value="<%= HTML.escape(password) %>">
- <% else %>
- <label for="password"><%= translate(locale, "Password") %> :</label>
- <input required class="pure-input-1" name="password" type="password" placeholder="<%= translate(locale, "Password") %>">
- <% end %>
-
- <% if prompt %>
- <label for="tfa"><%= translate(locale, prompt) %> :</label>
- <input required class="pure-input-1" name="tfa" type="text" placeholder="<%= translate(locale, prompt) %>">
- <% end %>
-
- <% if tfa %>
- <input type="hidden" name="tfa" value="<%= tfa %>">
- <% end %>
-
- <% if captcha %>
- <img style="width:50%" src="/Captcha?v=2&ctoken=<%= captcha[:tokens][0] %>"/>
- <input type="hidden" name="token" value="<%= captcha[:tokens][0] %>">
- <label for="answer"><%= translate(locale, "Answer") %> :</label>
- <input type="text" name="answer" type="text" placeholder="<%= translate(locale, "Answer") %>">
- <% end %>
-
- <button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Sign In") %></button>
- </fieldset>
- </form>
<% else # "invidious" %>
<form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post">
<fieldset>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 5b3190f3..498d57a1 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -204,19 +204,28 @@ we're going to need to do it here in order to allow for translations.
</div>
<div class="pure-u-1 <% if params.related_videos || plid %>pure-u-lg-3-5<% else %>pure-u-md-4-5<% end %>">
- <div class="h-box">
- <a href="/channel/<%= video.ucid %>" style="display:block;width:fit-content;width:-moz-fit-content">
- <div class="channel-profile">
- <% if !video.author_thumbnail.empty? %>
- <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
- <% end %>
- <span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
- </div>
- </a>
- <% sub_count_text = video.sub_count_text %>
- <%= rendered "components/subscribe_widget" %>
+ <div class="pure-g h-box flexible title">
+ <div class="pure-u-1-2 flex-left flexible">
+ <a href="/channel/<%= video.ucid %>">
+ <div class="channel-profile">
+ <% if !video.author_thumbnail.empty? %>
+ <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
+ <% end %>
+ <span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
+ </div>
+ </a>
+ </div>
+ <div class="pure-u-1-2 flex-right flexible button-container">
+ <div class="pure-u">
+ <% sub_count_text = video.sub_count_text %>
+ <%= rendered "components/subscribe_widget" %>
+ </div>
+ </div>
+ </div>
+
+ <div class="h-box">
<p id="published-date">
<% if video.premiere_timestamp.try &.> Time.utc %>
<b><%= video.premiere_timestamp.try { |t| translate(locale, "Premieres `x`", t.to_s("%B %-d, %R UTC")) } %></b>
@@ -295,15 +304,28 @@ we're going to need to do it here in order to allow for translations.
<% video.related_videos.each do |rv| %>
<% if rv["id"]? %>
- <a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>">
- <% if !env.get("preferences").as(Preferences).thin_mode %>
- <div class="thumbnail">
+ <div class="pure-u-1">
+
+ <div class="thumbnail">
+ <%- if !env.get("preferences").as(Preferences).thin_mode -%>
+ <a tabindex="-1" href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>">
<img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg" alt="" />
- <p class="length"><%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %></p>
- </div>
- <% end %>
- <p style="width:100%"><%= rv["title"] %></p>
- </a>
+ </a>
+ <%- else -%>
+ <div class="thumbnail-placeholder"></div>
+ <%- end -%>
+
+ <div class="bottom-right-overlay">
+ <%- if (length_seconds = rv["length_seconds"]?.try &.to_i?) && length_seconds != 0 -%>
+ <p class="length"><%= recode_length_seconds(length_seconds) %></p>
+ <%- end -%>
+ </div>
+ </div>
+
+ <div class="video-card-row">
+ <a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>"><p dir="auto"><%= HTML.escape(rv["title"]) %></p></a>
+ </div>
+
<h5 class="pure-g">
<div class="pure-u-14-24">
<% if rv["ucid"]? %>
@@ -321,6 +343,8 @@ we're going to need to do it here in order to allow for translations.
%></b>
</div>
</h5>
+
+ </div>
<% end %>
<% end %>
</div>
diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr
index 46e5bf85..658731cf 100644
--- a/src/invidious/yt_backend/connection_pool.cr
+++ b/src/invidious/yt_backend/connection_pool.cr
@@ -8,13 +8,15 @@
def add_yt_headers(request)
if request.headers["User-Agent"] == "Crystal"
- request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"
+ request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
end
+
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
+
# Preserve original cookies and add new YT consent cookie for EU servers
- request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+"
+ request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
if !CONFIG.cookies.empty?
request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
end
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index 6686e6e7..e5029dc5 100644
--- a/src/invidious/yt_backend/extractors.cr
+++ b/src/invidious/yt_backend/extractors.cr
@@ -408,8 +408,8 @@ private module Parsers
# Returns nil when the given object isn't a RichItemRenderer
#
# A richItemRenderer seems to be a simple wrapper for a videoRenderer, used
- # by the result page for hashtags. It is located inside a continuationItems
- # container.
+ # by the result page for hashtags and for the podcast tab on channels.
+ # It is located inside a continuationItems container for hashtags.
#
module RichItemRendererParser
def self.process(item : JSON::Any, author_fallback : AuthorFallback)
@@ -421,6 +421,7 @@ private module Parsers
private def self.parse(item_contents, author_fallback)
child = VideoRendererParser.process(item_contents, author_fallback)
child ||= ReelItemRendererParser.process(item_contents, author_fallback)
+ child ||= PlaylistRendererParser.process(item_contents, author_fallback)
return child
end
diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr
index 91a9332c..3dd9e9d8 100644
--- a/src/invidious/yt_backend/youtube_api.cr
+++ b/src/invidious/yt_backend/youtube_api.cr
@@ -7,16 +7,18 @@ module YoutubeAPI
private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
- private ANDROID_APP_VERSION = "17.33.42"
+ private ANDROID_APP_VERSION = "18.20.38"
# github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1308
- private ANDROID_USER_AGENT = "com.google.android.youtube/17.33.42 (Linux; U; Android 12; US) gzip"
+ private ANDROID_USER_AGENT = "com.google.android.youtube/18.20.38 (Linux; U; Android 12; US) gzip"
private ANDROID_SDK_VERSION = 31_i64
private ANDROID_VERSION = "12"
- private IOS_APP_VERSION = "17.33.2"
+
+ private IOS_APP_VERSION = "18.21.3"
# github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1330
- private IOS_USER_AGENT = "com.google.ios.youtube/17.33.2 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)"
+ private IOS_USER_AGENT = "com.google.ios.youtube/18.21.3 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)"
# github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1224
- private IOS_VERSION = "15.6.0.19G71"
+ private IOS_VERSION = "15.6.0.19G71"
+
private WINDOWS_VERSION = "10.0"
# Enumerate used to select one of the clients supported by the API
@@ -43,7 +45,7 @@ module YoutubeAPI
ClientType::Web => {
name: "WEB",
name_proto: "1",
- version: "2.20221118.01.00",
+ version: "2.20230602.01.00",
api_key: DEFAULT_API_KEY,
screen: "WATCH_FULL_SCREEN",
os_name: "Windows",
@@ -63,7 +65,7 @@ module YoutubeAPI
ClientType::WebMobile => {
name: "MWEB",
name_proto: "2",
- version: "2.20220805.01.00",
+ version: "2.20230531.05.00",
api_key: DEFAULT_API_KEY,
os_name: "Android",
os_version: ANDROID_VERSION,