summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/invidious.cr284
-rw-r--r--src/invidious/channels.cr15
-rw-r--r--src/invidious/helpers/handlers.cr33
-rw-r--r--src/invidious/helpers/helpers.cr247
-rw-r--r--src/invidious/helpers/patch_mapping.cr2
-rw-r--r--src/invidious/helpers/proxy.cr8
-rw-r--r--src/invidious/helpers/utils.cr13
-rw-r--r--src/invidious/playlists.cr18
-rw-r--r--src/invidious/search.cr176
-rw-r--r--src/invidious/users.cr87
-rw-r--r--src/invidious/videos.cr190
-rw-r--r--src/invidious/views/channel.ecr2
-rw-r--r--src/invidious/views/community.ecr2
-rw-r--r--src/invidious/views/components/item.ecr4
-rw-r--r--src/invidious/views/components/player.ecr2
-rw-r--r--src/invidious/views/components/player_sources.ecr1
-rw-r--r--src/invidious/views/licenses.ecr14
-rw-r--r--src/invidious/views/playlists.ecr2
-rw-r--r--src/invidious/views/preferences.ecr17
-rw-r--r--src/invidious/views/template.ecr9
-rw-r--r--src/invidious/views/watch.ecr6
21 files changed, 610 insertions, 522 deletions
diff --git a/src/invidious.cr b/src/invidious.cr
index c2de0dcf..8f7e1a63 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -148,26 +148,32 @@ statistics = {
}
if config.statistics_enabled
spawn do
- loop do
- statistics = {
- "version" => "2.0",
- "software" => SOFTWARE,
- "openRegistrations" => config.registration_enabled,
- "usage" => {
- "users" => {
- "total" => PG_DB.query_one("SELECT count(*) FROM users", as: Int64),
- "activeHalfyear" => PG_DB.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months'", as: Int64),
- "activeMonth" => PG_DB.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month'", as: Int64),
- },
- },
- "metadata" => {
- "updatedAt" => Time.utc.to_unix,
- "lastChannelRefreshedAt" => PG_DB.query_one?("SELECT updated FROM channels ORDER BY updated DESC LIMIT 1", as: Time).try &.to_unix || 0,
+ statistics = {
+ "version" => "2.0",
+ "software" => SOFTWARE,
+ "openRegistrations" => config.registration_enabled,
+ "usage" => {
+ "users" => {
+ "total" => PG_DB.query_one("SELECT count(*) FROM users", as: Int64),
+ "activeHalfyear" => PG_DB.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months'", as: Int64),
+ "activeMonth" => PG_DB.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month'", as: Int64),
},
- }
+ },
+ "metadata" => {
+ "updatedAt" => Time.utc.to_unix,
+ "lastChannelRefreshedAt" => PG_DB.query_one?("SELECT updated FROM channels ORDER BY updated DESC LIMIT 1", as: Time).try &.to_unix || 0_i64,
+ },
+ }
+ loop do
sleep 1.minute
Fiber.yield
+
+ statistics["usage"].as(Hash)["users"].as(Hash)["total"] = PG_DB.query_one("SELECT count(*) FROM users", as: Int64)
+ statistics["usage"].as(Hash)["users"].as(Hash)["activeHalfyear"] = PG_DB.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months'", as: Int64)
+ statistics["usage"].as(Hash)["users"].as(Hash)["activeMonth"] = PG_DB.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month'", as: Int64)
+ statistics["metadata"].as(Hash(String, Int64))["updatedAt"] = Time.utc.to_unix
+ statistics["metadata"].as(Hash(String, Int64))["lastChannelRefreshedAt"] = PG_DB.query_one?("SELECT updated FROM channels ORDER BY updated DESC LIMIT 1", as: Time).try &.to_unix || 0_i64
end
end
end
@@ -267,8 +273,7 @@ before_all do |env|
end
end
- dark_mode = env.params.query["dark_mode"]? || preferences.dark_mode.to_s
- dark_mode = dark_mode == "true"
+ 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"
@@ -463,8 +468,16 @@ get "/watch" do |env|
# Older videos may not have audio sources available.
# We redirect here so they're not unplayable
- if params.listen && audio_streams.empty?
- next env.redirect "/watch?#{env.params.query}&listen=0"
+ if audio_streams.empty?
+ if params.quality == "dash"
+ env.params.query.delete_all("quality")
+ env.params.query["quality"] = "medium"
+ next env.redirect "/watch?#{env.params.query}"
+ elsif params.listen
+ env.params.query.delete_all("listen")
+ env.params.query["listen"] = "0"
+ next env.redirect "/watch?#{env.params.query}"
+ end
end
captions = video.captions
@@ -689,6 +702,17 @@ get "/embed/:id" do |env|
video_streams = video.video_streams(adaptive_fmts)
audio_streams = video.audio_streams(adaptive_fmts)
+ if audio_streams.empty?
+ if params.quality == "dash"
+ env.params.query.delete_all("quality")
+ next env.redirect "/embed/#{video_id}?#{env.params.query}"
+ elsif params.listen
+ env.params.query.delete_all("listen")
+ env.params.query["listen"] = "0"
+ next env.redirect "/embed/#{video_id}?#{env.params.query}"
+ end
+ end
+
captions = video.captions
preferred_captions = captions.select { |caption|
@@ -1478,6 +1502,9 @@ post "/preferences" do |env|
speed = env.params.body["speed"]?.try &.as(String).to_f32?
speed ||= CONFIG.default_user_preferences.speed
+ player_style = env.params.body["player_style"]?.try &.as(String)
+ player_style ||= CONFIG.default_user_preferences.player_style
+
quality = env.params.body["quality"]?.try &.as(String)
quality ||= CONFIG.default_user_preferences.quality
@@ -1506,8 +1533,7 @@ post "/preferences" do |env|
locale ||= CONFIG.default_user_preferences.locale
dark_mode = env.params.body["dark_mode"]?.try &.as(String)
- dark_mode ||= "off"
- dark_mode = dark_mode == "on"
+ dark_mode ||= CONFIG.default_user_preferences.dark_mode
thin_mode = env.params.body["thin_mode"]?.try &.as(String)
thin_mode ||= "off"
@@ -1531,6 +1557,7 @@ post "/preferences" do |env|
notifications_only ||= "off"
notifications_only = notifications_only == "on"
+ # Convert to JSON and back again to take advantage of converters used for compatability
preferences = Preferences.from_json({
annotations: annotations,
annotations_subscribed: annotations_subscribed,
@@ -1546,6 +1573,7 @@ post "/preferences" do |env|
locale: locale,
max_results: max_results,
notifications_only: notifications_only,
+ player_style: player_style,
quality: quality,
redirect_feed: redirect_feed,
related_videos: related_videos,
@@ -1625,12 +1653,27 @@ get "/toggle_theme" do |env|
if user = env.get? "user"
user = user.as(User)
preferences = user.preferences
- preferences.dark_mode = !preferences.dark_mode
- PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
+ case preferences.dark_mode
+ when "dark"
+ preferences.dark_mode = "light"
+ else
+ preferences.dark_mode = "dark"
+ end
+
+ preferences = preferences.to_json
+
+ PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email)
else
preferences = env.get("preferences").as(Preferences)
- preferences.dark_mode = !preferences.dark_mode
+
+ case preferences.dark_mode
+ when "dark"
+ preferences.dark_mode = "light"
+ else
+ preferences.dark_mode = "dark"
+ end
+
preferences = preferences.to_json
if Kemal.config.ssl || config.https_only
@@ -2003,7 +2046,7 @@ post "/data_control" do |env|
env.response.puts %(<meta http-equiv="refresh" content="0; url=#{referer}">)
env.response.puts %(<link rel="stylesheet" href="/css/ionicons.min.css?v=#{ASSET_COMMIT}">)
env.response.puts %(<link rel="stylesheet" href="/css/default.css?v=#{ASSET_COMMIT}">)
- if env.get("preferences").as(Preferences).dark_mode
+ if env.get("preferences").as(Preferences).dark_mode == "dark"
env.response.puts %(<link rel="stylesheet" href="/css/darktheme.css?v=#{ASSET_COMMIT}">)
else
env.response.puts %(<link rel="stylesheet" href="/css/lighttheme.css?v=#{ASSET_COMMIT}">)
@@ -2966,7 +3009,7 @@ get "/user/:user/about" do |env|
env.redirect "/channel/#{user}"
end
-get "/channel:ucid/about" do |env|
+get "/channel/:ucid/about" do |env|
ucid = env.params.url["ucid"]
env.redirect "/channel/#{ucid}"
end
@@ -3010,8 +3053,7 @@ get "/channel/:ucid" do |env|
item.author
end
end
- items.select! { |item| item.responds_to?(:thumbnail_id) && item.thumbnail_id }
- items = items.map { |item| item.as(SearchPlaylist) }
+ items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) }
items.each { |item| item.author = "" }
else
sort_options = {"newest", "oldest", "popular"}
@@ -3071,8 +3113,7 @@ get "/channel/:ucid/playlists" do |env|
end
items, continuation = fetch_channel_playlists(channel.ucid, channel.author, channel.auto_generated, continuation, sort_by)
- items.select! { |item| item.is_a?(SearchPlaylist) && !item.videos.empty? }
- items = items.map { |item| item.as(SearchPlaylist) }
+ items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) }
items.each { |item| item.author = "" }
env.set "search", "channel:#{channel.ucid} "
@@ -3188,35 +3229,35 @@ get "/api/v1/storyboards/:id" do |env|
storyboard = storyboard[0]
end
- webvtt = <<-END_VTT
- WEBVTT
+ String.build do |str|
+ str << <<-END_VTT
+ WEBVTT
- END_VTT
+ END_VTT
- start_time = 0.milliseconds
- end_time = storyboard[:interval].milliseconds
+ start_time = 0.milliseconds
+ end_time = storyboard[:interval].milliseconds
- storyboard[:storyboard_count].times do |i|
- host_url = make_host_url(config, Kemal.config)
- url = storyboard[:url].gsub("$M", i).gsub("https://i9.ytimg.com", host_url)
+ storyboard[:storyboard_count].times do |i|
+ host_url = make_host_url(config, Kemal.config)
+ url = storyboard[:url].gsub("$M", i).gsub("https://i9.ytimg.com", host_url)
- storyboard[:storyboard_height].times do |j|
- storyboard[:storyboard_width].times do |k|
- webvtt += <<-END_CUE
- #{start_time}.000 --> #{end_time}.000
- #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width]},#{storyboard[:height]}
+ storyboard[:storyboard_height].times do |j|
+ storyboard[:storyboard_width].times do |k|
+ str << <<-END_CUE
+ #{start_time}.000 --> #{end_time}.000
+ #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width]},#{storyboard[:height]}
- END_CUE
+ END_CUE
- start_time += storyboard[:interval].milliseconds
- end_time += storyboard[:interval].milliseconds
+ start_time += storyboard[:interval].milliseconds
+ end_time += storyboard[:interval].milliseconds
+ end
end
end
end
-
- webvtt
end
get "/api/v1/captions/:id" do |env|
@@ -3286,7 +3327,7 @@ get "/api/v1/captions/:id" do |env|
caption = caption[0]
end
- url = caption.baseUrl + "&tlang=#{tlang}"
+ url = "#{caption.baseUrl}&tlang=#{tlang}"
# Auto-generated captions often have cues that aren't aligned properly with the video,
# as well as some other markup that makes it cumbersome, so we try to fix that here
@@ -3294,46 +3335,47 @@ get "/api/v1/captions/:id" do |env|
caption_xml = client.get(url).body
caption_xml = XML.parse(caption_xml)
- webvtt = <<-END_VTT
- WEBVTT
- Kind: captions
- Language: #{tlang || caption.languageCode}
+ webvtt = String.build do |str|
+ str << <<-END_VTT
+ WEBVTT
+ Kind: captions
+ Language: #{tlang || caption.languageCode}
- END_VTT
+ END_VTT
- caption_nodes = caption_xml.xpath_nodes("//transcript/text")
- caption_nodes.each_with_index do |node, i|
- start_time = node["start"].to_f.seconds
- duration = node["dur"]?.try &.to_f.seconds
- duration ||= start_time
+ caption_nodes = caption_xml.xpath_nodes("//transcript/text")
+ caption_nodes.each_with_index do |node, i|
+ start_time = node["start"].to_f.seconds
+ duration = node["dur"]?.try &.to_f.seconds
+ duration ||= start_time
- if caption_nodes.size > i + 1
- end_time = caption_nodes[i + 1]["start"].to_f.seconds
- else
- end_time = start_time + duration
- end
+ if caption_nodes.size > i + 1
+ end_time = caption_nodes[i + 1]["start"].to_f.seconds
+ else
+ end_time = start_time + duration
+ end
- start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}"
- end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}"
+ start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}"
+ end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}"
- text = HTML.unescape(node.content)
- text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "")
- text = text.gsub(/<\/font>/, "")
- if md = text.match(/(?<name>.*) : (?<text>.*)/)
- text = "<v #{md["name"]}>#{md["text"]}</v>"
- end
+ text = HTML.unescape(node.content)
+ text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "")
+ text = text.gsub(/<\/font>/, "")
+ if md = text.match(/(?<name>.*) : (?<text>.*)/)
+ text = "<v #{md["name"]}>#{md["text"]}</v>"
+ end
- webvtt += <<-END_CUE
- #{start_time} --> #{end_time}
- #{text}
+ str << <<-END_CUE
+ #{start_time} --> #{end_time}
+ #{text}
- END_CUE
+ END_CUE
+ end
end
else
- url += "&format=vtt"
- webvtt = client.get(url).body
+ webvtt = client.get("#{url}&format=vtt").body
end
if title = env.params.query["title"]?
@@ -4066,8 +4108,10 @@ get "/api/v1/playlists/:plid" do |env|
response = JSON.build do |json|
json.object do
+ json.field "type", "playlist"
json.field "title", playlist.title
json.field "playlistId", playlist.id
+ json.field "playlistThumbnail", playlist.thumbnail
json.field "author", playlist.author
json.field "authorId", playlist.ucid
@@ -4796,12 +4840,24 @@ get "/videoplayback" do |env|
end
end
+ client = make_client(URI.parse(host), region)
+
response = HTTP::Client::Response.new(403)
5.times do
begin
- client = make_client(URI.parse(host), region)
response = client.head(url, headers)
- break
+
+ if response.headers["Location"]?
+ location = URI.parse(response.headers["Location"])
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ host = "#{location.scheme}://#{location.host}"
+ client = make_client(URI.parse(host), region)
+
+ url = "#{location.full_path}&host=#{location.host}#{region ? "&region=#{region}" : ""}"
+ else
+ break
+ end
rescue Socket::Addrinfo::Error
if !mns.empty?
mn = mns.pop
@@ -4809,25 +4865,11 @@ get "/videoplayback" do |env|
fvip = "3"
host = "https://r#{fvip}---#{mn}.googlevideo.com"
+ client = make_client(URI.parse(host), region)
rescue ex
end
end
- if response.headers["Location"]?
- url = URI.parse(response.headers["Location"])
- host = url.host
- env.response.headers["Access-Control-Allow-Origin"] = "*"
-
- url = url.full_path
- url += "&host=#{host}"
-
- if region
- url += "&region=#{region}"
- end
-
- next env.redirect url
- end
-
if response.status_code >= 400
env.response.status_code = response.status_code
next
@@ -4884,6 +4926,8 @@ get "/videoplayback" do |env|
chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1
end
+ client = make_client(URI.parse(host), region)
+
# TODO: Record bytes written so we can restart after a chunk fails
while true
if !range_end && content_length
@@ -4901,7 +4945,6 @@ get "/videoplayback" do |env|
headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}"
begin
- client = make_client(URI.parse(host), region)
client.get(url, headers) do |response|
if first_chunk
if !env.request.headers["Range"]? && response.status_code == 206
@@ -4920,11 +4963,7 @@ get "/videoplayback" do |env|
if location = response.headers["Location"]?
location = URI.parse(location)
- location = "#{location.full_path}&host=#{location.host}"
-
- if region
- location += "&region=#{region}"
- end
+ location = "#{location.full_path}&host=#{location.host}#{region ? "&region=#{region}" : ""}"
env.redirect location
break
@@ -4951,6 +4990,8 @@ get "/videoplayback" do |env|
rescue ex
if ex.message != "Error reading socket: Connection reset by peer"
break
+ else
+ client = make_client(URI.parse(host), region)
end
end
@@ -5049,6 +5090,43 @@ get "/sb/:id/:storyboard/:index" do |env|
end
end
+get "/s_p/:id/:name" do |env|
+ id = env.params.url["id"]
+ name = env.params.url["name"]
+
+ host = "https://i9.ytimg.com"
+ client = make_client(URI.parse(host))
+ url = env.request.resource
+
+ headers = HTTP::Headers.new
+ REQUEST_HEADERS_WHITELIST.each do |header|
+ if env.request.headers[header]?
+ headers[header] = env.request.headers[header]
+ end
+ end
+
+ begin
+ client.get(url, headers) do |response|
+ env.response.status_code = response.status_code
+ response.headers.each do |key, value|
+ if !RESPONSE_HEADERS_BLACKLIST.includes? key
+ env.response.headers[key] = value
+ end
+ end
+
+ env.response.headers["Access-Control-Allow-Origin"] = "*"
+
+ if response.status_code >= 300 && response.status_code != 404
+ env.response.headers.delete("Transfer-Encoding")
+ break
+ end
+
+ proxy_file(response, env)
+ end
+ rescue ex
+ end
+end
+
get "/vi/:id/:name" do |env|
id = env.params.url["id"]
name = env.params.url["name"]
@@ -5095,7 +5173,7 @@ get "/vi/:id/:name" do |env|
end
end
-# Undocumented, creates anonymous playlist with specified 'video_ids'
+# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos
get "/watch_videos" do |env|
client = make_client(YT_URL)
diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr
index 5e01cef2..107039a6 100644
--- a/src/invidious/channels.cr
+++ b/src/invidious/channels.cr
@@ -387,14 +387,15 @@ def fetch_channel_playlists(ucid, author, auto_generated, continuation, sort_by)
html = XML.parse_html(json["content_html"].as_s)
nodeset = html.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
- else
- url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list"
+ elsif auto_generated
+ url = "/channel/#{ucid}"
- if auto_generated
- url += "&view=50"
- else
- url += "&view=1"
- end
+ response = client.get(url)
+ html = XML.parse_html(response.body)
+
+ nodeset = html.xpath_nodes(%q(//ul[@id="browse-items-primary"]/li[contains(@class, "feed-item-container")]))
+ else
+ url = "/channel/#{ucid}/playlists?disable_polymer=1&flow=list&view=1"
case sort_by
when "last", "last_added"
diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr
index 7fbfb643..5965923d 100644
--- a/src/invidious/helpers/handlers.cr
+++ b/src/invidious/helpers/handlers.cr
@@ -69,20 +69,20 @@ class FilteredCompressHandler < Kemal::Handler
return call_next env if exclude_match? env
{% if flag?(:without_zlib) %}
- call_next env
- {% else %}
- request_headers = env.request.headers
-
- if request_headers.includes_word?("Accept-Encoding", "gzip")
- env.response.headers["Content-Encoding"] = "gzip"
- env.response.output = Gzip::Writer.new(env.response.output, sync_close: true)
- elsif request_headers.includes_word?("Accept-Encoding", "deflate")
- env.response.headers["Content-Encoding"] = "deflate"
- env.response.output = Flate::Writer.new(env.response.output, sync_close: true)
- end
+ call_next env
+ {% else %}
+ request_headers = env.request.headers
+
+ if request_headers.includes_word?("Accept-Encoding", "gzip")
+ env.response.headers["Content-Encoding"] = "gzip"
+ env.response.output = Gzip::Writer.new(env.response.output, sync_close: true)
+ elsif request_headers.includes_word?("Accept-Encoding", "deflate")
+ env.response.headers["Content-Encoding"] = "deflate"
+ env.response.output = Flate::Writer.new(env.response.output, sync_close: true)
+ end
- call_next env
- {% end %}
+ call_next env
+ {% end %}
end
end
@@ -159,10 +159,9 @@ class APIHandler < Kemal::Handler
call_next env
env.response.output.rewind
- response = env.response.output.gets_to_end
- if env.response.headers["Content-Type"]?.try &.== "application/json"
- response = JSON.parse(response)
+ if env.response.headers.includes_word?("Content-Type", "application/json")
+ response = JSON.parse(env.response.output)
if fields_text = env.params.query["fields"]?
begin
@@ -178,6 +177,8 @@ class APIHandler < Kemal::Handler
else
response = response.to_json
end
+ else
+ response = env.response.output.gets_to_end
end
rescue ex
ensure
diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr
index 9cefcf14..331f6360 100644
--- a/src/invidious/helpers/helpers.cr
+++ b/src/invidious/helpers/helpers.cr
@@ -24,6 +24,27 @@ end
struct ConfigPreferences
module StringToArray
+ def self.to_json(value : Array(String), json : JSON::Builder)
+ json.array do
+ value.each do |element|
+ json.string element
+ end
+ end
+ end
+
+ def self.from_json(value : JSON::PullParser) : Array(String)
+ begin
+ result = [] of String
+ value.read_array do
+ result << HTML.escape(value.read_string[0, 100])
+ end
+ rescue ex
+ result = [HTML.escape(value.read_string[0, 100]), ""]
+ end
+
+ result
+ end
+
def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder)
yaml.sequence do
value.each do |element|
@@ -44,11 +65,11 @@ struct ConfigPreferences
node.raise "Expected scalar, not #{item.class}"
end
- result << item.value
+ result << HTML.escape(item.value[0, 100])
end
rescue ex
if node.is_a?(YAML::Nodes::Scalar)
- result = [node.value, ""]
+ result = [HTML.escape(node.value[0, 100]), ""]
else
result = ["", ""]
end
@@ -58,6 +79,53 @@ struct ConfigPreferences
end
end
+ module BoolToString
+ def self.to_json(value : String, json : JSON::Builder)
+ json.string value
+ end
+
+ def self.from_json(value : JSON::PullParser) : String
+ begin
+ result = value.read_string
+
+ if result.empty?
+ CONFIG.default_user_preferences.dark_mode
+ else
+ result
+ end
+ rescue ex
+ result = value.read_bool
+
+ if result
+ "dark"
+ else
+ "light"
+ end
+ end
+ end
+
+ def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
+ yaml.scalar value
+ end
+
+ def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
+ unless node.is_a?(YAML::Nodes::Scalar)
+ node.raise "Expected sequence, not #{node.class}"
+ end
+
+ case node.value
+ when "true"
+ "dark"
+ when "false"
+ "light"
+ when ""
+ CONFIG.default_user_preferences.dark_mode
+ else
+ node.value
+ end
+ end
+ end
+
yaml_mapping({
annotations: {type: Bool, default: false},
annotations_subscribed: {type: Bool, default: false},
@@ -66,13 +134,14 @@ struct ConfigPreferences
comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray},
continue: {type: Bool, default: false},
continue_autoplay: {type: Bool, default: true},
- dark_mode: {type: Bool, default: false},
+ dark_mode: {type: String, default: "", converter: BoolToString},
latest_only: {type: Bool, default: false},
listen: {type: Bool, default: false},
local: {type: Bool, default: false},
locale: {type: String, default: "en-US"},
max_results: {type: Int32, default: 40},
notifications_only: {type: Bool, default: false},
+ player_style: {type: String, default: "invidious"},
quality: {type: String, default: "hd720"},
redirect_feed: {type: Bool, default: false},
related_videos: {type: Bool, default: true},
@@ -243,8 +312,7 @@ end
def extract_videos(nodeset, ucid = nil, author_name = nil)
videos = extract_items(nodeset, ucid, author_name)
- videos.select! { |item| !item.is_a?(SearchChannel | SearchPlaylist) }
- videos.map { |video| video.as(SearchVideo) }
+ videos.select { |item| item.is_a?(SearchVideo) }.map { |video| video.as(SearchVideo) }
end
def extract_items(nodeset, ucid = nil, author_name = nil)
@@ -263,18 +331,8 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
next
end
- anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
- if anchor
- author = anchor.content.strip
- author_id = anchor["href"].split("/")[-1]
- end
-
- author ||= author_name
- author_id ||= ucid
-
- author ||= ""
- author_id ||= ""
-
+ author_id = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)).try &.["href"].split("/")[-1] || ucid || ""
+ author = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a)).try &.content.strip || author_name || ""
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")])).try &.to_s || ""
tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")]))
@@ -292,14 +350,14 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
anchor = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/a))
end
- video_count = node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b))
+ video_count = node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b)) ||
+ node.xpath_node(%q(.//span[@class="formatted-video-count-label"]))
if video_count
video_count = video_count.content
if video_count == "50+"
author = "YouTube"
author_id = "UC-9-kyTW8ZkZNDHQJ6FgpwQ"
- video_count = video_count.rchop("+")
end
video_count = video_count.gsub(/\D/, "").to_i?
@@ -329,22 +387,17 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
)
end
- playlist_thumbnail = node.xpath_node(%q(.//div/span/img)).try &.["data-thumb"]?
- playlist_thumbnail ||= node.xpath_node(%q(.//div/span/img)).try &.["src"]
- if !playlist_thumbnail || playlist_thumbnail.empty?
- thumbnail_id = videos[0]?.try &.id
- else
- thumbnail_id = playlist_thumbnail.match(/\/vi\/(?<video_id>[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"]
- end
+ playlist_thumbnail = node.xpath_node(%q(.//span/img)).try &.["data-thumb"]?
+ playlist_thumbnail ||= node.xpath_node(%q(.//span/img)).try &.["src"]
items << SearchPlaylist.new(
- title,
- plid,
- author,
- author_id,
- video_count,
- videos,
- thumbnail_id
+ title: title,
+ id: plid,
+ author: author,
+ ucid: author_id,
+ video_count: video_count,
+ videos: videos,
+ thumbnail: playlist_thumbnail
)
when .includes? "yt-lockup-channel"
author = title.strip
@@ -379,47 +432,20 @@ def extract_items(nodeset, ucid = nil, author_name = nil)
else
id = id.lchop("/watch?v=")
- metadata = node.xpath_nodes(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li))
+ metadata = node.xpath_node(%q(.//div[contains(@class,"yt-lockup-meta")]/ul))
- begin
- published = decode_date(metadata[0].content.lchop("Streamed ").lchop("Starts "))
- rescue ex
- end
- begin
- published ||= Time.unix(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
- rescue ex
- end
+ published = metadata.try &.xpath_node(%q(.//li[contains(text(), " ago")])).try { |node| decode_date(node.content.sub(/^[a-zA-Z]+ /, "")) }
+ published ||= metadata.try &.xpath_node(%q(.//span[@data-timestamp])).try { |node| Time.unix(node["data-timestamp"].to_i64) }
published ||= Time.utc
- begin
- view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64?
- rescue ex
- end
- begin
- view_count ||= metadata.try &.[1].content.delete("No views,").try &.to_i64?
- rescue ex
- end
+ view_count = metadata.try &.xpath_node(%q(.//li[contains(text(), " views")])).try &.content.gsub(/\D/, "").to_i64?
view_count ||= 0_i64
- length_seconds = node.xpath_node(%q(.//span[@class="video-time"]))
- if length_seconds
- length_seconds = decode_length_seconds(length_seconds.content)
- else
- length_seconds = -1
- end
-
- live_now = node.xpath_node(%q(.//span[contains(@class, "yt-badge-live")]))
- if live_now
- live_now = true
- else
- live_now = false
- end
+ length_seconds = node.xpath_node(%q(.//span[@class="video-time"])).try { |node| decode_length_seconds(node.content) }
+ length_seconds ||= -1
- if node.xpath_node(%q(.//span[text()="Premium"]))
- premium = true
- else
- premium = false
- end
+ live_now = node.xpath_node(%q(.//span[contains(@class, "yt-badge-live")])) ? true : false
+ premium = node.xpath_node(%q(.//span[text()="Premium"])) ? true : false
if !premium || node.xpath_node(%q(.//span[contains(text(), "Free episode")]))
paid = false
@@ -457,26 +483,18 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
nodeset.each do |shelf|
shelf_anchor = shelf.xpath_node(%q(.//h2[contains(@class, "branded-page-module-title")]))
+ next if !shelf_anchor
- if !shelf_anchor
- next
- end
-
- title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")]))
- if title
- title = title.content.strip
- end
+ title = shelf_anchor.xpath_node(%q(.//span[contains(@class, "branded-page-module-title-text")])).try &.content.strip
title ||= ""
id = shelf_anchor.xpath_node(%q(.//a)).try &.["href"]
- if !id
- next
- end
+ next if !id
- is_playlist = false
+ shelf_is_playlist = false
videos = [] of SearchPlaylistVideo
- shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list")]/li)).each do |child_node|
+ shelf.xpath_nodes(%q(.//ul[contains(@class, "yt-uix-shelfslider-list") or contains(@class, "expanded-shelf-content-list")]/li)).each do |child_node|
type = child_node.xpath_node(%q(./div))
if !type
next
@@ -484,7 +502,7 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
case type["class"]
when .includes? "yt-lockup-video"
- is_playlist = true
+ shelf_is_playlist = true
anchor = child_node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
if anchor
@@ -517,41 +535,60 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil)
playlist_thumbnail = child_node.xpath_node(%q(.//span/img)).try &.["data-thumb"]?
playlist_thumbnail ||= child_node.xpath_node(%q(.//span/img)).try &.["src"]
- if !playlist_thumbnail || playlist_thumbnail.empty?
- thumbnail_id = videos[0]?.try &.id
- else
- thumbnail_id = playlist_thumbnail.match(/\/vi\/(?<video_id>[a-zA-Z0-9_-]{11})\/\w+\.jpg/).try &.["video_id"]
- end
- video_count_label = child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"]))
- if video_count_label
- video_count = video_count_label.content.gsub(/\D/, "").to_i?
+ video_count = child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b)) ||
+ child_node.xpath_node(%q(.//span[@class="formatted-video-count-label"]))
+ if video_count
+ video_count = video_count.content.gsub(/\D/, "").to_i?
end
video_count ||= 50
+ videos = [] of SearchPlaylistVideo
+ child_node.xpath_nodes(%q(.//*[contains(@class, "yt-lockup-playlist-items")]/li)).each do |video|
+ anchor = video.xpath_node(%q(.//a))
+ if anchor
+ video_title = anchor.content.strip
+ id = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)["v"]
+ end
+ video_title ||= ""
+ id ||= ""
+
+ anchor = video.xpath_node(%q(.//span/span))
+ if anchor
+ length_seconds = decode_length_seconds(anchor.content)
+ end
+ length_seconds ||= 0
+
+ videos << SearchPlaylistVideo.new(
+ video_title,
+ id,
+ length_seconds
+ )
+ end
+
items << SearchPlaylist.new(
- playlist_title,
- plid,
- author_name,
- ucid,
- video_count,
- Array(SearchPlaylistVideo).new,
- thumbnail_id
+ title: playlist_title,
+ id: plid,
+ author: author_name,
+ ucid: ucid,
+ video_count: video_count,
+ videos: videos,
+ thumbnail: playlist_thumbnail
)
end
end
- if is_playlist
+ if shelf_is_playlist
plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"]
items << SearchPlaylist.new(
- title,
- plid,
- author_name,
- ucid,
- videos.size,
- videos,
- videos[0].try &.id
+ title: title,
+ id: plid,
+ author: author_name,
+ ucid: ucid,
+ video_count: videos.size,
+ videos: videos,
+ thumbnail: "https://i.ytimg.com/vi/#{videos[0].id}/mqdefault.jpg"
)
end
end
diff --git a/src/invidious/helpers/patch_mapping.cr b/src/invidious/helpers/patch_mapping.cr
index 8360caa6..e138aa1c 100644
--- a/src/invidious/helpers/patch_mapping.cr
+++ b/src/invidious/helpers/patch_mapping.cr
@@ -4,7 +4,7 @@ def Object.from_json(string_or_io, default) : self
new parser, default
end
-# Adds configurable 'default' to
+# Adds configurable 'default'
macro patched_json_mapping(_properties_, strict = false)
{% for key, value in _properties_ %}
{% _properties_[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %}
diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr
index e3c9d2f5..fde282cd 100644
--- a/src/invidious/helpers/proxy.cr
+++ b/src/invidious/helpers/proxy.cr
@@ -31,10 +31,10 @@ class HTTPProxy
if resp[:code]? == 200
{% if !flag?(:without_openssl) %}
- if tls
- tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host)
- socket = tls_socket
- end
+ if tls
+ tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host)
+ socket = tls_socket
+ end
{% end %}
return socket
diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr
index 69aae839..b39f65c5 100644
--- a/src/invidious/helpers/utils.cr
+++ b/src/invidious/helpers/utils.cr
@@ -356,3 +356,16 @@ def parse_range(range)
return 0_i64, nil
end
+
+def convert_theme(theme)
+ case theme
+ when "true"
+ "dark"
+ when "false"
+ "light"
+ when "", nil
+ nil
+ else
+ theme
+ end
+end
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr
index d28a4149..7965d990 100644
--- a/src/invidious/playlists.cr
+++ b/src/invidious/playlists.cr
@@ -51,6 +51,7 @@ struct Playlist
video_count: Int32,
views: Int64,
updated: Time,
+ thumbnail: String?,
})
end
@@ -223,6 +224,9 @@ def fetch_playlist(plid, locale)
description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1])).try &.to_s ||
document.xpath_node(%q(//span[@class="pl-header-description-text"])).try &.to_s || ""
+ playlist_thumbnail = document.xpath_node(%q(//div[@class="pl-header-thumb"]/img)).try &.["data-thumb"]? ||
+ document.xpath_node(%q(//div[@class="pl-header-thumb"]/img)).try &.["src"]
+
# YouTube allows anonymous playlists, so most of this can be empty or optional
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"]))
author = anchor.try &.xpath_node(%q(.//li[1]/a)).try &.content
@@ -234,15 +238,12 @@ def fetch_playlist(plid, locale)
video_count = anchor.try &.xpath_node(%q(.//li[2])).try &.content.gsub(/\D/, "").to_i?
video_count ||= 0
- views = anchor.try &.xpath_node(%q(.//li[3])).try &.content.delete("No views, ").to_i64?
+
+ views = anchor.try &.xpath_node(%q(.//li[3])).try &.content.gsub(/\D/, "").to_i64?
views ||= 0_i64
- updated = anchor.try &.xpath_node(%q(.//li[4])).try &.content.lchop("Last updated on ").lchop("Updated ")
- if updated
- updated = decode_date(updated)
- else
- updated = Time.utc
- end
+ updated = anchor.try &.xpath_node(%q(.//li[4])).try &.content.lchop("Last updated on ").lchop("Updated ").try { |date| decode_date(date) }
+ updated ||= Time.utc
playlist = Playlist.new(
title: title,
@@ -253,7 +254,8 @@ def fetch_playlist(plid, locale)
description_html: description_html,
video_count: video_count,
views: views,
- updated: updated
+ updated: updated,
+ thumbnail: playlist_thumbnail,
)
return playlist
diff --git a/src/invidious/search.cr b/src/invidious/search.cr
index 1d4805bf..a55bb216 100644
--- a/src/invidious/search.cr
+++ b/src/invidious/search.cr
@@ -117,6 +117,7 @@ struct SearchPlaylist
json.field "type", "playlist"
json.field "title", self.title
json.field "playlistId", self.id
+ json.field "playlistThumbnail", self.thumbnail
json.field "author", self.author
json.field "authorId", self.ucid
@@ -152,13 +153,13 @@ struct SearchPlaylist
end
db_mapping({
- title: String,
- id: String,
- author: String,
- ucid: String,
- video_count: Int32,
- videos: Array(SearchPlaylistVideo),
- thumbnail_id: String?,
+ title: String,
+ id: String,
+ author: String,
+ ucid: String,
+ video_count: Int32,
+ videos: Array(SearchPlaylistVideo),
+ thumbnail: String?,
})
end
@@ -276,96 +277,97 @@ end
def produce_search_params(sort : String = "relevance", date : String = "", content_type : String = "",
duration : String = "", features : Array(String) = [] of String)
- head = "\x08"
- head += case sort
- when "relevance"
- "\x00"
- when "rating"
- "\x01"
- when "upload_date", "date"
- "\x02"
- when "view_count", "views"
- "\x03"
- else
- raise "No sort #{sort}"
- end
+ header = IO::Memory.new
+ header.write Bytes[0x08]
+ header.write case sort
+ when "relevance"
+ Bytes[0x00]
+ when "rating"
+ Bytes[0x01]
+ when "upload_date", "date"
+ Bytes[0x02]
+ when "view_count", "views"
+ Bytes[0x03]
+ else
+ raise "No sort #{sort}"
+ end
- body = ""
- body += case date
- when "hour"
- "\x08\x01"
- when "today"
- "\x08\x02"
- when "week"
- "\x08\x03"
- when "month"
- "\x08\x04"
- when "year"
- "\x08\x05"
- else
- ""
- end
+ body = IO::Memory.new
+ body.write case date
+ when "hour"
+ Bytes[0x08, 0x01]
+ when "today"
+ Bytes[0x08, 0x02]
+ when "week"
+ Bytes[0x08, 0x03]
+ when "month"
+ Bytes[0x08, 0x04]
+ when "year"
+ Bytes[0x08, 0x05]
+ else
+ Bytes.new(0)
+ end
- body += case content_type
- when "video"
- "\x10\x01"
- when "channel"
- "\x10\x02"
- when "playlist"
- "\x10\x03"
- when "movie"
- "\x10\x04"
- when "show"
- "\x10\x05"
- when "all"
- ""
- else
- "\x10\x01"
- end
+ body.write case content_type
+ when "video"
+ Bytes[0x10, 0x01]
+ when "channel"
+ Bytes[0x10, 0x02]
+ when "playlist"
+ Bytes[0x10, 0x03]
+ when "movie"
+ Bytes[0x10, 0x04]
+ when "show"
+ Bytes[0x10, 0x05]
+ when "all"
+ Bytes.new(0)
+ else
+ Bytes[0x10, 0x01]
+ end
- body += case duration
- when "short"
- "\x18\x01"
- when "long"
- "\x18\x02"
- else
- ""
- end
+ body.write case duration
+ when "short"
+ Bytes[0x18, 0x01]
+ when "long"
+ Bytes[0x18, 0x12]
+ else
+ Bytes.new(0)
+ end
features.each do |feature|
- body += case feature
- when "hd"
- "\x20\x01"
- when "subtitles"
- "\x28\x01"
- when "creative_commons", "cc"
- "\x30\x01"
- when "3d"
- "\x38\x01"
- when "live", "livestream"
- "\x40\x01"
- when "purchased"
- "\x48\x01"
- when "4k"
- "\x70\x01"
- when "360"
- "\x78\x01"
- when "location"
- "\xb8\x01\x01"
- when "hdr"
- "\xc8\x01\x01"
- else
- raise "Unknown feature #{feature}"
- end
+ body.write case feature
+ when "hd"
+ Bytes[0x20, 0x01]
+ when "subtitles"
+ Bytes[0x28, 0x01]
+ when "creative_commons", "cc"
+ Bytes[0x30, 0x01]
+ when "3d"
+ Bytes[0x38, 0x01]
+ when "live", "livestream"
+ Bytes[0x40, 0x01]
+ when "purchased"
+ Bytes[0x48, 0x01]
+ when "4k"
+ Bytes[0x70, 0x01]
+ when "360"
+ Bytes[0x78, 0x01]
+ when "location"
+ Bytes[0xb8, 0x01, 0x01]
+ when "hdr"
+ Bytes[0xc8, 0x01, 0x01]
+ else
+ Bytes.new(0)
+ end
end
+ token = header
if !body.empty?
- token = head + "\x12" + body.size.unsafe_chr + body
- else
- token = head
+ token.write Bytes[0x12, body.bytesize]
+ token.write body.to_slice
end
- token = Base64.urlsafe_encode(token)
+ token = Base64.urlsafe_encode(token.to_slice)
token = URI.escape(token)
return token
diff --git a/src/invidious/users.cr b/src/invidious/users.cr
index 1b5d34c6..d3da28d7 100644
--- a/src/invidious/users.cr
+++ b/src/invidious/users.cr
@@ -31,62 +31,6 @@ struct User
end
struct Preferences
- module StringToArray
- def self.to_json(value : Array(String), json : JSON::Builder)
- json.array do
- value.each do |element|
- json.string element
- end
- end
- end
-
- def self.from_json(value : JSON::PullParser) : Array(String)
- begin
- result = [] of String
- value.read_array do
- result << HTML.escape(value.read_string[0, 100])
- end
- rescue ex
- result = [HTML.escape(value.read_string[0, 100]), ""]
- end
-
- result
- end
-
- def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder)
- yaml.sequence do
- value.each do |element|
- yaml.scalar element
- end
- end
- end
-
- def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String)
- begin
- unless node.is_a?(YAML::Nodes::Sequence)
- node.raise "Expected sequence, not #{node.class}"
- end
-
- result = [] of String
- node.nodes.each do |item|
- unless item.is_a?(YAML::Nodes::Scalar)
- node.raise "Expected scalar, not #{item.class}"
- end
-
- result << HTML.escape(item.value[0, 100])
- end
- rescue ex
- if node.is_a?(YAML::Nodes::Scalar)
- result = [HTML.escape(node.value[0, 100]), ""]
- else
- result = ["", ""]
- end
- end
-
- result
- end
- end
-
module ProcessString
def self.to_json(value : String, json : JSON::Builder)
json.string value
@@ -127,17 +71,18 @@ struct Preferences
annotations: {type: Bool, default: CONFIG.default_user_preferences.annotations},
annotations_subscribed: {type: Bool, default: CONFIG.default_user_preferences.annotations_subscribed},
autoplay: {type: Bool, default: CONFIG.default_user_preferences.autoplay},
- captions: {type: Array(String), default: CONFIG.default_user_preferences.captions, converter: StringToArray},
- comments: {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: StringToArray},
+ captions: {type: Array(String), default: CONFIG.default_user_preferences.captions, converter: ConfigPreferences::StringToArray},
+ comments: {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: ConfigPreferences::StringToArray},
continue: {type: Bool, default: CONFIG.default_user_preferences.continue},
continue_autoplay: {type: Bool, default: CONFIG.default_user_preferences.continue_autoplay},
- dark_mode: {type: Bool, default: CONFIG.default_user_preferences.dark_mode},
+ dark_mode: {type: String, default: CONFIG.default_user_preferences.dark_mode, converter: ConfigPreferences::BoolToString},
latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only},
listen: {type: Bool, default: CONFIG.default_user_preferences.listen},
local: {type: Bool, default: CONFIG.default_user_preferences.local},
locale: {type: String, default: CONFIG.default_user_preferences.locale, converter: ProcessString},
max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results, converter: ClampInt},
notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only},
+ player_style: {type: String, default: CONFIG.default_user_preferences.player_style, converter: ProcessString},
quality: {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString},
redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed},
related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos},
@@ -350,8 +295,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
args = arg_array(notifications)
- notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args})
- ORDER BY published DESC", notifications, as: ChannelVideo)
+ notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args}) ORDER BY published DESC", notifications, as: ChannelVideo)
videos = [] of ChannelVideo
notifications.sort_by! { |video| video.published }.reverse!
@@ -377,14 +321,11 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
- videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE \
- NOT id = ANY (#{values}) \
- ORDER BY ucid, published DESC", as: ChannelVideo)
+ videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY ucid, published DESC", as: ChannelVideo)
else
# Show latest video from each channel
- videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} \
- ORDER BY ucid, published DESC", as: ChannelVideo)
+ videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo)
end
videos.sort_by! { |video| video.published }.reverse!
@@ -397,14 +338,11 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
else
values = "VALUES #{user.watched.map { |id| %(('#{id}')) }.join(",")}"
end
- videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE \
- NOT id = ANY (#{values}) \
- ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
+ videos = PG_DB.query_all("SELECT * FROM #{view_name} WHERE NOT id = ANY (#{values}) ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
else
# Sort subscriptions as normal
- videos = PG_DB.query_all("SELECT * FROM #{view_name} \
- ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
+ videos = PG_DB.query_all("SELECT * FROM #{view_name} ORDER BY published DESC LIMIT $1 OFFSET $2", limit, offset, as: ChannelVideo)
end
end
@@ -421,16 +359,11 @@ def get_subscription_feed(db, user, max_results = 40, page = 1)
videos.sort_by! { |video| video.author }.reverse!
end
- notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email,
- as: Array(String))
+ notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email, as: Array(String))
notifications = videos.select { |v| notifications.includes? v.id }
videos = videos - notifications
end
- if !limit
- videos = videos[0..max_results]
- end
-
return videos, notifications
end
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index 7d0c7838..ef3f4d4b 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -108,33 +108,7 @@ CAPTION_LANGUAGES = {
"Zulu",
}
-REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"}
-BYPASS_REGIONS = {
- "GB",
- "DE",
- "FR",
- "IN",
- "CN",
- "RU",
- "CA",
- "JP",
- "IT",
- "TH",
- "ES",
- "AE",
- "KR",
- "IR",
- "BR",
- "PK",
- "ID",
- "BD",
- "MX",
- "PH",
- "EG",
- "VN",
- "CD",
- "TR",
-}
+REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"}
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476
VIDEO_FORMATS = {
@@ -258,6 +232,7 @@ struct VideoPreferences
listen: Bool,
local: Bool,
preferred_captions: Array(String),
+ player_style: String,
quality: String,
raw: Bool,
region: String?,
@@ -272,6 +247,7 @@ end
struct Video
property player_json : JSON::Any?
+ property recommended_json : JSON::Any?
module HTTPParamConverter
def self.from_rs(rs)
@@ -450,9 +426,29 @@ struct Video
json.field "videoThumbnails" do
generate_thumbnails(json, rv["id"], config, kemal_config)
end
+
json.field "author", rv["author"]
+ json.field "authorUrl", rv["author_url"]?
+ json.field "authorId", rv["ucid"]?
+ if rv["author_thumbnail"]?
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-")
+ json.field "width", quality
+ json.field "height", quality
+ end
+ end
+ end
+ end
+ end
+
json.field "lengthSeconds", rv["length_seconds"].to_i
json.field "viewCountText", rv["short_view_count_text"]
+ json.field "viewCount", rv["view_count"]?.try &.to_i64
end
end
end
@@ -710,12 +706,14 @@ struct Video
return audio_streams
end
- def player_response
- if !@player_json
- @player_json = JSON.parse(@info["player_response"])
- end
+ def recommended_videos
+ @recommended_json = JSON.parse(@info["recommended_videos"]) if !@recommended_json
+ @recommended_json.not_nil!
+ end
- return @player_json.not_nil!
+ def player_response
+ @player_json = JSON.parse(@info["player_response"]) if !@player_json
+ @player_json.not_nil!
end
def storyboards
@@ -928,6 +926,33 @@ def get_video(id, db, refresh = true, region = nil, force_refresh = false)
return video
end
+def extract_recommended(recommended_videos)
+ rvs = [] of HTTP::Params
+
+ recommended_videos.try &.each do |compact_renderer|
+ if compact_renderer["compactRadioRenderer"]? || compact_renderer["compactPlaylistRenderer"]?
+ # TODO
+ elsif video_renderer = compact_renderer["compactVideoRenderer"]?
+ recommended_video = HTTP::Params.new
+ recommended_video["id"] = video_renderer["videoId"].as_s
+ recommended_video["title"] = video_renderer["title"]["simpleText"].as_s
+ recommended_video["author"] = video_renderer["shortBylineText"]["runs"].as_a[0]["text"].as_s
+ recommended_video["ucid"] = video_renderer["shortBylineText"]["runs"].as_a[0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
+ recommended_video["author_thumbnail"] = video_renderer["channelThumbnail"]["thumbnails"][0]["url"].as_s
+
+ if view_count = video_renderer["viewCountText"]?.try { |field| field["simpleText"]?.try &.as_s || field["runs"][0]?.try &.["text"].as_s }.try &.delete(", views watching").to_i64?.try &.to_s
+ recommended_video["view_count"] = view_count
+ recommended_video["short_view_count_text"] = "#{number_to_short_text(view_count.to_i64)} views"
+ end
+ recommended_video["length_seconds"] = decode_length_seconds(video_renderer["lengthText"]?.try &.["simpleText"]?.try &.as_s || "0:00").to_s
+
+ rvs << recommended_video
+ end
+ end
+
+ rvs
+end
+
def extract_polymer_config(body, html)
params = HTTP::Params.new
@@ -958,36 +983,14 @@ def extract_polymer_config(body, html)
params["ctoken"] = comment_continuation.try &.["continuation"]?.try &.as_s || ""
params["itct"] = comment_continuation.try &.["clickTrackingParams"]?.try &.as_s || ""
- recommended_videos = initial_data["contents"]?
+ rvs = initial_data["contents"]?
.try &.["twoColumnWatchNextResults"]?
.try &.["secondaryResults"]?
.try &.["secondaryResults"]?
.try &.["results"]?
.try &.as_a
- rvs = [] of String
-
- recommended_videos.try &.each do |compact_renderer|
- if compact_renderer["compactRadioRenderer"]? || compact_renderer["compactPlaylistRenderer"]?
- # TODO
- elsif compact_renderer["compactVideoRenderer"]?
- compact_renderer = compact_renderer["compactVideoRenderer"]
-
- recommended_video = HTTP::Params.new
- recommended_video["id"] = compact_renderer["videoId"].as_s
- recommended_video["title"] = compact_renderer["title"]["simpleText"].as_s
- recommended_video["author"] = compact_renderer["shortBylineText"]["runs"].as_a[0]["text"].as_s
- recommended_video["ucid"] = compact_renderer["shortBylineText"]["runs"].as_a[0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
- recommended_video["author_thumbnail"] = compact_renderer["channelThumbnail"]["thumbnails"][0]["url"].as_s
-
- recommended_video["short_view_count_text"] = compact_renderer["shortViewCountText"]["simpleText"].as_s
- recommended_video["view_count"] = compact_renderer["viewCountText"]?.try &.["simpleText"]?.try &.as_s.delete(", views watching").to_i64?.try &.to_s || "0"
- recommended_video["length_seconds"] = decode_length_seconds(compact_renderer["lengthText"]?.try &.["simpleText"]?.try &.as_s || "0:00").to_s
-
- rvs << recommended_video.to_s
- end
- end
- params["rvs"] = rvs.join(",")
+ params["rvs"] = extract_recommended(rvs).join(",")
# TODO: Watching now
params["views"] = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]?
@@ -1097,8 +1100,32 @@ def extract_player_config(body, html)
params["session_token"] = md["session_token"]
end
- if md = body.match(/'RELATED_PLAYER_ARGS': (?<rvs>{"rvs":"[^"]+"})/)
- params["rvs"] = JSON.parse(md["rvs"])["rvs"].as_s
+ if md = body.match(/'RELATED_PLAYER_ARGS': (?<json>.*?),\n/)
+ recommended_json = JSON.parse(md["json"])
+ rvs_params = recommended_json["rvs"].as_s.split(",").map { |params| HTTP::Params.parse(params) }
+
+ if watch_next_response = recommended_json["watch_next_response"]?
+ watch_next_json = JSON.parse(watch_next_response.as_s)
+ rvs = watch_next_json["contents"]?
+ .try &.["twoColumnWatchNextResults"]?
+ .try &.["secondaryResults"]?
+ .try &.["secondaryResults"]?
+ .try &.["results"]?
+ .try &.as_a
+
+ rvs = extract_recommended(rvs)
+ rvs.each_with_index do |rv, i|
+ if !rv["view_count"]?
+ rv_params = rvs_params.select { |rv_params| rv_params["id"]? == (rv["id"]? || "") }[0]?
+ if rv_params
+ rvs[i]["short_view_count_text"] = rv_params["short_view_count_text"]
+ else
+ rvs.delete_at(i)
+ end
+ end
+ end
+ params["rvs"] = (rvs.map &.to_s).join(",")
+ end
end
html_info = body.match(/ytplayer\.config = (?<info>.*?);ytplayer\.load/).try &.["info"]
@@ -1131,34 +1158,24 @@ def fetch_video(id, region)
info = extract_player_config(response.body, html)
info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ")
- # Try to use proxies for region-blocked videos
- if info["reason"]? && info["reason"].includes? "your country"
- bypass_channel = Channel({XML::Node, HTTP::Params} | Nil).new
+ allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
+ if !allowed_regions || allowed_regions == [""]
+ allowed_regions = [] of String
+ end
- PROXY_LIST.each do |proxy_region, list|
- spawn do
- client = make_client(YT_URL, proxy_region)
- proxy_response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
+ # Check for region-blocks
+ if info["reason"]? && info["reason"].includes?("your country")
+ bypass_regions = PROXY_LIST.keys & allowed_regions
+ if !bypass_regions.empty?
+ region = bypass_regions[rand(bypass_regions.size)]
+ client = make_client(YT_URL, region)
+ response = client.get("/watch?v=#{id}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999")
- proxy_html = XML.parse_html(proxy_response.body)
- proxy_info = extract_player_config(proxy_response.body, proxy_html)
+ html = XML.parse_html(response.body)
+ info = extract_player_config(response.body, html)
- if !proxy_info["reason"]?
- proxy_info["region"] = proxy_region
- proxy_info["cookie"] = proxy_response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ")
- bypass_channel.send({proxy_html, proxy_info})
- else
- bypass_channel.send(nil)
- end
- end
- end
-
- PROXY_LIST.size.times do
- response = bypass_channel.receive
- if response
- html, info = response
- break
- end
+ info["region"] = region if region
+ info["cookie"] = response.cookies.to_h.map { |name, cookie| "#{name}=#{cookie.value}" }.join("; ")
end
end
@@ -1214,9 +1231,6 @@ def fetch_video(id, region)
published ||= Time.utc.to_s("%Y-%m-%d")
published = Time.parse(published, "%Y-%m-%d", Time::Location.local)
- allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
- allowed_regions ||= [] of String
-
is_family_friendly = html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).try &.["content"] == "True"
is_family_friendly ||= true
@@ -1264,6 +1278,7 @@ def process_video_params(query, preferences)
continue_autoplay = query["continue_autoplay"]?.try &.to_i?
listen = query["listen"]? && (query["listen"] == "true" || query["listen"] == "1").to_unsafe
local = query["local"]? && (query["local"] == "true" || query["local"] == "1").to_unsafe
+ player_style = query["player_style"]?
preferred_captions = query["subtitles"]?.try &.split(",").map { |a| a.downcase }
quality = query["quality"]?
region = query["region"]?
@@ -1281,6 +1296,7 @@ def process_video_params(query, preferences)
continue_autoplay ||= preferences.continue_autoplay.to_unsafe
listen ||= preferences.listen.to_unsafe
local ||= preferences.local.to_unsafe
+ player_style ||= preferences.player_style
preferred_captions ||= preferences.captions
quality ||= preferences.quality
related_videos ||= preferences.related_videos.to_unsafe
@@ -1296,6 +1312,7 @@ def process_video_params(query, preferences)
continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe
listen ||= CONFIG.default_user_preferences.listen.to_unsafe
local ||= CONFIG.default_user_preferences.local.to_unsafe
+ player_style ||= CONFIG.default_user_preferences.player_style
preferred_captions ||= CONFIG.default_user_preferences.captions
quality ||= CONFIG.default_user_preferences.quality
related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe
@@ -1354,6 +1371,7 @@ def process_video_params(query, preferences)
controls: controls,
listen: listen,
local: local,
+ player_style: player_style,
preferred_captions: preferred_captions,
quality: quality,
raw: raw,
diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr
index b5eb46ea..1074598d 100644
--- a/src/invidious/views/channel.ecr
+++ b/src/invidious/views/channel.ecr
@@ -34,7 +34,7 @@
<div class="h-box">
<% ucid = channel.ucid %>
<% author = channel.author %>
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
+ <% sub_count_text = channel.sub_count.format %>
<%= rendered "components/subscribe_widget" %>
</div>
diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr
index 218cc2d4..9d086b5d 100644
--- a/src/invidious/views/community.ecr
+++ b/src/invidious/views/community.ecr
@@ -33,7 +33,7 @@
<div class="h-box">
<% ucid = channel.ucid %>
<% author = channel.author %>
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
+ <% sub_count_text = channel.sub_count.format %>
<%= rendered "components/subscribe_widget" %>
</div>
diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr
index 28e70058..71ae70df 100644
--- a/src/invidious/views/components/item.ecr
+++ b/src/invidious/views/components/item.ecr
@@ -15,7 +15,7 @@
<h5><%= item.description_html %></h5>
<% when SearchPlaylist %>
<% if item.id.starts_with? "RD" %>
- <% url = "/mix?list=#{item.id}&continuation=#{item.thumbnail_id}" %>
+ <% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").full_path.split("/")[2]}" %>
<% else %>
<% url = "/playlist?list=#{item.id}" %>
<% end %>
@@ -23,7 +23,7 @@
<a style="width:100%" href="<%= url %>">
<% if !env.get("preferences").as(Preferences).thin_mode %>
<div class="thumbnail">
- <img class="thumbnail" src="/vi/<%= item.thumbnail_id %>/mqdefault.jpg"/>
+ <img class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").full_path %>"/>
<p class="length"><%= number_with_separator(item.video_count) %> videos</p>
</div>
<% end %>
diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr
index 491e8fb1..ba6311cb 100644
--- a/src/invidious/views/components/player.ecr
+++ b/src/invidious/views/components/player.ecr
@@ -1,5 +1,5 @@
<video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>" title="<%= HTML.escape(video.title) %>"
- id="player" class="video-js"
+ id="player" class="video-js player-style-<%= params.player_style %>"
onmouseenter='this["data-title"]=this["title"];this["title"]=""'
onmouseleave='this["title"]=this["data-title"];this["data-title"]=""'
oncontextmenu='this["title"]=this["data-title"]'
diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr
index 003d2c3a..d950e0da 100644
--- a/src/invidious/views/components/player_sources.ecr
+++ b/src/invidious/views/components/player_sources.ecr
@@ -6,7 +6,6 @@
<script src="/js/video.min.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/videojs-contrib-quality-levels.min.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/videojs-http-source-selector.min.js?v=<%= ASSET_COMMIT %>"></script>
-<script src="/js/videojs.hotkeys.min.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/videojs-markers.min.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/videojs-share.min.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/videojs-vtt-thumbnails.min.js?v=<%= ASSET_COMMIT %>"></script>
diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr
index 7cffb7fc..aae8bb19 100644
--- a/src/invidious/views/licenses.ecr
+++ b/src/invidious/views/licenses.ecr
@@ -137,20 +137,6 @@
<tr>
<td>
- <a href="/js/videojs.hotkeys.min.js?v=<%= ASSET_COMMIT %>">videojs.hotkeys.min.js</a>
- </td>
-
- <td>
- <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache-2.0-only</a>
- </td>
-
- <td>
- <a href="https://github.com/ctd1500/videojs-hotkeys"><%= translate(locale, "source") %></a>
- </td>
- </tr>
-
- <tr>
- <td>
<a href="/js/videojs-http-source-selector.min.js?v=<%= ASSET_COMMIT %>">videojs-http-source-selector.min.js</a>
</td>
diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr
index a32192b5..400922ff 100644
--- a/src/invidious/views/playlists.ecr
+++ b/src/invidious/views/playlists.ecr
@@ -33,7 +33,7 @@
<div class="h-box">
<% ucid = channel.ucid %>
<% author = channel.author %>
- <% sub_count_text = number_to_short_text(channel.sub_count) %>
+ <% sub_count_text = channel.sub_count.format %>
<%= rendered "components/subscribe_widget" %>
</div>
diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr
index 794f8cb0..6ea01fba 100644
--- a/src/invidious/views/preferences.ecr
+++ b/src/invidious/views/preferences.ecr
@@ -113,8 +113,21 @@ function update_value(element) {
</div>
<div class="pure-control-group">
- <label for="dark_mode"><%= translate(locale, "Dark mode: ") %></label>
- <input name="dark_mode" id="dark_mode" type="checkbox" <% if preferences.dark_mode %>checked<% end %>>
+ <label for="player_style"><%= translate(locale, "Player style: ") %></label>
+ <select name="player_style" id="player_style">
+ <% {"invidious", "youtube"}.each do |option| %>
+ <option value="<%= option %>" <% if preferences.player_style == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <% end %>
+ </select>
+ </div>
+
+ <div class="pure-control-group">
+ <label for="dark_mode"><%= translate(locale, "Theme: ") %></label>
+ <select name="dark_mode" id="dark_mode">
+ <% {"", "light", "dark"}.each do |option| %>
+ <option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= translate(locale, option) %></option>
+ <% end %>
+ </select>
</div>
<div class="pure-control-group">
diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr
index 6272d2be..8d8cec88 100644
--- a/src/invidious/views/template.ecr
+++ b/src/invidious/views/template.ecr
@@ -18,13 +18,14 @@
<link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
- <link rel="stylesheet" href="/css/darktheme.css?v=<%= ASSET_COMMIT %>" id="dark_theme" <% if !env.get("preferences").as(Preferences).dark_mode %>media="none"<% end %>>
- <link rel="stylesheet" href="/css/lighttheme.css?v=<%= ASSET_COMMIT %>" id="light_theme" <% if env.get("preferences").as(Preferences).dark_mode %>media="none"<% end %>>
+ <link rel="stylesheet" href="/css/darktheme.css?v=<%= ASSET_COMMIT %>" id="dark_theme" <% if env.get("preferences").as(Preferences).dark_mode != "dark" %>media="none"<% end %>>
+ <link rel="stylesheet" href="/css/lighttheme.css?v=<%= ASSET_COMMIT %>" id="light_theme" <% if env.get("preferences").as(Preferences).dark_mode == "dark" %>media="none"<% end %>>
</head>
<% locale = LOCALES[env.get("preferences").as(Preferences).locale]? %>
<body>
+ <span style="display:none" id="dark_mode_pref"><%= env.get("preferences").as(Preferences).dark_mode %></span>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-2-24"></div>
<div class="pure-u-1 pure-u-md-20-24">
@@ -43,7 +44,7 @@
<% if env.get? "user" %>
<div class="pure-u-1-4">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
- <% if env.get("preferences").as(Preferences).dark_mode %>
+ <% if env.get("preferences").as(Preferences).dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
<i class="icon ion-ios-moon"></i>
@@ -76,7 +77,7 @@
<% else %>
<div class="pure-u-1-3">
<a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading">
- <% if env.get("preferences").as(Preferences).dark_mode %>
+ <% if env.get("preferences").as(Preferences).dark_mode == "dark" %>
<i class="icon ion-ios-sunny"></i>
<% else %>
<i class="icon ion-ios-moon"></i>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 542fe9e5..58c4b1a3 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -235,7 +235,11 @@ var video_data = {
<p style="width:100%"><%= rv["title"] %></p>
<h5 class="pure-g">
<div class="pure-u-14-24">
- <b style="width:100%"><%= rv["author"] %></b>
+ <% if rv["ucid"]? %>
+ <b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"] %></a></b>
+ <% else %>
+ <b style="width:100%"><%= rv["author"] %></b>
+ <% end %>
</div>
<div class="pure-u-10-24" style="text-align:right">