diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/invidious.cr | 284 | ||||
| -rw-r--r-- | src/invidious/channels.cr | 15 | ||||
| -rw-r--r-- | src/invidious/helpers/handlers.cr | 33 | ||||
| -rw-r--r-- | src/invidious/helpers/helpers.cr | 247 | ||||
| -rw-r--r-- | src/invidious/helpers/patch_mapping.cr | 2 | ||||
| -rw-r--r-- | src/invidious/helpers/proxy.cr | 8 | ||||
| -rw-r--r-- | src/invidious/helpers/utils.cr | 13 | ||||
| -rw-r--r-- | src/invidious/playlists.cr | 18 | ||||
| -rw-r--r-- | src/invidious/search.cr | 176 | ||||
| -rw-r--r-- | src/invidious/users.cr | 87 | ||||
| -rw-r--r-- | src/invidious/videos.cr | 190 | ||||
| -rw-r--r-- | src/invidious/views/channel.ecr | 2 | ||||
| -rw-r--r-- | src/invidious/views/community.ecr | 2 | ||||
| -rw-r--r-- | src/invidious/views/components/item.ecr | 4 | ||||
| -rw-r--r-- | src/invidious/views/components/player.ecr | 2 | ||||
| -rw-r--r-- | src/invidious/views/components/player_sources.ecr | 1 | ||||
| -rw-r--r-- | src/invidious/views/licenses.ecr | 14 | ||||
| -rw-r--r-- | src/invidious/views/playlists.ecr | 2 | ||||
| -rw-r--r-- | src/invidious/views/preferences.ecr | 17 | ||||
| -rw-r--r-- | src/invidious/views/template.ecr | 9 | ||||
| -rw-r--r-- | src/invidious/views/watch.ecr | 6 |
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 ? "®ion=#{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 += "®ion=#{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 += "®ion=#{region}" - end + location = "#{location.full_path}&host=#{location.host}#{region ? "®ion=#{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"> |
