summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ext/kemal_static_file_handler.cr28
-rw-r--r--src/invidious.cr301
-rw-r--r--src/invidious/channels/about.cr20
-rw-r--r--src/invidious/channels/community.cr12
-rw-r--r--src/invidious/comments.cr15
-rw-r--r--src/invidious/config.cr2
-rw-r--r--src/invidious/exceptions.cr15
-rw-r--r--src/invidious/helpers/errors.cr8
-rw-r--r--src/invidious/playlists.cr2
-rw-r--r--src/invidious/routes/api/manifest.cr21
-rw-r--r--src/invidious/routes/api/v1/authenticated.cr2
-rw-r--r--src/invidious/routes/api/v1/channels.cr6
-rw-r--r--src/invidious/routes/api/v1/videos.cr8
-rw-r--r--src/invidious/routes/before_all.cr152
-rw-r--r--src/invidious/routes/channels.cr9
-rw-r--r--src/invidious/routes/embed.cr16
-rw-r--r--src/invidious/routes/errors.cr47
-rw-r--r--src/invidious/routes/feeds.cr8
-rw-r--r--src/invidious/routes/playlists.cr14
-rw-r--r--src/invidious/routes/search.cr6
-rw-r--r--src/invidious/routes/video_playback.cr8
-rw-r--r--src/invidious/routes/watch.cr3
-rw-r--r--src/invidious/routing.cr337
-rw-r--r--src/invidious/search/query.cr12
-rw-r--r--src/invidious/user/cookies.cr4
-rw-r--r--src/invidious/videos.cr146
-rw-r--r--src/invidious/views/components/item.ecr33
-rw-r--r--src/invidious/views/components/player.ecr15
-rw-r--r--src/invidious/views/embed.ecr1
-rw-r--r--src/invidious/views/feeds/history.ecr4
-rw-r--r--src/invidious/views/licenses.ecr18
-rw-r--r--src/invidious/views/search.ecr10
-rw-r--r--src/invidious/views/template.ecr4
-rw-r--r--src/invidious/views/user/subscription_manager.ecr4
-rw-r--r--src/invidious/views/user/token_manager.ecr4
-rw-r--r--src/invidious/views/watch.ecr8
-rw-r--r--src/invidious/yt_backend/extractors.cr28
-rw-r--r--src/invidious/yt_backend/extractors_utils.cr2
-rw-r--r--src/invidious/yt_backend/youtube_api.cr99
39 files changed, 817 insertions, 615 deletions
diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr
index 6ef2d74c..eb068aeb 100644
--- a/src/ext/kemal_static_file_handler.cr
+++ b/src/ext/kemal_static_file_handler.cr
@@ -111,7 +111,7 @@ module Kemal
if @fallthrough
call_next(context)
else
- context.response.status_code = 405
+ context.response.status = HTTP::Status::METHOD_NOT_ALLOWED
context.response.headers.add("Allow", "GET, HEAD")
end
return
@@ -124,7 +124,7 @@ module Kemal
# File path cannot contains '\0' (NUL) because all filesystem I know
# don't accept '\0' character as file name.
if request_path.includes? '\0'
- context.response.status_code = 400
+ context.response.status = HTTP::Status::BAD_REQUEST
return
end
@@ -143,13 +143,15 @@ module Kemal
add_cache_headers(context.response.headers, last_modified)
if cache_request?(context, last_modified)
- context.response.status_code = 304
+ context.response.status = HTTP::Status::NOT_MODIFIED
return
end
send_file(context, file_path, file[:data], file[:filestat])
else
- is_dir = Dir.exists? file_path
+ file_info = File.info?(file_path)
+ is_dir = file_info.try &.directory? || false
+ is_file = file_info.try &.file? || false
if request_path != expanded_path
redirect_to context, expanded_path
@@ -157,19 +159,21 @@ module Kemal
redirect_to context, expanded_path + '/'
end
- if Dir.exists?(file_path)
+ return call_next(context) if file_info.nil?
+
+ if is_dir
if config.is_a?(Hash) && config["dir_listing"] == true
context.response.content_type = "text/html"
directory_listing(context.response, request_path, file_path)
else
call_next(context)
end
- elsif File.exists?(file_path)
- last_modified = modification_time(file_path)
+ elsif is_file
+ last_modified = file_info.modification_time
add_cache_headers(context.response.headers, last_modified)
if cache_request?(context, last_modified)
- context.response.status_code = 304
+ context.response.status = HTTP::Status::NOT_MODIFIED
return
end
@@ -177,14 +181,12 @@ module Kemal
data = Bytes.new(size)
File.open(file_path, &.read(data))
- filestat = File.info(file_path)
-
- @cached_files[file_path] = {data: data, filestat: filestat}
- send_file(context, file_path, data, filestat)
+ @cached_files[file_path] = {data: data, filestat: file_info}
+ send_file(context, file_path, data, file_info)
else
send_file(context, file_path)
end
- else
+ else # Not a normal file (FIFO/device/socket)
call_next(context)
end
end
diff --git a/src/invidious.cr b/src/invidious.cr
index 4952b365..0601d5b2 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -133,12 +133,13 @@ Invidious::Database.check_integrity(CONFIG)
# Running the script by itself would show some colorful feedback while this doesn't.
# Perhaps we should just move the script to runtime in order to get that feedback?
- {% puts "\nChecking player dependencies...\n" %}
+ {% puts "\nChecking player dependencies, this may take more than 20 minutes... If it is stuck, check your internet connection.\n" %}
{% if flag?(:minified_player_dependencies) %}
{% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %}
{% else %}
{% puts run("../scripts/fetch-player-dependencies.cr").stringify %}
{% end %}
+ {% puts "\nDone checking player dependencies, now compiling Invidious...\n" %}
{% end %}
# Start jobs
@@ -177,305 +178,19 @@ def popular_videos
Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get
end
-before_all do |env|
- preferences = Preferences.from_json("{}")
-
- begin
- if prefs_cookie = env.request.cookies["PREFS"]?
- preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value))
- else
- if language_header = env.request.headers["Accept-Language"]?
- if language = ANG.language_negotiator.best(language_header, LOCALES.keys)
- preferences.locale = language.header
- end
- end
- end
- rescue
- preferences = Preferences.from_json("{}")
- end
-
- env.set "preferences", preferences
- env.response.headers["X-XSS-Protection"] = "1; mode=block"
- env.response.headers["X-Content-Type-Options"] = "nosniff"
-
- # Allow media resources to be loaded from google servers
- # TODO: check if *.youtube.com can be removed
- if CONFIG.disabled?("local") || !preferences.local
- extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
- else
- extra_media_csp = ""
- end
-
- # Only allow the pages at /embed/* to be embedded
- if env.request.resource.starts_with?("/embed")
- frame_ancestors = "'self' http: https:"
- else
- frame_ancestors = "'none'"
- end
-
- # TODO: Remove style-src's 'unsafe-inline', requires to remove all
- # inline styles (<style> [..] </style>, style=" [..] ")
- env.response.headers["Content-Security-Policy"] = {
- "default-src 'none'",
- "script-src 'self'",
- "style-src 'self' 'unsafe-inline'",
- "img-src 'self' data:",
- "font-src 'self' data:",
- "connect-src 'self'",
- "manifest-src 'self'",
- "media-src 'self' blob:" + extra_media_csp,
- "child-src 'self' blob:",
- "frame-src 'self'",
- "frame-ancestors " + frame_ancestors,
- }.join("; ")
-
- env.response.headers["Referrer-Policy"] = "same-origin"
-
- # Ask the chrom*-based browsers to disable FLoC
- # See: https://blog.runcloud.io/google-floc/
- env.response.headers["Permissions-Policy"] = "interest-cohort=()"
-
- if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts
- env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
- end
-
- next if {
- "/sb/",
- "/vi/",
- "/s_p/",
- "/yts/",
- "/ggpht/",
- "/api/manifest/",
- "/videoplayback",
- "/latest_version",
- "/download",
- }.any? { |r| env.request.resource.starts_with? r }
-
- if env.request.cookies.has_key? "SID"
- sid = env.request.cookies["SID"].value
-
- if sid.starts_with? "v1:"
- raise "Cannot use token as SID"
- end
+# Routing
- # 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
- end
- end
-
- dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
- thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s
- thin_mode = thin_mode == "true"
- locale = env.params.query["hl"]? || preferences.locale
-
- preferences.dark_mode = dark_mode
- preferences.thin_mode = thin_mode
- preferences.locale = locale
- env.set "preferences", preferences
-
- current_page = env.request.path
- if env.request.query
- query = HTTP::Params.parse(env.request.query.not_nil!)
-
- if query["referer"]?
- query["referer"] = get_referer(env, "/")
- end
-
- current_page += "?#{query}"
- end
-
- env.set "current_page", URI.encode_www_form(current_page)
+before_all do |env|
+ Invidious::Routes::BeforeAll.handle(env)
end
-{% unless flag?(:api_only) %}
- Invidious::Routing.get "/", Invidious::Routes::Misc, :home
- Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy
- Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses
-
- Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home
- Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home
- Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos
- Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists
- Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community
- Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about
- Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Channels, :live
- Invidious::Routing.get "/user/:user/live", Invidious::Routes::Channels, :live
- Invidious::Routing.get "/c/:user/live", Invidious::Routes::Channels, :live
-
- ["", "/videos", "/playlists", "/community", "/about"].each do |path|
- # /c/LinusTechTips
- Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect
- # /user/linustechtips | Not always the same as /c/
- Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect
- # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
- Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect
- # /profile?user=linustechtips
- Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile
- end
-
- Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle
- Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched
- Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect
- Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect
- Invidious::Routing.get "/clip/:clip", Invidious::Routes::Watch, :clip
- Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect
- Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect
- Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect
- Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect
-
- Invidious::Routing.post "/download", Invidious::Routes::Watch, :download
-
- Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect
- Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show
-
- Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new
- Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create
- Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe
- Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page
- Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete
- Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit
- Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update
- Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page
- Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax
- Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show
- Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix
- Invidious::Routing.get "/watch_videos", Invidious::Routes::Playlists, :watch_videos
-
- Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch
- Invidious::Routing.get "/results", Invidious::Routes::Search, :results
- Invidious::Routing.get "/search", Invidious::Routes::Search, :search
- Invidious::Routing.get "/hashtag/:hashtag", Invidious::Routes::Search, :hashtag
-
- # User routes
- define_user_routes()
-
- # Feeds
- Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
- Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists
- Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular
- Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending
- Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions
- Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history
-
- # RSS Feeds
- Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel
- Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private
- Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist
- Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos
-
- # Support push notifications via PubSubHubbub
- Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get
- Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
-
- Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify
-
- Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription
- Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager
-{% end %}
-
-Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht
-Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard
-Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard
-Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image
-Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image
-Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails
-
-# API routes (macro)
-define_v1_api_routes()
-
-# Video playback (macros)
-define_api_manifest_routes()
-define_video_playback_routes()
+Invidious::Routing.register_all
error 404 do |env|
- if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
- item = md["id"]
-
- # Check if item is branding URL e.g. https://youtube.com/gaming
- response = YT_POOL.client &.get("/#{item}")
-
- if response.status_code == 301
- response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target)
- end
-
- if response.body.empty?
- env.response.headers["Location"] = "/"
- halt env, status_code: 302
- end
-
- html = XML.parse_html(response.body)
- ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
-
- if ucid
- env.response.headers["Location"] = "/channel/#{ucid}"
- halt env, status_code: 302
- end
-
- params = [] of String
- env.params.query.each do |k, v|
- params << "#{k}=#{v}"
- end
- params = params.join("&")
-
- url = "/watch?v=#{item}"
- if !params.empty?
- url += "&#{params}"
- end
-
- # Check if item is video ID
- if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404
- env.response.headers["Location"] = url
- halt env, status_code: 302
- end
- end
-
- env.response.headers["Location"] = "/"
- halt env, status_code: 302
+ Invidious::Routes::ErrorRoutes.error_404(env)
end
error 500 do |env, ex|
- locale = env.get("preferences").as(Preferences).locale
error_template(500, ex)
end
@@ -483,6 +198,8 @@ static_headers do |response|
response.headers.add("Cache-Control", "max-age=2629800")
end
+# Init Kemal
+
public_folder "assets"
Kemal.config.powered_by_header = false
diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr
index 565f2bca..f60ee7af 100644
--- a/src/invidious/channels/about.cr
+++ b/src/invidious/channels/about.cr
@@ -31,7 +31,12 @@ def get_about_info(ucid, locale) : AboutChannel
end
if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR"
- raise InfoException.new(initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s)
+ error_message = initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s
+ if error_message == "This channel does not exist."
+ raise NotFoundException.new(error_message)
+ else
+ raise InfoException.new(error_message)
+ end
end
if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]?
@@ -54,9 +59,6 @@ def get_about_info(ucid, locale) : AboutChannel
banner = banners.try &.[-1]?.try &.["url"].as_s?
description_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]
-
- is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
- allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
else
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
@@ -74,13 +76,17 @@ def get_about_info(ucid, locale) : AboutChannel
# end
description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?
-
- is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
- allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s)
end
+ is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool
+
+ allowed_regions = initdata
+ .dig?("microformat", "microformatDataRenderer", "availableCountries")
+ .try &.as_a.map(&.as_s) || [] of String
+
description = !description_node.nil? ? description_node.as_s : ""
description_html = HTML.escape(description)
+
if !description_node.nil?
if description_node.as_h?.nil?
description_node = text_to_parsed_content(description_node.as_s)
diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr
index 4701ecbd..2a2c74aa 100644
--- a/src/invidious/channels/community.cr
+++ b/src/invidious/channels/community.cr
@@ -6,20 +6,18 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
end
if response.status_code != 200
- raise InfoException.new("This channel does not exist.")
+ raise NotFoundException.new("This channel does not exist.")
end
ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"]
if !continuation || continuation.empty?
initial_data = extract_initial_data(response.body)
- body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]?
+ body = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
if !body
raise InfoException.new("Could not extract community tab.")
end
-
- body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]
else
continuation = produce_channel_community_continuation(ucid, continuation)
@@ -49,7 +47,11 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode)
error_message = (message["text"]["simpleText"]? ||
message["text"]["runs"]?.try &.[0]?.try &.["text"]?)
.try &.as_s || ""
- raise InfoException.new(error_message)
+ if error_message == "This channel does not exist."
+ raise NotFoundException.new(error_message)
+ else
+ raise InfoException.new(error_message)
+ end
end
response = JSON.build do |json|
diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr
index 593189fd..d691ca36 100644
--- a/src/invidious/comments.cr
+++ b/src/invidious/comments.cr
@@ -95,7 +95,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
contents = body["contents"]?
header = body["header"]?
else
- raise InfoException.new("Could not fetch comments")
+ raise NotFoundException.new("Comments not found.")
end
if !contents
@@ -201,15 +201,6 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
end
if node_replies && !response["commentRepliesContinuation"]?
- if node_replies["moreText"]?
- reply_count = (node_replies["moreText"]["simpleText"]? || node_replies["moreText"]["runs"]?.try &.[0]?.try &.["text"]?)
- .try &.as_s.gsub(/\D/, "").to_i? || 1
- elsif node_replies["viewReplies"]?
- reply_count = node_replies["viewReplies"]["buttonRenderer"]["text"]?.try &.["runs"][1]?.try &.["text"]?.try &.as_s.to_i? || 1
- else
- reply_count = 1
- end
-
if node_replies["continuations"]?
continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s
elsif node_replies["contents"]?
@@ -219,7 +210,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b
json.field "replies" do
json.object do
- json.field "replyCount", reply_count
+ json.field "replyCount", node_comment["replyCount"]? || 1
json.field "continuation", continuation
end
end
@@ -290,7 +281,7 @@ def fetch_reddit_comments(id, sort_by = "confidence")
thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink)
else
- raise InfoException.new("Could not fetch comments")
+ raise NotFoundException.new("Comments not found.")
end
client.close
diff --git a/src/invidious/config.cr b/src/invidious/config.cr
index a077c7fd..786b65df 100644
--- a/src/invidious/config.cr
+++ b/src/invidious/config.cr
@@ -75,7 +75,7 @@ class Config
@[YAML::Field(converter: Preferences::URIConverter)]
property database_url : URI = URI.parse("")
# Use polling to keep decryption function up to date
- property decrypt_polling : Bool = true
+ property decrypt_polling : Bool = false
# Used for crawling channels: threads should check all videos uploaded by a channel
property full_refresh : Bool = false
# Used to tell Invidious it is behind a proxy, so links to resources should be https://
diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr
index bfaa3fd5..425c08da 100644
--- a/src/invidious/exceptions.cr
+++ b/src/invidious/exceptions.cr
@@ -1,3 +1,11 @@
+# InfoExceptions are for displaying information to the user.
+#
+# An InfoException might or might not indicate that something went wrong.
+# Historically Invidious didn't differentiate between these two options, so to
+# maintain previous functionality InfoExceptions do not print backtraces.
+class InfoException < Exception
+end
+
# Exception used to hold the bogus UCID during a channel search.
class ChannelSearchException < InfoException
getter channel : String
@@ -18,3 +26,10 @@ class BrokenTubeException < Exception
return "Missing JSON element \"#{@element}\""
end
end
+
+# Exception threw when an element is not found.
+class NotFoundException < InfoException
+end
+
+class VideoNotAvailableException < Exception
+end
diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr
index b80dcdaf..6e5a975d 100644
--- a/src/invidious/helpers/errors.cr
+++ b/src/invidious/helpers/errors.cr
@@ -1,11 +1,3 @@
-# InfoExceptions are for displaying information to the user.
-#
-# An InfoException might or might not indicate that something went wrong.
-# Historically Invidious didn't differentiate between these two options, so to
-# maintain previous functionality InfoExceptions do not print backtraces.
-class InfoException < Exception
-end
-
# -------------------
# Issue template
# -------------------
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index aefa34cc..c4eb7507 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -317,7 +317,7 @@ def get_playlist(plid : String)
if playlist = Invidious::Database::Playlists.select(id: plid)
return playlist
else
- raise InfoException.new("Playlist does not exist.")
+ raise NotFoundException.new("Playlist does not exist.")
end
else
return fetch_playlist(plid)
diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr
index 8bc36946..bfb8a377 100644
--- a/src/invidious/routes/api/manifest.cr
+++ b/src/invidious/routes/api/manifest.cr
@@ -16,6 +16,8 @@ module Invidious::Routes::API::Manifest
video = get_video(id, region: region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
+ rescue ex : NotFoundException
+ haltf env, status_code: 404
rescue ex
haltf env, status_code: 403
end
@@ -46,7 +48,7 @@ module Invidious::Routes::API::Manifest
end
end
- audio_streams = video.audio_streams
+ audio_streams = video.audio_streams.sort_by { |stream| {stream["bitrate"].as_i} }.reverse!
video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse!
manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml|
@@ -60,16 +62,22 @@ module Invidious::Routes::API::Manifest
mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type }
next if mime_streams.empty?
- xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do
- mime_streams.each do |fmt|
- # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
- next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
+ mime_streams.each do |fmt|
+ # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415)
+ next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange"))
+ # Different representations of the same audio should be groupped into one AdaptationSet.
+ # However, most players don't support auto quality switching, so we have to trick them
+ # into providing a quality selector.
+ # See https://github.com/iv-org/invidious/issues/3074 for more details.
+ xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do
codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
bandwidth = fmt["bitrate"].as_i
itag = fmt["itag"].as_i
url = fmt["url"].as_s
+ xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate")
+
xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
value: "2")
@@ -79,9 +87,8 @@ module Invidious::Routes::API::Manifest
end
end
end
+ i += 1
end
-
- i += 1
end
potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144}
diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr
index b559a01a..1f5ad8ef 100644
--- a/src/invidious/routes/api/v1/authenticated.cr
+++ b/src/invidious/routes/api/v1/authenticated.cr
@@ -237,6 +237,8 @@ module Invidious::Routes::API::V1::Authenticated
begin
video = get_video(video_id)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr
index 8650976d..6b81c546 100644
--- a/src/invidious/routes/api/v1/channels.cr
+++ b/src/invidious/routes/api/v1/channels.cr
@@ -13,6 +13,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
@@ -170,6 +172,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
@@ -205,6 +209,8 @@ module Invidious::Routes::API::V1::Channels
rescue ex : ChannelRedirect
env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr
index a9f891f5..1b7b4fa7 100644
--- a/src/invidious/routes/api/v1/videos.cr
+++ b/src/invidious/routes/api/v1/videos.cr
@@ -12,6 +12,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
@@ -42,6 +44,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
+ rescue ex : NotFoundException
+ haltf env, 404
rescue ex
haltf env, 500
end
@@ -167,6 +171,8 @@ module Invidious::Routes::API::V1::Videos
rescue ex : VideoRedirect
env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
+ rescue ex : NotFoundException
+ haltf env, 404
rescue ex
haltf env, 500
end
@@ -324,6 +330,8 @@ module Invidious::Routes::API::V1::Videos
begin
comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
return error_json(500, ex)
end
diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr
new file mode 100644
index 00000000..8e2a253f
--- /dev/null
+++ b/src/invidious/routes/before_all.cr
@@ -0,0 +1,152 @@
+module Invidious::Routes::BeforeAll
+ def self.handle(env)
+ preferences = Preferences.from_json("{}")
+
+ begin
+ if prefs_cookie = env.request.cookies["PREFS"]?
+ preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value))
+ else
+ if language_header = env.request.headers["Accept-Language"]?
+ if language = ANG.language_negotiator.best(language_header, LOCALES.keys)
+ preferences.locale = language.header
+ end
+ end
+ end
+ rescue
+ preferences = Preferences.from_json("{}")
+ end
+
+ env.set "preferences", preferences
+ env.response.headers["X-XSS-Protection"] = "1; mode=block"
+ env.response.headers["X-Content-Type-Options"] = "nosniff"
+
+ # Allow media resources to be loaded from google servers
+ # TODO: check if *.youtube.com can be removed
+ if CONFIG.disabled?("local") || !preferences.local
+ extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443"
+ else
+ extra_media_csp = ""
+ end
+
+ # Only allow the pages at /embed/* to be embedded
+ if env.request.resource.starts_with?("/embed")
+ frame_ancestors = "'self' http: https:"
+ else
+ frame_ancestors = "'none'"
+ end
+
+ # TODO: Remove style-src's 'unsafe-inline', requires to remove all
+ # inline styles (<style> [..] </style>, style=" [..] ")
+ env.response.headers["Content-Security-Policy"] = {
+ "default-src 'none'",
+ "script-src 'self'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data:",
+ "font-src 'self' data:",
+ "connect-src 'self'",
+ "manifest-src 'self'",
+ "media-src 'self' blob:" + extra_media_csp,
+ "child-src 'self' blob:",
+ "frame-src 'self'",
+ "frame-ancestors " + frame_ancestors,
+ }.join("; ")
+
+ env.response.headers["Referrer-Policy"] = "same-origin"
+
+ # Ask the chrom*-based browsers to disable FLoC
+ # See: https://blog.runcloud.io/google-floc/
+ env.response.headers["Permissions-Policy"] = "interest-cohort=()"
+
+ if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts
+ env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
+ end
+
+ return if {
+ "/sb/",
+ "/vi/",
+ "/s_p/",
+ "/yts/",
+ "/ggpht/",
+ "/api/manifest/",
+ "/videoplayback",
+ "/latest_version",
+ "/download",
+ }.any? { |r| env.request.resource.starts_with? r }
+
+ if env.request.cookies.has_key? "SID"
+ sid = env.request.cookies["SID"].value
+
+ if sid.starts_with? "v1:"
+ 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
+ end
+ end
+
+ dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s
+ thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode.to_s
+ thin_mode = thin_mode == "true"
+ locale = env.params.query["hl"]? || preferences.locale
+
+ preferences.dark_mode = dark_mode
+ preferences.thin_mode = thin_mode
+ preferences.locale = locale
+ env.set "preferences", preferences
+
+ current_page = env.request.path
+ if env.request.query
+ query = HTTP::Params.parse(env.request.query.not_nil!)
+
+ if query["referer"]?
+ query["referer"] = get_referer(env, "/")
+ end
+
+ current_page += "?#{query}"
+ end
+
+ env.set "current_page", URI.encode_www_form(current_page)
+ end
+end
diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr
index cd2e3323..c6e02cbd 100644
--- a/src/invidious/routes/channels.cr
+++ b/src/invidious/routes/channels.cr
@@ -85,6 +85,9 @@ module Invidious::Routes::Channels
rescue ex : InfoException
env.response.status_code = 500
error_message = ex.message
+ rescue ex : NotFoundException
+ env.response.status_code = 404
+ error_message = ex.message
rescue ex
return error_template(500, ex)
end
@@ -118,7 +121,7 @@ module Invidious::Routes::Channels
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
rescue ex : InfoException | KeyError
- raise InfoException.new(translate(locale, "This channel does not exist."))
+ return error_template(404, translate(locale, "This channel does not exist."))
end
selected_tab = env.request.path.split("/")[-1]
@@ -141,7 +144,7 @@ module Invidious::Routes::Channels
user = env.params.query["user"]?
if !user
- raise InfoException.new("This channel does not exist.")
+ return error_template(404, "This channel does not exist.")
else
env.redirect "/user/#{user}#{uri_params}"
end
@@ -197,6 +200,8 @@ module Invidious::Routes::Channels
channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr
index 207970b0..e6486587 100644
--- a/src/invidious/routes/embed.cr
+++ b/src/invidious/routes/embed.cr
@@ -2,11 +2,18 @@
module Invidious::Routes::Embed
def self.redirect(env)
+ locale = env.get("preferences").as(Preferences).locale
if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
begin
playlist = get_playlist(plid)
offset = env.params.query["index"]?.try &.to_i? || 0
videos = get_playlist_videos(playlist, offset: offset)
+ if videos.empty?
+ url = "/playlist?list=#{plid}"
+ raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
+ end
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
@@ -24,6 +31,7 @@ module Invidious::Routes::Embed
end
def self.show(env)
+ locale = env.get("preferences").as(Preferences).locale
id = env.params.url["id"]
plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
@@ -60,6 +68,12 @@ module Invidious::Routes::Embed
playlist = get_playlist(plid)
offset = env.params.query["index"]?.try &.to_i? || 0
videos = get_playlist_videos(playlist, offset: offset)
+ if videos.empty?
+ url = "/playlist?list=#{plid}"
+ raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url))
+ end
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
@@ -119,6 +133,8 @@ module Invidious::Routes::Embed
video = get_video(id, region: params.region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr
new file mode 100644
index 00000000..b138b562
--- /dev/null
+++ b/src/invidious/routes/errors.cr
@@ -0,0 +1,47 @@
+module Invidious::Routes::ErrorRoutes
+ def self.error_404(env)
+ if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
+ item = md["id"]
+
+ # Check if item is branding URL e.g. https://youtube.com/gaming
+ response = YT_POOL.client &.get("/#{item}")
+
+ if response.status_code == 301
+ response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target)
+ end
+
+ if response.body.empty?
+ env.response.headers["Location"] = "/"
+ haltf env, status_code: 302
+ end
+
+ html = XML.parse_html(response.body)
+ ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
+
+ if ucid
+ env.response.headers["Location"] = "/channel/#{ucid}"
+ haltf env, status_code: 302
+ end
+
+ params = [] of String
+ env.params.query.each do |k, v|
+ params << "#{k}=#{v}"
+ end
+ params = params.join("&")
+
+ url = "/watch?v=#{item}"
+ if !params.empty?
+ url += "&#{params}"
+ end
+
+ # Check if item is video ID
+ if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404
+ env.response.headers["Location"] = url
+ haltf env, status_code: 302
+ end
+ end
+
+ env.response.headers["Location"] = "/"
+ haltf env, status_code: 302
+ end
+end
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index 2e6043f7..b601db94 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -150,6 +150,8 @@ module Invidious::Routes::Feeds
channel = get_about_info(ucid, locale)
rescue ex : ChannelRedirect
return env.redirect env.request.resource.gsub(ucid, ex.channel_id)
+ rescue ex : NotFoundException
+ return error_atom(404, ex)
rescue ex
return error_atom(500, ex)
end
@@ -202,6 +204,12 @@ module Invidious::Routes::Feeds
xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" }
end
+ xml.element("image") do
+ xml.element("url") { xml.text channel.author_thumbnail }
+ xml.element("title") { xml.text channel.author }
+ xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}")
+ end
+
videos.each do |video|
video.to_xml(channel.auto_generated, params, xml)
end
diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr
index de981d81..fe7e4e1c 100644
--- a/src/invidious/routes/playlists.cr
+++ b/src/invidious/routes/playlists.cr
@@ -66,7 +66,13 @@ module Invidious::Routes::Playlists
user = user.as(User)
playlist_id = env.params.query["list"]
- playlist = get_playlist(playlist_id)
+ begin
+ playlist = get_playlist(playlist_id)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
+ rescue ex
+ return error_template(500, ex)
+ end
subscribe_playlist(user, playlist)
env.redirect "/playlist?list=#{playlist.id}"
@@ -304,6 +310,8 @@ module Invidious::Routes::Playlists
playlist_id = env.params.query["playlist_id"]
playlist = get_playlist(playlist_id).as(InvidiousPlaylist)
raise "Invalid user" if playlist.author != user.email
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
if redirect
return error_template(400, ex)
@@ -334,6 +342,8 @@ module Invidious::Routes::Playlists
begin
video = get_video(video_id)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
rescue ex
if redirect
return error_template(500, ex)
@@ -394,6 +404,8 @@ module Invidious::Routes::Playlists
begin
playlist = get_playlist(plid)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
rescue ex
return error_template(500, ex)
end
diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr
index 6f8bffea..2a9705cf 100644
--- a/src/invidious/routes/search.cr
+++ b/src/invidious/routes/search.cr
@@ -59,6 +59,12 @@ module Invidious::Routes::Search
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)
+
env.set "search", query.text
templated "search"
end
diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr
index 3a92ef96..560f9c19 100644
--- a/src/invidious/routes/video_playback.cr
+++ b/src/invidious/routes/video_playback.cr
@@ -265,7 +265,13 @@ module Invidious::Routes::VideoPlayback
return error_template(403, "Administrator has disabled this endpoint.")
end
- video = get_video(id, region: region)
+ begin
+ video = get_video(id, region: region)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
+ rescue ex
+ return error_template(500, ex)
+ end
fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag }
url = fmt.try &.["url"]?.try &.as_s
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 7280de4f..fe1d8e54 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -63,6 +63,9 @@ module Invidious::Routes::Watch
video = get_video(id, region: params.region)
rescue ex : VideoRedirect
return env.redirect env.request.resource.gsub(id, ex.video_id)
+ rescue ex : NotFoundException
+ LOGGER.error("get_video not found: #{id} : #{ex.message}")
+ return error_template(404, ex)
rescue ex
LOGGER.error("get_video: #{id} : #{ex.message}")
return error_template(500, ex)
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index bd72c577..f409f13c 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -1,130 +1,273 @@
module Invidious::Routing
- {% for http_method in {"get", "post", "delete", "options", "patch", "put", "head"} %}
+ extend self
+
+ {% for http_method in {"get", "post", "delete", "options", "patch", "put"} %}
macro {{http_method.id}}(path, controller, method = :handle)
- {{http_method.id}} \{{ path }} do |env|
+ unless Kemal::Utils.path_starts_with_slash?(\{{path}})
+ raise Kemal::Exceptions::InvalidPathStartException.new({{http_method}}, \{{path}})
+ end
+
+ Kemal::RouteHandler::INSTANCE.add_route({{http_method.upcase}}, \{{path}}) do |env|
\{{ controller }}.\{{ method.id }}(env)
end
end
{% end %}
-end
-macro define_user_routes
- # User login/out
- Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
- Invidious::Routing.post "/login", Invidious::Routes::Login, :login
- Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
- Invidious::Routing.get "/Captcha", Invidious::Routes::Login, :captcha
-
- # User preferences
- Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
- Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
- Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
- Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
- Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
-
- # User account management
- Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password
- Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password
- Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete
- Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete
- Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history
- Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history
- Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token
- Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token
- Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager
- Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax
-end
+ def register_all
+ {% unless flag?(:api_only) %}
+ get "/", Routes::Misc, :home
+ get "/privacy", Routes::Misc, :privacy
+ get "/licenses", Routes::Misc, :licenses
+ get "/redirect", Routes::Misc, :cross_instance_redirect
-macro define_v1_api_routes
- {{namespace = Invidious::Routes::API::V1}}
- # Videos
- Invidious::Routing.get "/api/v1/videos/:id", {{namespace}}::Videos, :videos
- Invidious::Routing.get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards
- Invidious::Routing.get "/api/v1/captions/:id", {{namespace}}::Videos, :captions
- Invidious::Routing.get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
- Invidious::Routing.get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
-
- # Feeds
- Invidious::Routing.get "/api/v1/trending", {{namespace}}::Feeds, :trending
- Invidious::Routing.get "/api/v1/popular", {{namespace}}::Feeds, :popular
-
- # Channels
- Invidious::Routing.get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
- {% for route in {"videos", "latest", "playlists", "community", "search"} %}
- Invidious::Routing.get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
- Invidious::Routing.get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
- {% end %}
+ self.register_channel_routes
+ self.register_watch_routes
- # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community
- Invidious::Routing.get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect
- Invidious::Routing.get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect
+ self.register_iv_playlist_routes
+ self.register_yt_playlist_routes
+ self.register_search_routes
- # Search
- Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search
- Invidious::Routing.get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions
+ self.register_user_routes
+ self.register_feed_routes
- # Authenticated
+ # Support push notifications via PubSubHubbub
+ get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get
+ post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post
- # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr
- #
- # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
- # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
+ get "/modify_notifications", Routes::Notifications, :modify
+ {% end %}
- Invidious::Routing.get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
- Invidious::Routing.post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
+ self.register_image_routes
+ self.register_api_v1_routes
+ self.register_api_manifest_routes
+ self.register_video_playback_routes
+ end
- Invidious::Routing.get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed
+ # -------------------
+ # Invidious routes
+ # -------------------
- Invidious::Routing.get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions
- Invidious::Routing.post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel
- Invidious::Routing.delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel
+ def register_user_routes
+ # User login/out
+ 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
+ post "/preferences", Routes::PreferencesRoute, :update
+ get "/toggle_theme", Routes::PreferencesRoute, :toggle_theme
+ get "/data_control", Routes::PreferencesRoute, :data_control
+ post "/data_control", Routes::PreferencesRoute, :update_data_control
- Invidious::Routing.get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists
- Invidious::Routing.post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist
- Invidious::Routing.patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute
- Invidious::Routing.delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist
+ # User account management
+ get "/change_password", Routes::Account, :get_change_password
+ post "/change_password", Routes::Account, :post_change_password
+ get "/delete_account", Routes::Account, :get_delete
+ post "/delete_account", Routes::Account, :post_delete
+ get "/clear_watch_history", Routes::Account, :get_clear_history
+ post "/clear_watch_history", Routes::Account, :post_clear_history
+ get "/authorize_token", Routes::Account, :get_authorize_token
+ post "/authorize_token", Routes::Account, :post_authorize_token
+ get "/token_manager", Routes::Account, :token_manager
+ post "/token_ajax", Routes::Account, :token_ajax
+ post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription
+ get "/subscription_manager", Routes::Subscriptions, :subscription_manager
+ end
+ def register_iv_playlist_routes
+ get "/create_playlist", Routes::Playlists, :new
+ post "/create_playlist", Routes::Playlists, :create
+ get "/subscribe_playlist", Routes::Playlists, :subscribe
+ get "/delete_playlist", Routes::Playlists, :delete_page
+ post "/delete_playlist", Routes::Playlists, :delete
+ get "/edit_playlist", Routes::Playlists, :edit
+ post "/edit_playlist", Routes::Playlists, :update
+ get "/add_playlist_items", Routes::Playlists, :add_playlist_items_page
+ post "/playlist_ajax", Routes::Playlists, :playlist_ajax
+ end
- Invidious::Routing.post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist
- Invidious::Routing.delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist
+ def register_feed_routes
+ # Feeds
+ get "/view_all_playlists", Routes::Feeds, :view_all_playlists_redirect
+ get "/feed/playlists", Routes::Feeds, :playlists
+ get "/feed/popular", Routes::Feeds, :popular
+ get "/feed/trending", Routes::Feeds, :trending
+ get "/feed/subscriptions", Routes::Feeds, :subscriptions
+ get "/feed/history", Routes::Feeds, :history
- Invidious::Routing.get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens
- Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
- Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
+ # RSS Feeds
+ get "/feed/channel/:ucid", Routes::Feeds, :rss_channel
+ get "/feed/private", Routes::Feeds, :rss_private
+ get "/feed/playlist/:plid", Routes::Feeds, :rss_playlist
+ get "/feeds/videos.xml", Routes::Feeds, :rss_videos
+ end
- Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
- Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
+ # -------------------
+ # Youtube routes
+ # -------------------
- # Misc
- Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats
- Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
- Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
- Invidious::Routing.get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes
-end
+ def register_channel_routes
+ get "/channel/:ucid", Routes::Channels, :home
+ get "/channel/:ucid/home", Routes::Channels, :home
+ get "/channel/:ucid/videos", Routes::Channels, :videos
+ get "/channel/:ucid/playlists", Routes::Channels, :playlists
+ get "/channel/:ucid/community", Routes::Channels, :community
+ get "/channel/:ucid/about", Routes::Channels, :about
+ get "/channel/:ucid/live", Routes::Channels, :live
+ get "/user/:user/live", Routes::Channels, :live
+ get "/c/:user/live", Routes::Channels, :live
-macro define_api_manifest_routes
- Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::API::Manifest, :get_dash_video_id
+ ["", "/videos", "/playlists", "/community", "/about"].each do |path|
+ # /c/LinusTechTips
+ get "/c/:user#{path}", Routes::Channels, :brand_redirect
+ # /user/linustechtips | Not always the same as /c/
+ get "/user/:user#{path}", Routes::Channels, :brand_redirect
+ # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow
+ get "/attribution_link#{path}", Routes::Channels, :brand_redirect
+ # /profile?user=linustechtips
+ get "/profile/#{path}", Routes::Channels, :profile
+ end
+ end
- Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :get_dash_video_playback
- Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :get_dash_video_playback_greedy
+ def register_watch_routes
+ get "/watch", Routes::Watch, :handle
+ post "/watch_ajax", Routes::Watch, :mark_watched
+ get "/watch/:id", Routes::Watch, :redirect
+ get "/shorts/:id", Routes::Watch, :redirect
+ get "/clip/:clip", Routes::Watch, :clip
+ get "/w/:id", Routes::Watch, :redirect
+ get "/v/:id", Routes::Watch, :redirect
+ get "/e/:id", Routes::Watch, :redirect
- Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :options_dash_video_playback
- Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :options_dash_video_playback
+ post "/download", Routes::Watch, :download
- Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::API::Manifest, :get_hls_playlist
- Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::API::Manifest, :get_hls_variant
-end
+ get "/embed/", Routes::Embed, :redirect
+ get "/embed/:id", Routes::Embed, :show
+ end
+
+ def register_yt_playlist_routes
+ get "/playlist", Routes::Playlists, :show
+ get "/mix", Routes::Playlists, :mix
+ get "/watch_videos", Routes::Playlists, :watch_videos
+ end
+
+ def register_search_routes
+ get "/opensearch.xml", Routes::Search, :opensearch
+ get "/results", Routes::Search, :results
+ get "/search", Routes::Search, :search
+ get "/hashtag/:hashtag", Routes::Search, :hashtag
+ end
+
+ # -------------------
+ # Media proxy routes
+ # -------------------
+
+ def register_api_manifest_routes
+ get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id
+
+ get "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :get_dash_video_playback
+ get "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :get_dash_video_playback_greedy
+
+ options "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :options_dash_video_playback
+ options "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :options_dash_video_playback
+
+ get "/api/manifest/hls_playlist/*", Routes::API::Manifest, :get_hls_playlist
+ get "/api/manifest/hls_variant/*", Routes::API::Manifest, :get_hls_variant
+ end
+
+ def register_video_playback_routes
+ get "/videoplayback", Routes::VideoPlayback, :get_video_playback
+ get "/videoplayback/*", Routes::VideoPlayback, :get_video_playback_greedy
+
+ options "/videoplayback", Routes::VideoPlayback, :options_video_playback
+ options "/videoplayback/*", Routes::VideoPlayback, :options_video_playback
+
+ get "/latest_version", Routes::VideoPlayback, :latest_version
+ end
+
+ def register_image_routes
+ get "/ggpht/*", Routes::Images, :ggpht
+ options "/sb/:authority/:id/:storyboard/:index", Routes::Images, :options_storyboard
+ get "/sb/:authority/:id/:storyboard/:index", Routes::Images, :get_storyboard
+ get "/s_p/:id/:name", Routes::Images, :s_p_image
+ get "/yts/img/:name", Routes::Images, :yts_image
+ get "/vi/:id/:name", Routes::Images, :thumbnails
+ end
+
+ # -------------------
+ # API routes
+ # -------------------
+
+ def register_api_v1_routes
+ {% begin %}
+ {{namespace = Routes::API::V1}}
+
+ # Videos
+ get "/api/v1/videos/:id", {{namespace}}::Videos, :videos
+ get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards
+ get "/api/v1/captions/:id", {{namespace}}::Videos, :captions
+ get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
+ get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
+
+ # Feeds
+ get "/api/v1/trending", {{namespace}}::Feeds, :trending
+ get "/api/v1/popular", {{namespace}}::Feeds, :popular
+
+ # Channels
+ get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
+ {% for route in {"videos", "latest", "playlists", "community", "search"} %}
+ get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
+ get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
+ {% end %}
+
+ # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community
+ get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect
+ get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect
+
+ # Search
+ get "/api/v1/search", {{namespace}}::Search, :search
+ get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions
+
+ # Authenticated
+
+ # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr
+ #
+ # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
+ # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
+
+ get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
+ post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
+
+ get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed
+
+ get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions
+ post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel
+ delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel
+
+ get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists
+ post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist
+ patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute
+ delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist
+ post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist
+ delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist
-macro define_video_playback_routes
- Invidious::Routing.get "/videoplayback", Invidious::Routes::VideoPlayback, :get_video_playback
- Invidious::Routing.get "/videoplayback/*", Invidious::Routes::VideoPlayback, :get_video_playback_greedy
+ get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens
+ post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
+ post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
- Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback
- Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback
+ get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
+ post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
- Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version
+ # Misc
+ get "/api/v1/stats", {{namespace}}::Misc, :stats
+ get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
+ get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
+ get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes
+ {% end %}
+ end
end
diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr
index 34b36b1d..24e79609 100644
--- a/src/invidious/search/query.cr
+++ b/src/invidious/search/query.cr
@@ -57,7 +57,7 @@ module Invidious::Search
# Get the page number (also common to all search types)
@page = params["page"]?.try &.to_i? || 1
- # Stop here is raw query in empty
+ # Stop here if raw query is empty
# NOTE: maybe raise in the future?
return if self.empty_raw_query?
@@ -127,6 +127,16 @@ module Invidious::Search
return items
end
+ # Return the HTTP::Params corresponding to this Query (invidious format)
+ def to_http_params : HTTP::Params
+ params = @filters.to_iv_params
+
+ params["q"] = @query
+ params["channel"] = @channel if !@channel.empty?
+
+ return params
+ end
+
# TODO: clean code
private def unnest_items(all_items) : Array(SearchItem)
items = [] of SearchItem
diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr
index 65e079ec..654efc15 100644
--- a/src/invidious/user/cookies.cr
+++ b/src/invidious/user/cookies.cr
@@ -18,7 +18,7 @@ struct Invidious::User
expires: Time.utc + 2.years,
secure: SECURE,
http_only: true,
- samesite: HTTP::Cookie::SameSite::Strict
+ samesite: HTTP::Cookie::SameSite::Lax
)
end
@@ -32,7 +32,7 @@ struct Invidious::User
expires: Time.utc + 2.years,
secure: SECURE,
http_only: false,
- samesite: HTTP::Cookie::SameSite::Strict
+ samesite: HTTP::Cookie::SameSite::Lax
)
end
end
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index 1504e390..c0ed6e85 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -323,7 +323,7 @@ struct Video
json.field "viewCount", self.views
json.field "likeCount", self.likes
- json.field "dislikeCount", self.dislikes
+ json.field "dislikeCount", 0_i64
json.field "paid", self.paid
json.field "premium", self.premium
@@ -354,7 +354,7 @@ struct Video
json.field "lengthSeconds", self.length_seconds
json.field "allowRatings", self.allow_ratings
- json.field "rating", self.average_rating
+ json.field "rating", 0_i64
json.field "isListed", self.is_listed
json.field "liveNow", self.live_now
json.field "isUpcoming", self.is_upcoming
@@ -556,11 +556,6 @@ struct Video
info["dislikes"]?.try &.as_i64 || 0_i64
end
- def average_rating : Float64
- # (likes / (likes + dislikes) * 4 + 1)
- info["videoDetails"]["averageRating"]?.try { |t| t.as_f? || t.as_i64?.try &.to_f64 }.try &.round(4) || 0.0
- end
-
def published : Time
info
.dig?("microformat", "playerMicroformatRenderer", "publishDate")
@@ -813,14 +808,6 @@ struct Video
return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s
end
- def wilson_score : Float64
- ci_lower_bound(likes, likes + dislikes).round(4)
- end
-
- def engagement : Float64
- (((likes + dislikes) / views) * 100).round(4)
- end
-
def reason : String?
info["reason"]?.try &.as_s
end
@@ -899,36 +886,50 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
end
def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil)
- params = {} of String => JSON::Any
-
+ # Init client config for the API
client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region)
if context_screen == "embed"
client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed
end
+ # Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
- if player_response.dig?("playabilityStatus", "status").try &.as_s != "OK"
+ playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
+
+ if playability_status != "OK"
subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
reason = subreason.try &.[]?("simpleText").try &.as_s
reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
reason ||= player_response.dig("playabilityStatus", "reason").as_s
- params["reason"] = JSON::Any.new(reason)
- return params
- end
- params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil)
+ # Stop here if video is not a scheduled livestream
+ if playability_status != "LIVE_STREAM_OFFLINE"
+ return {
+ "reason" => JSON::Any.new(reason),
+ }
+ end
+ 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)")
+ else
+ reason = nil
+ end
# Don't fetch the next endpoint if the video is unavailable.
- if !params["reason"]?
+ if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status)
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
player_response = player_response.merge(next_response)
end
+ params = parse_video_info(video_id, player_response)
+ params["reason"] = JSON::Any.new(reason) if reason
+
# Fetch the video streams using an Android client in order to get the decrypted URLs and
# maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
- if !params["reason"]?
+ if reason.nil?
if context_screen == "embed"
client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed
else
@@ -936,20 +937,29 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
end
android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config)
- # Sometime, the video is available from the web client, but not on Android, so check
+ # Sometimes, the video is available from the web client, but not on Android, so check
# that here, and fallback to the streaming data from the web client if needed.
# See: https://github.com/iv-org/invidious/issues/2549
- if android_player["playabilityStatus"]["status"] == "OK"
+ if video_id != android_player.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. (ANDROID client)")
+ elsif android_player["playabilityStatus"]["status"] == "OK"
params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("")
else
params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("")
end
end
+ # TODO: clean that up
{"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f|
params[f] = player_response[f] if player_response[f]?
end
+ return params
+end
+
+def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
# Top level elements
main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
@@ -1003,16 +1013,14 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
end
end
- params["relatedVideos"] = JSON::Any.new(related)
-
- # Likes/dislikes
+ # Likes
toplevel_buttons = video_primary_renderer
.try &.dig?("videoActions", "menuRenderer", "topLevelButtons")
if toplevel_buttons
likes_button = toplevel_buttons.as_a
- .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "LIKE")
+ .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE")
.try &.["toggleButtonRenderer"]
if likes_button
@@ -1023,64 +1031,38 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"")
LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes
end
-
- dislikes_button = toplevel_buttons.as_a
- .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "DISLIKE")
- .try &.["toggleButtonRenderer"]
-
- if dislikes_button
- dislikes_txt = (dislikes_button["defaultText"]? || dislikes_button["toggledText"]?)
- .try &.dig?("accessibility", "accessibilityData", "label")
- dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64? if dislikes_txt
-
- LOGGER.trace("extract_video_info: Found \"dislikes\" button. Button text is \"#{dislikes_txt}\"")
- LOGGER.debug("extract_video_info: Dislikes count is #{dislikes}") if dislikes
- end
end
- if likes && likes != 0_i64 && (!dislikes || dislikes == 0_i64)
- if rating = player_response.dig?("videoDetails", "averageRating").try { |x| x.as_i64? || x.as_f? }
- dislikes = (likes * ((5 - rating)/(rating - 1))).round.to_i64
- LOGGER.debug("extract_video_info: Dislikes count (using fallback method) is #{dislikes}")
- end
- end
-
- params["likes"] = JSON::Any.new(likes || 0_i64)
- params["dislikes"] = JSON::Any.new(dislikes || 0_i64)
-
# Description
+ short_description = player_response.dig?("videoDetails", "shortDescription")
+
description_html = video_secondary_renderer.try &.dig?("description", "runs")
.try &.as_a.try { |t| content_to_comment_html(t, video_id) }
- params["descriptionHtml"] = JSON::Any.new(description_html || "<p></p>")
-
# Video metadata
metadata = video_secondary_renderer
.try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows")
.try &.as_a
- params["genre"] = params["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["category"]? || JSON::Any.new("")
- params["genreUrl"] = JSON::Any.new(nil)
+ genre = player_response.dig?("microformat", "playerMicroformatRenderer", "category")
+ genre_ucid = nil
+ license = nil
metadata.try &.each do |row|
- title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s
+ metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s
contents = row.dig?("metadataRowRenderer", "contents", 0)
- if title.try &.== "Category"
+ if metadata_title == "Category"
contents = contents.try &.dig?("runs", 0)
- params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "")
- params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]?
- .try &.["browseId"]?.try &.as_s || "")
- elsif title.try &.== "License"
- contents = contents.try &.["runs"]?
- .try &.as_a[0]?
-
- params["license"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "")
- elsif title.try &.== "Licensed to YouTube by"
- params["license"] = JSON::Any.new(contents.try &.["simpleText"]?.try &.as_s || "")
+ genre = contents.try &.["text"]?
+ genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId")
+ elsif metadata_title == "License"
+ license = contents.try &.dig?("runs", 0, "text")
+ elsif metadata_title == "Licensed to YouTube by"
+ license = contents.try &.["simpleText"]?
end
end
@@ -1088,20 +1070,30 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_
if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer")
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url")
- params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "")
-
author_verified = has_verified_badge?(author_info["badges"]?)
- params["authorVerified"] = JSON::Any.new(author_verified)
subs_text = author_info["subscriberCountText"]?
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
.try &.as_s.split(" ", 2)[0]
-
- params["subCountText"] = JSON::Any.new(subs_text || "-")
end
# Return data
+ params = {
+ "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
+ "relatedVideos" => JSON::Any.new(related),
+ "likes" => JSON::Any.new(likes || 0_i64),
+ "dislikes" => JSON::Any.new(0_i64),
+ "descriptionHtml" => JSON::Any.new(description_html || "<p></p>"),
+ "genre" => JSON::Any.new(genre.try &.as_s || ""),
+ "genreUrl" => JSON::Any.new(nil),
+ "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""),
+ "license" => JSON::Any.new(license.try &.as_s || ""),
+ "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""),
+ "authorVerified" => JSON::Any.new(author_verified),
+ "subCountText" => JSON::Any.new(subs_text || "-"),
+ }
+
return params
end
@@ -1158,7 +1150,11 @@ def fetch_video(id, region)
end
if reason = info["reason"]?
- raise InfoException.new(reason.as_s || "")
+ if reason == "Video unavailable"
+ raise NotFoundException.new(reason.as_s || "")
+ else
+ raise InfoException.new(reason.as_s || "")
+ end
end
video = Video.new({
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index fb7ad1dc..0e959ff2 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -5,7 +5,7 @@
<a href="/channel/<%= item.ucid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<center>
- <img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
+ <img loading="lazy" tabindex="-1" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
</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>
@@ -23,7 +23,7 @@
<a style="width:100%" href="<%= url %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
<p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p>
</div>
<% end %>
@@ -36,7 +36,7 @@
<a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% if item.length_seconds != 0 %>
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
<% end %>
@@ -51,16 +51,13 @@
<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" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+
<% 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">
- <a data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>" href="javascript:void(0)">
- <button type="submit" style="all:unset">
- <i class="icon ion-md-trash"></i>
- </button>
- </a>
+ <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 %>
@@ -103,29 +100,21 @@
<a style="width:100%" href="/watch?v=<%= item.id %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
+ <img loading="lazy" tabindex="-1" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% 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">
- <a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)">
- <button type="submit" style="all:unset">
- <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye"
- class="icon ion-ios-eye">
- </i>
- </button>
- </a>
+ <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">
- <a data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>" href="javascript:void(0)">
- <button type="submit" style="all:unset">
- <i class="icon ion-md-add"></i>
- </button>
- </a>
+ <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 %>
diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr
index fffefc9a..c3c02df0 100644
--- a/src/invidious/views/components/player.ecr
+++ b/src/invidious/views/components/player.ecr
@@ -7,14 +7,25 @@
<source src="<%= URI.parse(hlsvp).request_target %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream">
<% else %>
<% if params.listen %>
- <% audio_streams.each_with_index do |fmt, i|
+ <% # default to 128k m4a stream
+ best_m4a_stream_index = 0
+ best_m4a_stream_bitrate = 0
+ audio_streams.each_with_index do |fmt, i|
+ bandwidth = fmt["bitrate"].as_i
+ if (fmt["mimeType"].as_s.starts_with?("audio/mp4") && bandwidth > best_m4a_stream_bitrate)
+ best_m4a_stream_bitrate = bandwidth
+ best_m4a_stream_index = i
+ end
+ end
+
+ audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local
bitrate = fmt["bitrate"]
mimetype = HTML.escape(fmt["mimeType"].as_s)
- selected = i == 0 ? true : false
+ selected = (i == best_m4a_stream_index)
%>
<source src="<%= src_url %>" type='<%= mimetype %>' label="<%= bitrate %>k" selected="<%= selected %>">
<% if !params.local && !CONFIG.disabled?("local") %>
diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr
index ce5ff7f0..1bf5cc3e 100644
--- a/src/invidious/views/embed.ecr
+++ b/src/invidious/views/embed.ecr
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>">
<title><%= HTML.escape(video.title) %> - Invidious</title>
+ <script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
</head>
<body class="dark-theme">
diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr
index 6c1243c5..471d21db 100644
--- a/src/invidious/views/feeds/history.ecr
+++ b/src/invidious/views/feeds/history.ecr
@@ -38,9 +38,7 @@
<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">
- <a data-onclick="mark_unwatched" data-id="<%= item %>" href="javascript:void(0)">
- <button type="submit" style="all:unset"><i class="icon ion-md-trash"></i></button>
- </a>
+ <button type="submit" style="all:unset" data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button>
</p>
</form>
</div>
diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr
index 25b24ed4..667cfa37 100644
--- a/src/invidious/views/licenses.ecr
+++ b/src/invidious/views/licenses.ecr
@@ -25,6 +25,20 @@
<tr>
<td>
+ <a href="/js/handlers.js?v=<%= ASSET_COMMIT %>">handlers.js</a>
+ </td>
+
+ <td>
+ <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>
+ </td>
+
+ <td>
+ <a href="/js/handlers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a>
+ </td>
+ </tr>
+
+ <tr>
+ <td>
<a href="/js/community.js?v=<%= ASSET_COMMIT %>">community.js</a>
</td>
@@ -169,7 +183,7 @@
</td>
<td>
- <a href="https://choosealicense.com/licenses/mit/">MIT</a>
+ <a href="https://choosealicense.com/licenses/mit/">Expat</a>
</td>
<td>
@@ -253,7 +267,7 @@
</td>
<td>
- <a href="https://choosealicense.com/licenses/mit">MIT</a>
+ <a href="https://choosealicense.com/licenses/mit">Expat</a>
</td>
<td>
diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr
index 7110703e..254449a1 100644
--- a/src/invidious/views/search.ecr
+++ b/src/invidious/views/search.ecr
@@ -3,16 +3,6 @@
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
<% end %>
-<%-
- search_query_encoded = URI.encode_www_form(query.text, space_to_plus: true)
- filter_params = query.filters.to_iv_params
-
- url_prev_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page - 1}"
- url_next_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page + 1}"
-
- redirect_url = Invidious::Frontend::Misc.redirect_url(env)
--%>
-
<!-- Search redirection and filtering UI -->
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<hr/>
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 4e2b29f0..98f72eba 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -67,8 +67,8 @@
</a>
</div>
<% if env.get("preferences").as(Preferences).show_nick %>
- <div class="pure-u-1-4">
- <span id="user_name"><%= env.get("user").as(Invidious::User).email %></span>
+ <div class="pure-u-1-4" style="overflow: hidden; white-space: nowrap;">
+ <span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span>
</div>
<% end %>
<div class="pure-u-1-4">
diff --git a/src/invidious/views/user/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr
index c2a89ca2..c9801f09 100644
--- a/src/invidious/views/user/subscription_manager.ecr
+++ b/src/invidious/views/user/subscription_manager.ecr
@@ -39,9 +39,7 @@
<h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <a data-onclick="remove_subscription" data-ucid="<%= channel.id %>" href="#">
- <input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>">
- </a>
+ <input style="all:unset" type="submit" data-onclick="remove_subscription" data-ucid="<%= channel.id %>" value="<%= translate(locale, "unsubscribe") %>">
</form>
</h3>
</div>
diff --git a/src/invidious/views/user/token_manager.ecr b/src/invidious/views/user/token_manager.ecr
index 79f905a1..a73fa048 100644
--- a/src/invidious/views/user/token_manager.ecr
+++ b/src/invidious/views/user/token_manager.ecr
@@ -31,9 +31,7 @@
<h3 style="padding-right:0.5em">
<form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
- <a data-onclick="revoke_token" data-session="<%= token[:session] %>" href="#">
- <input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>">
- </a>
+ <input style="all:unset" type="submit" data-onclick="revoke_token" data-session="<%= token[:session] %>" value="<%= translate(locale, "revoke") %>">
</form>
</h3>
</div>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index c8f0e6f3..243ea3a4 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -173,7 +173,7 @@ we're going to need to do it here in order to allow for translations.
<p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
<p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
- <p id="dislikes"></p>
+ <p id="dislikes" style="display: none; visibility: hidden;"></p>
<p id="genre"><%= translate(locale, "Genre: ") %>
<% if !video.genre_url %>
<%= video.genre %>
@@ -185,9 +185,9 @@ we're going to need to do it here in order to allow for translations.
<p id="license"><%= translate(locale, "License: ") %><%= video.license %></p>
<% end %>
<p id="family_friendly"><%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %></p>
- <p id="wilson"><%= translate(locale, "Wilson score: ") %><%= video.wilson_score %></p>
- <p id="rating"></p>
- <p id="engagement"><%= translate(locale, "Engagement: ") %><%= video.engagement %>%</p>
+ <p id="wilson" style="display: none; visibility: hidden;"></p>
+ <p id="rating" style="display: none; visibility: hidden;"></p>
+ <p id="engagement" style="display: none; visibility: hidden;"></p>
<% if video.allowed_regions.size != REGIONS.size %>
<p id="allowed_regions">
<% if video.allowed_regions.size < REGIONS.size // 2 %>
diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr
index c4326cab..dc65cc52 100644
--- a/src/invidious/yt_backend/extractors.cr
+++ b/src/invidious/yt_backend/extractors.cr
@@ -417,7 +417,7 @@ private module Extractors
# {"tabRenderer": {
# "endpoint": {...}
# "title": "Playlists",
- # "selected": true,
+ # "selected": true, # Is nil unless tab is selected
# "content": {...},
# ...
# }}
@@ -435,20 +435,22 @@ private module Extractors
raw_items = [] of JSON::Any
content = extract_selected_tab(target["tabs"])["content"]
- content["sectionListRenderer"]["contents"].as_a.each do |renderer_container|
- renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0]
+ if section_list_contents = content.dig?("sectionListRenderer", "contents")
+ section_list_contents.as_a.each do |renderer_container|
+ renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0]
- # Category extraction
- if items_container = renderer_container_contents["shelfRenderer"]?
- raw_items << renderer_container_contents
- next
- elsif items_container = renderer_container_contents["gridRenderer"]?
- else
- items_container = renderer_container_contents
- end
+ # Category extraction
+ if items_container = renderer_container_contents["shelfRenderer"]?
+ raw_items << renderer_container_contents
+ next
+ elsif items_container = renderer_container_contents["gridRenderer"]?
+ else
+ items_container = renderer_container_contents
+ end
- items_container["items"]?.try &.as_a.each do |item|
- raw_items << item
+ items_container["items"]?.try &.as_a.each do |item|
+ raw_items << item
+ end
end
end
diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr
index 3d5e5787..f8245160 100644
--- a/src/invidious/yt_backend/extractors_utils.cr
+++ b/src/invidious/yt_backend/extractors_utils.cr
@@ -84,7 +84,7 @@ end
def extract_selected_tab(tabs)
# Extract the selected tab from the array of tabs Youtube returns
- return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"]
+ return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"]
end
def fetch_continuation_token(items : Array(JSON::Any))
diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr
index 2678ac6c..30d7613b 100644
--- a/src/invidious/yt_backend/youtube_api.cr
+++ b/src/invidious/yt_backend/youtube_api.cr
@@ -5,15 +5,28 @@
module YoutubeAPI
extend self
+ private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
+
+ private ANDROID_APP_VERSION = "17.29.35"
+ private ANDROID_SDK_VERSION = 30_i64
+ private IOS_APP_VERSION = "17.30.1"
+
# Enumerate used to select one of the clients supported by the API
enum ClientType
Web
WebEmbeddedPlayer
WebMobile
WebScreenEmbed
+
Android
AndroidEmbeddedPlayer
AndroidScreenEmbed
+
+ IOS
+ IOSEmbedded
+ IOSMusic
+
+ TvHtml5
TvHtml5ScreenEmbed
end
@@ -21,50 +34,78 @@ module YoutubeAPI
HARDCODED_CLIENTS = {
ClientType::Web => {
name: "WEB",
- version: "2.20210721.00.00",
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
+ version: "2.20220804.07.00",
+ api_key: DEFAULT_API_KEY,
screen: "WATCH_FULL_SCREEN",
},
ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER", # 56
- version: "1.20210721.1.0",
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
+ version: "1.20220803.01.00",
+ api_key: DEFAULT_API_KEY,
screen: "EMBED",
},
ClientType::WebMobile => {
name: "MWEB",
- version: "2.20210726.08.00",
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
- screen: "", # None
+ version: "2.20220805.01.00",
+ api_key: DEFAULT_API_KEY,
},
ClientType::WebScreenEmbed => {
name: "WEB",
- version: "2.20210721.00.00",
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
+ version: "2.20220804.00.00",
+ api_key: DEFAULT_API_KEY,
screen: "EMBED",
},
+
+ # Android
+
ClientType::Android => {
- name: "ANDROID",
- version: "16.20",
- api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w",
- screen: "", # ??
+ name: "ANDROID",
+ version: ANDROID_APP_VERSION,
+ api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w",
+ android_sdk_version: ANDROID_SDK_VERSION,
},
ClientType::AndroidEmbeddedPlayer => {
name: "ANDROID_EMBEDDED_PLAYER", # 55
- version: "16.20",
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
- screen: "", # None?
+ version: ANDROID_APP_VERSION,
+ api_key: DEFAULT_API_KEY,
},
ClientType::AndroidScreenEmbed => {
- name: "ANDROID", # 3
- version: "16.20",
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
- screen: "EMBED",
+ name: "ANDROID", # 3
+ version: ANDROID_APP_VERSION,
+ api_key: DEFAULT_API_KEY,
+ screen: "EMBED",
+ android_sdk_version: ANDROID_SDK_VERSION,
+ },
+
+ # IOS
+
+ ClientType::IOS => {
+ name: "IOS", # 5
+ version: IOS_APP_VERSION,
+ api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc",
+ },
+ ClientType::IOSEmbedded => {
+ name: "IOS_MESSAGES_EXTENSION", # 66
+ version: IOS_APP_VERSION,
+ api_key: DEFAULT_API_KEY,
+ },
+ ClientType::IOSMusic => {
+ name: "IOS_MUSIC", # 26
+ version: "4.32",
+ api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s",
+ },
+
+ # TV app
+
+ ClientType::TvHtml5 => {
+ name: "TVHTML5", # 7
+ version: "7.20220325",
+ api_key: DEFAULT_API_KEY,
},
ClientType::TvHtml5ScreenEmbed => {
- name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
+ name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", # 85
version: "2.0",
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
+ api_key: DEFAULT_API_KEY,
screen: "EMBED",
},
}
@@ -131,7 +172,11 @@ module YoutubeAPI
# :ditto:
def screen : String
- HARDCODED_CLIENTS[@client_type][:screen]
+ HARDCODED_CLIENTS[@client_type][:screen]? || ""
+ end
+
+ def android_sdk_version : Int64?
+ HARDCODED_CLIENTS[@client_type][:android_sdk_version]?
end
# Convert to string, for logging purposes
@@ -163,7 +208,7 @@ module YoutubeAPI
"gl" => client_config.region || "US", # Can't be empty!
"clientName" => client_config.name,
"clientVersion" => client_config.version,
- },
+ } of String => String | Int64,
}
# Add some more context if it exists in the client definitions
@@ -174,7 +219,11 @@ module YoutubeAPI
if client_config.screen == "EMBED"
client_context["thirdParty"] = {
"embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ",
- }
+ } of String => String | Int64
+ end
+
+ if android_sdk_version = client_config.android_sdk_version
+ client_context["client"]["androidSdkVersion"] = android_sdk_version
end
return client_context