diff --git a/.ameba.yml b/.ameba.yml index 36d7c48f..df97b539 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -38,9 +38,6 @@ Style/RedundantBegin: Style/RedundantReturn: Enabled: false -Style/RedundantNext: - Enabled: false - Style/ParenthesesAroundCondition: Enabled: false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9f17bb40..7a2c3760 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,12 @@ +# Default and lowest precedence. If none of the below matches, @iv-org/developers would be requested for review. +* @iv-org/developers + docker-compose.yml @unixfox docker/ @unixfox kubernetes/ @unixfox README.md @thefrenchghosty -config/config.example.yml @SamantazFox @unixfox +config/config.example.yml @thefrenchghosty @SamantazFox @unixfox scripts/ @syeopite shards.lock @syeopite diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 02bc3795..4c1a6330 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,10 +10,8 @@ assignees: '' -
#{issue_template}
+
#{issue_template}
END_HTML @@ -139,7 +128,7 @@ def error_json_helper( env : HTTP::Server::Context, status_code : Int32, exception : Exception, - additional_fields : Hash(String, Object) | Nil = nil, + additional_fields : Hash(String, Object) | Nil = nil ) if exception.is_a?(InfoException) return error_json_helper(env, status_code, exception.message || "", additional_fields) @@ -161,7 +150,7 @@ def error_json_helper( env : HTTP::Server::Context, status_code : Int32, message : String, - additional_fields : Hash(String, Object) | Nil = nil, + additional_fields : Hash(String, Object) | Nil = nil ) env.response.content_type = "application/json" env.response.status_code = status_code diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 13ea9fe9..f3e3b951 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -27,7 +27,6 @@ class Kemal::RouteHandler # Processes the route if it's a match. Otherwise renders 404. private def process_request(context) raise Kemal::Exceptions::RouteNotFound.new(context) unless context.route_found? - return if context.response.closed? content = context.route.handler.call(context) if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(context.response.status_code) && exclude_match?(context) diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index bca2edda..23a1aafc 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,22 +1,8 @@ -# Languages requiring a better level of translation (at least 20%) -# to be added to the list below: -# -# "af" => "", # Afrikaans -# "az" => "", # Azerbaijani -# "be" => "", # Belarusian -# "bn_BD" => "", # Bengali (Bangladesh) -# "ia" => "", # Interlingua -# "or" => "", # Odia -# "tk" => "", # Turkmen -# "tok => "", # Toki Pona -# LOCALES_LIST = { "ar" => "العربية", # Arabic - "bg" => "български", # Bulgarian "bn" => "বাংলা", # Bengali "ca" => "Català", # Catalan "cs" => "Čeština", # Czech - "cy" => "Cymraeg", # Welsh "da" => "Dansk", # Danish "de" => "Deutsch", # German "el" => "Ελληνικά", # Greek @@ -37,7 +23,6 @@ LOCALES_LIST = { "it" => "Italiano", # Italian "ja" => "日本語", # Japanese "ko" => "한국어", # Korean - "lmo" => "Lombard", # Lombard "lt" => "Lietuvių", # Lithuanian "nb-NO" => "Norsk bokmål", # Norwegian Bokmål "nl" => "Nederlands", # Dutch @@ -54,7 +39,6 @@ LOCALES_LIST = { "sr" => "Srpski (latinica)", # Serbian (Latin) "sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic) "sv-SE" => "Svenska", # Swedish - "ta" => "தமிழ்", # Tamil "tr" => "Türkçe", # Turkish "uk" => "Українська", # Ukrainian "vi" => "Tiếng Việt", # Vietnamese diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index 03349595..b443073e 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -1,5 +1,3 @@ -require "colorize" - enum LogLevel All = 0 Trace = 1 @@ -12,9 +10,7 @@ enum LogLevel end class Invidious::LogHandler < Kemal::BaseLogHandler - def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true) - Colorize.enabled = use_color - Colorize.on_tty_only! + def initialize(@io : IO = STDOUT, @level = LogLevel::Debug) end def call(context : HTTP::Server::Context) @@ -43,22 +39,10 @@ class Invidious::LogHandler < Kemal::BaseLogHandler @io.flush end - def color(level) - case level - when LogLevel::Trace then :cyan - when LogLevel::Debug then :green - when LogLevel::Info then :white - when LogLevel::Warn then :yellow - when LogLevel::Error then :red - when LogLevel::Fatal then :magenta - else :default - end - end - {% for level in %w(trace debug info warn error fatal) %} def {{level.id}}(message : String) if LogLevel::{{level.id.capitalize}} >= @level - puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}}))) + puts("#{Time.utc} [{{level.id}}] #{message}") end end {% end %} diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 84847321..43e7171b 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -55,11 +55,12 @@ macro templated(_filename, template = "template", navbar_search = true) {{ layout = "src/invidious/views/" + template + ".ecr" }} __content_filename__ = {{filename}} - render {{filename}}, {{layout}} + content = Kilt.render({{filename}}) + Kilt.render({{layout}}) end macro rendered(filename) - render("src/invidious/views/#{{{filename}}}.ecr") + Kilt.render("src/invidious/views/#{{{filename}}}.ecr") end # Similar to Kemals halt method but works in a diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 2796a8dc..463d5557 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -1,16 +1,3 @@ -@[Flags] -enum VideoBadges - LiveNow - Premium - ThreeD - FourK - New - EightK - VR180 - VR360 - ClosedCaptions -end - struct SearchVideo include DB::Serializable @@ -22,10 +9,10 @@ struct SearchVideo property views : Int64 property description_html : String property length_seconds : Int32 + property live_now : Bool + property premium : Bool property premiere_timestamp : Time? property author_verified : Bool - property author_thumbnail : String? - property badges : VideoBadges def to_xml(auto_generated, query_params, xml : XML::Builder) query_params["v"] = self.id @@ -89,24 +76,6 @@ struct SearchVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "authorVerified", self.author_verified - author_thumbnail = self.author_thumbnail - - if 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", author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - json.field "videoThumbnails" do Invidious::JSONify::APIv1.thumbnails(json, self.id) end @@ -119,20 +88,13 @@ struct SearchVideo json.field "published", self.published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) json.field "lengthSeconds", self.length_seconds - json.field "liveNow", self.badges.live_now? - json.field "premium", self.badges.premium? + json.field "liveNow", self.live_now + json.field "premium", self.premium json.field "isUpcoming", self.upcoming? if self.premiere_timestamp json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix end - json.field "isNew", self.badges.new? - json.field "is4k", self.badges.four_k? - json.field "is8k", self.badges.eight_k? - json.field "isVr180", self.badges.vr180? - json.field "isVr360", self.badges.vr360? - json.field "is3d", self.badges.three_d? - json.field "hasCaptions", self.badges.closed_captions? end end @@ -242,7 +204,7 @@ struct SearchChannel qualities.each do |quality| json.object do - json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}") json.field "width", quality json.field "height", quality end @@ -291,55 +253,6 @@ struct SearchHashtag end end -# A `ProblematicTimelineItem` is a `SearchItem` created by Invidious that -# represents an item that caused an exception during parsing. -# -# This is not a parsed object from YouTube but rather an Invidious-only type -# created to gracefully communicate parse errors without throwing away -# the rest of the (hopefully) successfully parsed item on a page. -struct ProblematicTimelineItem - property parse_exception : Exception - property id : String - - def initialize(@parse_exception) - @id = Random.new.hex(8) - end - - def to_json(locale : String?, json : JSON::Builder) - json.object do - json.field "type", "parse-error" - json.field "errorMessage", @parse_exception.message - json.field "errorBacktrace", @parse_exception.inspect_with_backtrace - end - end - - # Provides compatibility with PlaylistVideo - def to_json(json : JSON::Builder, *args, **kwargs) - return to_json("", json) - end - - def to_xml(env, locale, xml : XML::Builder) - xml.element("entry") do - xml.element("id") { xml.text "iv-err-#{@id}" } - xml.element("title") { xml.text "Parse Error: This item has failed to parse" } - xml.element("updated") { xml.text Time.utc.to_rfc3339 } - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("div") do - xml.element("h4") { translate(locale, "timeline_parse_error_placeholder_heading") } - xml.element("p") { translate(locale, "timeline_parse_error_placeholder_message") } - end - - xml.element("pre") do - get_issue_template(env, @parse_exception) - end - end - end - end - end -end - class Category include DB::Serializable @@ -382,4 +295,4 @@ struct Continuation end end -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category | ProblematicTimelineItem +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index 6d198a42..9e72c1c7 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -175,9 +175,8 @@ module Invidious::SigHelper @queue = {} of TransactionID => Transaction @conn : Connection - @uri_or_path : String - def initialize(@uri_or_path) + def initialize(uri_or_path) @conn = Connection.new(uri_or_path) listen end @@ -187,26 +186,10 @@ module Invidious::SigHelper LOGGER.debug("SigHelper: Multiplexor listening") + # TODO: reopen socket if unexpectedly closed spawn do loop do - begin - receive_data - rescue ex - LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...") - # We close the socket because for some reason is not closed. - @conn.close - loop do - begin - @conn = Connection.new(@uri_or_path) - LOGGER.info("SigHelper: Reconnected to SigHelper!") - rescue ex - LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying") - sleep 500.milliseconds - next - end - break if !@conn.closed? - end - end + receive_data Fiber.yield end end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 5637e533..8e9e9a6a 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -262,7 +262,7 @@ def get_referer(env, fallback = "/", unroll = true) end referer = referer.request_target - referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z+]/, "").lstrip("/\\") + referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z]/, "").lstrip("/\\") if referer == env.request.path referer = fallback @@ -323,6 +323,68 @@ def parse_range(range) return 0_i64, nil end +def fetch_random_instance + begin + instance_api_client = make_client(URI.parse("https://api.invidious.io")) + + # Timeouts + instance_api_client.connect_timeout = 10.seconds + instance_api_client.dns_timeout = 10.seconds + + instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a + instance_api_client.close + rescue Socket::ConnectError | IO::TimeoutError | JSON::ParseException + instance_list = [] of JSON::Any + end + + filtered_instance_list = [] of String + + instance_list.each do |data| + # TODO Check if current URL is onion instance and use .onion types if so. + if data[1]["type"] == "https" + # Instances can have statistics disabled, which is an requirement of version validation. + # as_nil? doesn't exist. Thus we'll have to handle the error raised if as_nil fails. + begin + data[1]["stats"].as_nil + next + rescue TypeCastError + end + + # stats endpoint could also lack the software dict. + next if data[1]["stats"]["software"]?.nil? + + # Makes sure the instance isn't too outdated. + if remote_version = data[1]["stats"]?.try &.["software"]?.try &.["version"] + remote_commit_date = remote_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) + next if !remote_commit_date + + remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) + local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) + + next if (remote_commit_date - local_commit_date).abs.days > 30 + + begin + data[1]["monitor"].as_nil + health = data[1]["monitor"].as_h["dailyRatios"][0].as_h["ratio"] + filtered_instance_list << data[0].as_s if health.to_s.to_f > 90 + rescue TypeCastError + # We can't check the health if the monitoring is broken. Thus we'll just add it to the list + # and move on. Ideally we'll ignore any instance that has broken health monitoring but due to the fact that + # it's an error that often occurs with all the instances at the same time, we have to just skip the check. + filtered_instance_list << data[0].as_s + end + end + end + end + + # If for some reason no instances managed to get fetched successfully then we'll just redirect to redirect.invidious.io + if filtered_instance_list.size == 0 + return "redirect.invidious.io" + end + + return filtered_instance_list.sample(1)[0] +end + def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "…") : String str = uri.to_s.sub(/^https?:\/\//, "") if str.size > max_length @@ -383,22 +445,3 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String) end return text end - -def encrypt_ecb_without_salt(data, key) - cipher = OpenSSL::Cipher.new("aes-128-ecb") - cipher.encrypt - cipher.key = key - - io = IO::Memory.new - io.write(cipher.update(data)) - io.write(cipher.final) - io.rewind - - return io -end - -def invidious_companion_encrypt(data) - timestamp = Time.utc.to_unix - encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key) - return Base64.urlsafe_encode(encrypted_data) -end diff --git a/src/invidious/jobs/instance_refresh_job.cr b/src/invidious/jobs/instance_refresh_job.cr deleted file mode 100644 index cb4280b9..00000000 --- a/src/invidious/jobs/instance_refresh_job.cr +++ /dev/null @@ -1,97 +0,0 @@ -class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob - # We update the internals of a constant as so it can be accessed from anywhere - # within the codebase - # - # "INSTANCES" => Array(Tuple(String, String)) # region, instance - - INSTANCES = {"INSTANCES" => [] of Tuple(String, String)} - - def initialize - end - - def begin - loop do - refresh_instances - LOGGER.info("InstanceListRefreshJob: Done, sleeping for 30 minutes") - sleep 30.minute - Fiber.yield - end - end - - # Refreshes the list of instances used for redirects. - # - # Does the following three checks for each instance - # - Is it a clear-net instance? - # - Is it an instance with a good uptime? - # - Is it an updated instance? - private def refresh_instances - raw_instance_list = self.fetch_instances - filtered_instance_list = [] of Tuple(String, String) - - raw_instance_list.each do |instance_data| - # TODO allow Tor hidden service instances when the current instance - # is also a hidden service. Same for i2p and any other non-clearnet instances. - begin - domain = instance_data[0] - info = instance_data[1] - stats = info["stats"] - - next unless info["type"] == "https" - next if bad_uptime?(info["monitor"]) - next if outdated?(stats["software"]["version"]) - - filtered_instance_list << {info["region"].as_s, domain.as_s} - rescue ex - if domain - LOGGER.info("InstanceListRefreshJob: failed to parse information from '#{domain}' because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ") - else - LOGGER.info("InstanceListRefreshJob: failed to parse information from an instance because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ") - end - end - end - - if !filtered_instance_list.empty? - INSTANCES["INSTANCES"] = filtered_instance_list - end - end - - # Fetches information regarding instances from api.invidious.io or an otherwise configured URL - private def fetch_instances : Array(JSON::Any) - begin - # We directly call the stdlib HTTP::Client here as it allows us to negate the effects - # of the force_resolve config option. This is needed as api.invidious.io does not support ipv6 - # and as such the following request raises if we were to use force_resolve with the ipv6 value. - instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io")) - - # Timeouts - instance_api_client.connect_timeout = 10.seconds - instance_api_client.dns_timeout = 10.seconds - - raw_instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a - instance_api_client.close - rescue ex : Socket::ConnectError | IO::TimeoutError | JSON::ParseException - raw_instance_list = [] of JSON::Any - end - - return raw_instance_list - end - - # Checks if the given target instance is outdated - private def outdated?(target_instance_version) : Bool - remote_commit_date = target_instance_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) - return false if !remote_commit_date - - remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) - local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) - - return (remote_commit_date - local_commit_date).abs.days > 30 - end - - # Checks if the uptime of the target instance is greater than 90% over a 30 day period - private def bad_uptime?(target_instance_health_monitor) : Bool - return true if !target_instance_health_monitor["down"].as_bool == false - return true if target_instance_health_monitor["uptime"].as_f < 90 - - return false - end -end diff --git a/src/invidious/jobs/notification_job.cr b/src/invidious/jobs/notification_job.cr index 968ee47f..b445107b 100644 --- a/src/invidious/jobs/notification_job.cr +++ b/src/invidious/jobs/notification_job.cr @@ -1,32 +1,8 @@ -struct VideoNotification - getter video_id : String - getter channel_id : String - getter published : Time - - def_hash @channel_id, @video_id - - def ==(other) - video_id == other.video_id - end - - def self.from_video(video : ChannelVideo) : self - VideoNotification.new(video.id, video.ucid, video.published) - end - - def initialize(@video_id, @channel_id, @published) - end - - def clone : VideoNotification - VideoNotification.new(video_id.clone, channel_id.clone, published.clone) - end -end - class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob - private getter notification_channel : ::Channel(VideoNotification) private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)}) private getter pg_url : URI - def initialize(@notification_channel, @connection_channel, @pg_url) + def initialize(@connection_channel, @pg_url) end def begin @@ -34,70 +10,6 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } - # hash of channels to their videos (id+published) that need notifying - to_notify = Hash(String, Set(VideoNotification)).new( - ->(hash : Hash(String, Set(VideoNotification)), key : String) { - hash[key] = Set(VideoNotification).new - } - ) - notify_mutex = Mutex.new - - # fiber to locally cache all incoming notifications (from pubsub webhooks and refresh channels job) - spawn do - begin - loop do - notification = notification_channel.receive - notify_mutex.synchronize do - to_notify[notification.channel_id] << notification - end - end - end - end - # fiber to regularly persist all cached notifications - spawn do - loop do - begin - LOGGER.debug("NotificationJob: waking up") - cloned = {} of String => Set(VideoNotification) - notify_mutex.synchronize do - cloned = to_notify.clone - to_notify.clear - end - - cloned.each do |channel_id, notifications| - if notifications.empty? - next - end - - LOGGER.info("NotificationJob: updating channel #{channel_id} with #{notifications.size} notifications") - if CONFIG.enable_user_notifications - video_ids = notifications.map(&.video_id) - Invidious::Database::Users.add_multiple_notifications(channel_id, video_ids) - PG_DB.using_connection do |conn| - notifications.each do |n| - # Deliver notifications to `/api/v1/auth/notifications` - payload = { - "topic" => n.channel_id, - "videoId" => n.video_id, - "published" => n.published.to_unix, - }.to_json - conn.exec("NOTIFY notifications, E'#{payload}'") - end - end - else - Invidious::Database::Users.feed_needs_update(channel_id) - end - end - - LOGGER.trace("NotificationJob: Done, sleeping") - rescue ex - LOGGER.error("NotificationJob: #{ex.message}") - end - sleep 1.minute - Fiber.yield - end - end - loop do action, connection = connection_channel.receive diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 58805af2..08cd533f 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -267,12 +267,6 @@ module Invidious::JSONify::APIv1 json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i json.field "viewCountText", rv["short_view_count"]? json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 - json.field "published", rv["published"]? - if rv["published"]?.try &.presence - json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale)) - else - json.field "publishedText", "" - end end end end diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 28ff0ff6..823ca85b 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -81,7 +81,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil) }) end -def template_mix(mix, listen) +def template_mix(mix) html = <<-END_HTML

@@ -95,7 +95,7 @@ def template_mix(mix, listen) mix["videos"].as_a.each do |video| html += <<-END_HTML
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 7c584d15..3e6eef95 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -270,7 +270,7 @@ end def subscribe_playlist(user, playlist) playlist = InvidiousPlaylist.new({ - title: playlist.title[..150], + title: playlist.title.byte_slice(0, 150), id: playlist.id, author: user.email, description: "", # Max 5000 characters @@ -432,7 +432,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset end - videos = [] of PlaylistVideo | ProblematicTimelineItem + videos = [] of PlaylistVideo until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count # 100 videos per request @@ -448,7 +448,7 @@ def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, end def extract_playlist_videos(initial_data : Hash(String, JSON::Any)) - videos = [] of PlaylistVideo | ProblematicTimelineItem + videos = [] of PlaylistVideo if initial_data["contents"]? tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"] @@ -500,14 +500,12 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any)) index: index, }) end - rescue ex - videos << ProblematicTimelineItem.new(parse_exception: ex) end return videos end -def template_playlist(playlist, listen) +def template_playlist(playlist) html = <<-END_HTML

    @@ -521,7 +519,7 @@ def template_playlist(playlist, listen) playlist["videos"].as_a.each do |video| html += <<-END_HTML
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index c8db207c..dd65e7a6 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -328,9 +328,17 @@ module Invidious::Routes::Account end end - case action = env.params.query["action"]? - when "revoke_token" - session = env.params.query["session"] + if env.params.query["action_revoke_token"]? + action = "action_revoke_token" + else + return env.redirect referer + end + + session = env.params.query["session"]? + session ||= "" + + case action + when .starts_with? "action_revoke_token" Invidious::Database::SessionIDs.delete(sid: session, email: user.email) else return error_json(400, "Unsupported action #{action}") diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index c27caad7..d89e752c 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -8,11 +8,6 @@ module Invidious::Routes::API::Manifest id = env.params.url["id"] region = env.params.query["region"]? - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - return env.redirect "#{invidious_companion.public_url}/api/manifest/dash/id/#{id}?#{env.params.query}" - end - # Since some implementations create playlists based on resolution regardless of different codecs, # we can opt to only add a source to a representation if it has a unique height within that representation unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } @@ -32,21 +27,28 @@ module Invidious::Routes::API::Manifest haltf env, status_code: response.status_code end - # Proxy URLs for video playback on invidious. - # Other API clients can get the original URLs by omiting `local=true`. - manifest = response.body.gsub(/[^<]+<\/BaseURL>/) do |baseurl| - url = baseurl.lchop("").rchop("") - url = HttpServer::Utils.proxy_video_url(url, absolute: true) if local + manifest = response.body + + manifest = manifest.gsub(/[^<]+<\/BaseURL>/) do |baseurl| + url = baseurl.lchop("") + url = url.rchop("") + + if local + uri = URI.parse(url) + url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/" + end + "#{url}" end return manifest end - # Ditto, only proxify URLs if `local=true` is used + adaptive_fmts = video.adaptive_fmts + if local - video.adaptive_fmts.each do |fmt| - fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s, absolute: true)) + adaptive_fmts.each do |fmt| + fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}") end end @@ -68,23 +70,17 @@ module Invidious::Routes::API::Manifest # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) - audio_track = fmt["audioTrack"]?.try &.as_h? || {} of String => JSON::Any - lang = audio_track["id"]?.try &.as_s.split('.')[0] || "und" - is_default = audio_track.has_key?("audioIsDefault") ? audio_track["audioIsDefault"].as_bool : i == 0 - displayname = audio_track["displayName"]?.try &.as_s || "Unknown" - bitrate = fmt["bitrate"] - # Different representations of the same audio should be groupped into one AdaptationSet. # However, most players don't support auto quality switching, so we have to trick them # into providing a quality selector. # See https://github.com/iv-org/invidious/issues/3074 for more details. - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: "#{displayname} [#{bitrate}k]", lang: lang) do + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') bandwidth = fmt["bitrate"].as_i itag = fmt["itag"].as_i url = fmt["url"].as_s - xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: is_default ? "main" : "alternate") + xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate") xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", @@ -181,9 +177,8 @@ module Invidious::Routes::API::Manifest manifest = response.body if local - manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match| - uri = URI.parse(match) - path = uri.path + manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match| + path = URI.parse(match).path path = path.lchop("/videoplayback/") path = path.rchop("/") @@ -212,7 +207,7 @@ module Invidious::Routes::API::Manifest raw_params["fvip"] = fvip["fvip"] end - raw_params["host"] = uri.host.not_nil! + raw_params["local"] = "true" "#{HOST_URL}/videoplayback?#{raw_params}" end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index a940ee68..2da76134 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -197,7 +197,6 @@ module Invidious::Routes::API::V1::Channels get_channel() # Retrieve continuation from URL parameters - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" continuation = env.params.query["continuation"]? if channel.is_age_gated @@ -212,7 +211,7 @@ module Invidious::Routes::API::V1::Channels else begin videos, next_continuation = Channel::Tabs.get_shorts( - channel, continuation: continuation, sort_by: sort_by + channel, continuation: continuation ) rescue ex return error_json(500, ex) @@ -368,35 +367,6 @@ module Invidious::Routes::API::V1::Channels end end - def self.courses(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - - # Use the macro defined above - channel = nil # Make the compiler happy - get_channel() - - items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", next_continuation if next_continuation - end - end - end - def self.community(env) locale = env.get("preferences").as(Preferences).locale diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 4f5b58da..093669fe 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -42,9 +42,6 @@ module Invidious::Routes::API::V1::Misc format = env.params.query["format"]? format ||= "json" - listen_param = env.params.query["listen"]? - listen = (listen_param == "true" || listen_param == "1") - if plid.starts_with? "RD" return env.redirect "/api/v1/mixes/#{plid}" end @@ -88,7 +85,7 @@ module Invidious::Routes::API::V1::Misc end if format == "html" - playlist_html = template_playlist(json_response, listen) + playlist_html = template_playlist(json_response) index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} response = { @@ -114,9 +111,6 @@ module Invidious::Routes::API::V1::Misc format = env.params.query["format"]? format ||= "json" - listen_param = env.params.query["listen"]? - listen = (listen_param == "true" || listen_param == "1") - begin mix = fetch_mix(rdid, continuation, locale: locale) @@ -147,7 +141,9 @@ module Invidious::Routes::API::V1::Misc json.field "authorUrl", "/channel/#{video.ucid}" json.field "videoThumbnails" do - Invidious::JSONify::APIv1.thumbnails(json, video.id) + json.array do + Invidious::JSONify::APIv1.thumbnails(json, video.id) + end end json.field "index", video.index @@ -161,7 +157,7 @@ module Invidious::Routes::API::V1::Misc if format == "html" response = JSON.parse(response) - playlist_html = template_mix(response, listen) + playlist_html = template_mix(response) next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] response = { diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 59a30745..2922b060 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -31,7 +31,9 @@ module Invidious::Routes::API::V1::Search query = env.params.query["q"]? || "" begin - client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true) + client = HTTP::Client.new("suggestqueries-clients6.youtube.com") + client.before_request { |r| add_yt_headers(r) } + url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt" response = client.get(url).body diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 6a3eb8ae..368304ac 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -429,90 +429,4 @@ module Invidious::Routes::API::V1::Videos end end end - - # Fetches transcripts from YouTube - # - # Use the `lang` and `autogen` query parameter to select which transcript to fetch - # Request without any URL parameters to see all the available transcripts. - def self.transcripts(env) - env.response.content_type = "application/json" - - id = env.params.url["id"] - lang = env.params.query["lang"]? - label = env.params.query["label"]? - auto_generated = env.params.query["autogen"]? ? true : false - - # Return all available transcript options when none is given - if !label && !lang - begin - video = get_video(id) - rescue ex : NotFoundException - return error_json(404, ex) - rescue ex - return error_json(500, ex) - end - - response = JSON.build do |json| - # The amount of transcripts available to fetch is the - # same as the amount of captions available. - available_transcripts = video.captions - - json.object do - json.field "transcripts" do - json.array do - available_transcripts.each do |transcript| - json.object do - json.field "label", transcript.name - json.field "languageCode", transcript.language_code - json.field "autoGenerated", transcript.auto_generated - - if transcript.auto_generated - json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}&autogen" - else - json.field "url", "/api/v1/transcripts/#{id}?lang=#{URI.encode_www_form(transcript.language_code)}" - end - end - end - end - end - end - end - - return response - end - - # If lang is not given then we attempt to fetch - # the transcript through the given label - if lang.nil? - begin - video = get_video(id) - rescue ex : NotFoundException - return error_json(404, ex) - rescue ex - return error_json(500, ex) - end - - target_transcript = video.captions.select(&.name.== label) - if target_transcript.empty? - return error_json(404, NotFoundException.new("Requested transcript does not exist")) - else - target_transcript = target_transcript[0] - lang, auto_generated = target_transcript.language_code, target_transcript.auto_generated - end - end - - params = Invidious::Videos::Transcript.generate_param(id, lang, auto_generated) - - begin - transcript = Invidious::Videos::Transcript.from_raw( - YoutubeAPI.get_transcript(params), lang, auto_generated - ) - rescue ex : NotFoundException - return error_json(404, ex) - rescue ex - return error_json(500, ex) - end - - return transcript.to_json - end end diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index b5269668..5695dee9 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -20,6 +20,14 @@ module Invidious::Routes::BeforeAll env.response.headers["X-XSS-Protection"] = "1; mode=block" env.response.headers["X-Content-Type-Options"] = "nosniff" + # Allow media resources to be loaded from google servers + # TODO: check if *.youtube.com can be removed + if CONFIG.disabled?("local") || !preferences.local + extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443" + else + extra_media_csp = "" + end + # Only allow the pages at /embed/* to be embedded if env.request.resource.starts_with?("/embed") frame_ancestors = "'self' file: http: https:" @@ -37,7 +45,7 @@ module Invidious::Routes::BeforeAll "font-src 'self' data:", "connect-src 'self'", "manifest-src 'self'", - "media-src 'self' blob:", + "media-src 'self' blob:" + extra_media_csp, "child-src 'self' blob:", "frame-src 'self'", "frame-ancestors " + frame_ancestors, @@ -102,21 +110,6 @@ module Invidious::Routes::BeforeAll preferences.locale = locale env.set "preferences", preferences - # Allow media resources to be loaded from google servers - # TODO: check if *.youtube.com can be removed - # - # `!preferences.local` has to be checked after setting and - # reading `preferences` from the "PREFS" cookie and - # saved user preferences from the database, otherwise - # `https://*.googlevideo.com:443 https://*.youtube.com:443` - # will not be set in the CSP header if - # `default_user_preferences.local` is set to true on the - # configuration file, causing preference “Proxy Videos” - # not to work while having it disabled and using medium quality. - if CONFIG.disabled?("local") || !preferences.local - env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443") - end - current_page = env.request.path if env.request.query query = HTTP::Params.parse(env.request.query.not_nil!) diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 508aa3e4..952098e0 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -20,11 +20,10 @@ module Invidious::Routes::Channels sort_by = env.params.query["sort_by"]?.try &.downcase if channel.auto_generated - sort_by ||= "last" sort_options = {"last", "oldest", "newest"} items, next_continuation = fetch_channel_playlists( - channel.ucid, channel.author, continuation, sort_by + channel.ucid, channel.author, continuation, (sort_by || "last") ) items.uniq! do |item| @@ -50,11 +49,9 @@ module Invidious::Routes::Channels end next_continuation = nil else - sort_by ||= "newest" sort_options = {"newest", "oldest", "popular"} - - items, next_continuation = Channel::Tabs.get_60_videos( - channel, continuation: continuation, sort_by: sort_by + items, next_continuation = Channel::Tabs.get_videos( + channel, continuation: continuation, sort_by: (sort_by || "newest") ) end end @@ -85,12 +82,13 @@ module Invidious::Routes::Channels end next_continuation = nil else - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" - sort_options = {"newest", "oldest", "popular"} + # TODO: support sort option for shorts + sort_by = "" + sort_options = [] of String # Fetch items and continuation token items, next_continuation = Channel::Tabs.get_shorts( - channel, continuation: continuation, sort_by: sort_by + channel, continuation: continuation ) end @@ -197,29 +195,7 @@ module Invidious::Routes::Channels templated "channel" end - def self.courses(env) - data = self.fetch_basic_information(env) - return data if !data.is_a?(Tuple) - - locale, user, subscriptions, continuation, ucid, channel = data - - sort_by = "" - sort_options = [] of String - - items, next_continuation = fetch_channel_courses( - channel.ucid, channel.author, continuation - ) - - items = items.select(SearchPlaylist) - items.each(&.author = "") - - selected_tab = Frontend::ChannelPage::TabsAvailable::Courses - templated "channel" - end - def self.community(env) - return env.redirect env.request.path.sub("posts", "community") if env.request.path.split("/").last == "posts" - data = self.fetch_basic_information(env) if !data.is_a?(Tuple) return data @@ -236,7 +212,7 @@ module Invidious::Routes::Channels continuation = env.params.query["continuation"]? - if !channel.tabs.includes? "community" && "posts" + if !channel.tabs.includes? "community" return env.redirect "/channel/#{channel.ucid}" end @@ -329,8 +305,7 @@ module Invidious::Routes::Channels private KNOWN_TABS = { "home", "videos", "shorts", "streams", "podcasts", - "releases", "courses", "playlists", "community", "channels", "about", - "posts", + "releases", "playlists", "community", "channels", "about", } # Redirects brand url channels to a normal /channel/:ucid route diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 930e4915..266f7ba4 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -12,15 +12,13 @@ module Invidious::Routes::Embed url = "/playlist?list=#{plid}" raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) end - - first_playlist_video = videos[0].as(PlaylistVideo) rescue ex : NotFoundException return error_template(404, ex) rescue ex return error_template(500, ex) end - url = "/embed/#{first_playlist_video}?#{env.params.query}" + url = "/embed/#{videos[0].id}?#{env.params.query}" if env.params.query.size > 0 url += "?#{env.params.query}" @@ -74,15 +72,13 @@ module Invidious::Routes::Embed url = "/playlist?list=#{plid}" raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) end - - first_playlist_video = videos[0].as(PlaylistVideo) rescue ex : NotFoundException return error_template(404, ex) rescue ex return error_template(500, ex) end - url = "/embed/#{first_playlist_video.id}" + url = "/embed/#{videos[0].id}" elsif video_series url = "/embed/#{video_series.shift}" env.params.query["playlist"] = video_series.join(",") @@ -161,12 +157,10 @@ module Invidious::Routes::Embed adaptive_fmts = video.adaptive_fmts if params.local - fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } + fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) } + adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) } end - # Always proxy DASH streams, otherwise youtube CORS headers will prevent playback - adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } - video_streams = video.video_streams audio_streams = video.audio_streams @@ -207,14 +201,6 @@ module Invidious::Routes::Embed return env.redirect url end - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - env.response.headers["Content-Security-Policy"] = - env.response.headers["Content-Security-Policy"] - .gsub("media-src", "media-src #{invidious_companion.public_url}") - .gsub("connect-src", "connect-src #{invidious_companion.public_url}") - end - rendered "embed" end end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 070c96eb..e20a7139 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -143,25 +143,32 @@ module Invidious::Routes::Feeds # RSS feeds def self.rss_channel(env) + locale = env.get("preferences").as(Preferences).locale + env.response.headers["Content-Type"] = "application/atom+xml" env.response.content_type = "application/atom+xml" - if env.params.url["ucid"].matches?(/^[\w-]+$/) - ucid = env.params.url["ucid"] - else - return error_atom(400, InfoException.new("Invalid channel ucid provided.")) - end + ucid = env.params.url["ucid"] params = HTTP::Params.parse(env.params.query["params"]? || "") + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + return env.redirect env.request.resource.gsub(ucid, ex.channel_id) + rescue ex : NotFoundException + return error_atom(404, ex) + rescue ex + return error_atom(500, ex) + end + namespaces = { "yt" => "http://www.youtube.com/xml/schemas/2015", "media" => "http://search.yahoo.com/mrss/", "default" => "http://www.w3.org/2005/Atom", } - response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}") - return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404 + response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") rss = XML.parse(response.body) videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry| @@ -172,7 +179,7 @@ module Invidious::Routes::Feeds updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content - video_ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content + ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64 @@ -180,44 +187,43 @@ module Invidious::Routes::Feeds title: title, id: video_id, author: author, - ucid: video_ucid, + ucid: ucid, published: published, views: views, description_html: description_html, length_seconds: 0, + live_now: false, + paid: false, + premium: false, premiere_timestamp: nil, author_verified: false, - author_thumbnail: nil, - badges: VideoBadges::None, }) end - author = "" - author = videos[0].author if videos.size > 0 - XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", "xml:lang": "en-US") do xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - xml.element("id") { xml.text "yt:channel:#{ucid}" } - xml.element("yt:channelId") { xml.text ucid } - xml.element("title") { xml.text author } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{ucid}") + xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } + xml.element("yt:channelId") { xml.text channel.ucid } + xml.element("icon") { xml.text channel.author_thumbnail } + xml.element("title") { xml.text channel.author } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") xml.element("author") do - xml.element("name") { xml.text author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } + xml.element("name") { xml.text channel.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } end xml.element("image") do - xml.element("url") { xml.text "" } - xml.element("title") { xml.text author } + xml.element("url") { xml.text channel.author_thumbnail } + xml.element("title") { xml.text channel.author } xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") end videos.each do |video| - video.to_xml(false, params, xml) + video.to_xml(channel.auto_generated, params, xml) end end end @@ -296,13 +302,7 @@ module Invidious::Routes::Feeds xml.element("name") { xml.text playlist.author } end - videos.each do |video| - if video.is_a? PlaylistVideo - video.to_xml(xml) - else - video.to_xml(env, locale, xml) - end - end + videos.each &.to_xml(xml) end end else @@ -311,9 +311,8 @@ module Invidious::Routes::Feeds end response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") - return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404 - document = XML.parse(response.body) + document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| node.attributes.each do |attribute| case attribute.name @@ -426,6 +425,16 @@ module Invidious::Routes::Feeds next # skip this video since it raised an exception (e.g. it is a scheduled live event) end + if CONFIG.enable_user_notifications + # Deliver notifications to `/api/v1/auth/notifications` + payload = { + "topic" => video.ucid, + "videoId" => video.id, + "published" => published.to_unix, + }.to_json + PG_DB.exec("NOTIFY notifications, E'#{payload}'") + end + video = ChannelVideo.new({ id: id, title: video.title, @@ -441,7 +450,11 @@ module Invidious::Routes::Feeds was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) if was_insert - NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video)) + if CONFIG.enable_user_notifications + Invidious::Database::Users.add_notification(video) + else + Invidious::Database::Users.feed_needs_update(video) + end end end end diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 51d85dfe..b6a2e110 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -11,9 +11,29 @@ module Invidious::Routes::Images end end + # We're encapsulating this into a proc in order to easily reuse this + # portion of the code for each request block below. + request_proc = ->(response : HTTP::Client::Response) { + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + env.response.headers.delete("Transfer-Encoding") + return + end + + proxy_file(response, env) + } + begin - GGPHT_POOL.client &.get(url, headers) do |resp| - return self.proxy_image(env, resp) + HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| + return request_proc.call(resp) end rescue ex end @@ -41,10 +61,27 @@ module Invidious::Routes::Images end end + request_proc = ->(response : HTTP::Client::Response) { + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Connection"] = "close" + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + return env.response.headers.delete("Transfer-Encoding") + end + + proxy_file(response, env) + } + begin - get_ytimg_pool(authority).client &.get(url, headers) do |resp| - env.response.headers["Connection"] = "close" - return self.proxy_image(env, resp) + HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| + return request_proc.call(resp) end rescue ex end @@ -64,9 +101,26 @@ module Invidious::Routes::Images end end + request_proc = ->(response : HTTP::Client::Response) { + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + return env.response.headers.delete("Transfer-Encoding") + end + + proxy_file(response, env) + } + begin - get_ytimg_pool("i9").client &.get(url, headers) do |resp| - return self.proxy_image(env, resp) + HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| + return request_proc.call(resp) end rescue ex end @@ -111,7 +165,8 @@ module Invidious::Routes::Images if name == "maxres.jpg" build_thumbnails(id).each do |thumb| thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" - if get_ytimg_pool("i").client &.head(thumbnail_resource_path, headers).status_code == 200 + # This can likely be optimized into a (small) pool sometime in the future. + if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 name = thumb[:url] + ".jpg" break end @@ -126,28 +181,29 @@ module Invidious::Routes::Images end end + request_proc = ->(response : HTTP::Client::Response) { + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + return env.response.headers.delete("Transfer-Encoding") + end + + proxy_file(response, env) + } + begin - get_ytimg_pool("i").client &.get(url, headers) do |resp| - return self.proxy_image(env, resp) + # This can likely be optimized into a (small) pool sometime in the future. + HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| + return request_proc.call(resp) end rescue ex end end - - private def self.proxy_image(env, response) - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - return env.response.headers.delete("Transfer-Encoding") - end - - return proxy_file(response, env) - end end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index e7de5018..d0f7ac22 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -21,6 +21,9 @@ module Invidious::Routes::Login account_type = env.params.query["type"]? account_type ||= "invidious" + captcha_type = env.params.query["captcha"]? + captcha_type ||= "image" + templated "user/login" end @@ -85,14 +88,34 @@ module Invidious::Routes::Login password = password.byte_slice(0, 55) if CONFIG.captcha_enabled + captcha_type = env.params.body["captcha_type"]? answer = env.params.body["answer"]? + change_type = env.params.body["change_type"]? - account_type = "invidious" - captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) + if !captcha_type || change_type + if change_type + captcha_type = change_type + end + captcha_type ||= "image" + + account_type = "invidious" + + if captcha_type == "image" + captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) + else + captcha = Invidious::User::Captcha.generate_text(HMAC_KEY) + end + + return templated "user/login" + end tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v } - if answer + answer ||= "" + captcha_type ||= "image" + + case captcha_type + when "image" answer = answer.lstrip('0') answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) @@ -101,8 +124,27 @@ module Invidious::Routes::Login rescue ex return error_template(400, ex) end - else - return templated "user/login" + else # "text" + answer = Digest::MD5.hexdigest(answer.downcase.strip) + + if tokens.empty? + return error_template(500, "Erroneous CAPTCHA") + end + + found_valid_captcha = false + error_exception = Exception.new + tokens.each do |tok| + begin + validate_request(tok, answer, env.request, HMAC_KEY, locale) + found_valid_captcha = true + rescue ex + error_exception = ex + end + end + + if !found_valid_captcha + return error_template(500, error_exception) + end end end diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index 0b868755..d6bd9571 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -40,21 +40,7 @@ module Invidious::Routes::Misc def self.cross_instance_redirect(env) referer = get_referer(env) - - instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"] - # Filter out the current instance - other_available_instances = instance_list.reject { |_, domain| domain == CONFIG.domain } - - if other_available_instances.empty? - # If the current instance is the only one, use the redirect URL as fallback - instance_url = "redirect.invidious.io" - else - # Select other random instance - # Sample returns an array - # Instances are packaged as {region, domain} in the instance list - instance_url = other_available_instances.sample(1)[0][1] - end - + instance_url = fetch_random_instance env.redirect "https://#{instance_url}#{referer}" end end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index f2213da4..9c6843e9 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -304,6 +304,23 @@ module Invidious::Routes::Playlists end end + if env.params.query["action_create_playlist"]? + action = "action_create_playlist" + elsif env.params.query["action_delete_playlist"]? + action = "action_delete_playlist" + elsif env.params.query["action_edit_playlist"]? + action = "action_edit_playlist" + elsif env.params.query["action_add_video"]? + action = "action_add_video" + video_id = env.params.query["video_id"] + elsif env.params.query["action_remove_video"]? + action = "action_remove_video" + elsif env.params.query["action_move_video_before"]? + action = "action_move_video_before" + else + return env.redirect referer + end + begin playlist_id = env.params.query["playlist_id"] playlist = get_playlist(playlist_id).as(InvidiousPlaylist) @@ -318,8 +335,12 @@ module Invidious::Routes::Playlists end end - case action = env.params.query["action"]? - when "add_video" + email = user.email + + case action + when "action_edit_playlist" + # TODO: Playlist stub + when "action_add_video" if playlist.index.size >= CONFIG.playlist_length_limit if redirect return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") @@ -356,14 +377,12 @@ module Invidious::Routes::Playlists Invidious::Database::PlaylistVideos.insert(playlist_video) Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index) - when "remove_video" + when "action_remove_video" index = env.params.query["set_video_id"] Invidious::Database::PlaylistVideos.delete(index) Invidious::Database::Playlists.update_video_removed(playlist_id, index) - when "move_video_before" + when "action_move_video_before" # TODO: Playlist stub - when nil - return error_json(400, "Missing action") else return error_json(400, "Unsupported action #{action}") end diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 39ca77c0..05bc2714 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -27,10 +27,6 @@ module Invidious::Routes::PreferencesRoute annotations_subscribed ||= "off" annotations_subscribed = annotations_subscribed == "on" - preload = env.params.body["preload"]?.try &.as(String) - preload ||= "off" - preload = preload == "on" - autoplay = env.params.body["autoplay"]?.try &.as(String) autoplay ||= "off" autoplay = autoplay == "on" @@ -148,7 +144,6 @@ module Invidious::Routes::PreferencesRoute preferences = Preferences.from_json({ annotations: annotations, annotations_subscribed: annotations_subscribed, - preload: preload, autoplay: autoplay, captions: captions, comments: comments, diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index b195c7b3..44970922 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -58,11 +58,7 @@ module Invidious::Routes::Search end begin - if user - items = query.process(user.as(User)) - else - items = query.process - end + items = query.process rescue ex : ChannelSearchException return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index 1de655d2..7f9ec592 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -32,16 +32,24 @@ module Invidious::Routes::Subscriptions end end + if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1 + action = "action_create_subscription_to_channel" + elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1 + action = "action_remove_subscriptions" + else + return env.redirect referer + end + channel_id = env.params.query["c"]? channel_id ||= "" - case action = env.params.query["action"]? - when "create_subscription_to_channel" + case action + when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id get_channel(channel_id) Invidious::Database::Users.subscribe_channel(user, channel_id) end - when "remove_subscriptions" + when "action_remove_subscriptions" Invidious::Database::Users.unsubscribe_channel(user, channel_id) else return error_json(400, "Unsupported action #{action}") diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 083087a9..24693662 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -21,7 +21,7 @@ module Invidious::Routes::VideoPlayback end # Sanity check, to avoid being used as an open proxy - if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/) + if !host.matches?(/[\w-]+.googlevideo.com/) return error_template(400, "Invalid \"host\" parameter.") end @@ -37,13 +37,12 @@ module Invidious::Routes::VideoPlayback # See: https://github.com/iv-org/invidious/issues/3302 range_header = env.request.headers["Range"]? - sq = query_params["sq"]? - if range_header.nil? && sq.nil? + if range_header.nil? range_for_head = query_params["range"]? || "0-640" headers["Range"] = "bytes=#{range_for_head}" end - client = make_client(URI.parse(host), region, force_resolve: true) + client = make_client(URI.parse(host), region, force_resolve = true) response = HTTP::Client::Response.new(500) error = "" 5.times do @@ -58,7 +57,7 @@ module Invidious::Routes::VideoPlayback if new_host != host host = new_host client.close - client = make_client(URI.parse(new_host), region, force_resolve: true) + client = make_client(URI.parse(new_host), region, force_resolve = true) end url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" @@ -72,7 +71,7 @@ module Invidious::Routes::VideoPlayback fvip = "3" host = "https://r#{fvip}---#{mn}.googlevideo.com" - client = make_client(URI.parse(host), region, force_resolve: true) + client = make_client(URI.parse(host), region, force_resolve = true) rescue ex error = ex.message end @@ -165,13 +164,10 @@ module Invidious::Routes::VideoPlayback env.response.headers["Access-Control-Allow-Origin"] = "*" if location = resp.headers["Location"]? - url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region) + location = URI.parse(location) + location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" - if title = query_params["title"]? - url = "#{url}&title=#{URI.encode_www_form(title)}" - end - - env.redirect url + env.redirect location break end @@ -200,7 +196,7 @@ module Invidious::Routes::VideoPlayback break else client.close - client = make_client(URI.parse(host), region, force_resolve: true) + client = make_client(URI.parse(host), region, force_resolve = true) end end @@ -257,11 +253,6 @@ module Invidious::Routes::VideoPlayback # YouTube /videoplayback links expire after 6 hours, # so we have a mechanism here to redirect to the latest version def self.latest_version(env) - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}" - end - id = env.params.query["id"]? itag = env.params.query["itag"]?.try &.to_i? diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index e777b3f1..aabe8dfc 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -121,12 +121,10 @@ module Invidious::Routes::Watch adaptive_fmts = video.adaptive_fmts if params.local - fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } + fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) } + adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) } end - # Always proxy DASH streams, otherwise youtube CORS headers will prevent playback - adaptive_fmts.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } - video_streams = video.video_streams audio_streams = video.audio_streams @@ -192,14 +190,6 @@ module Invidious::Routes::Watch captions: video.captions ) - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - env.response.headers["Content-Security-Policy"] = - env.response.headers["Content-Security-Policy"] - .gsub("media-src", "media-src #{invidious_companion.public_url}") - .gsub("connect-src", "connect-src #{invidious_companion.public_url}") - end - templated "watch" end @@ -251,10 +241,18 @@ module Invidious::Routes::Watch end end - case action = env.params.query["action"]? - when "mark_watched" + if env.params.query["action_mark_watched"]? + action = "action_mark_watched" + elsif env.params.query["action_mark_unwatched"]? + action = "action_mark_unwatched" + else + return env.redirect referer + end + + case action + when "action_mark_watched" Invidious::Database::Users.mark_watched(user, id) - when "mark_unwatched" + when "action_mark_unwatched" Invidious::Database::Users.mark_unwatched(user, id) else return error_json(400, "Unsupported action #{action}") @@ -293,9 +291,6 @@ module Invidious::Routes::Watch if CONFIG.disabled?("downloads") return error_template(403, "Administrator has disabled this endpoint.") end - if CONFIG.invidious_companion.present? - return error_template(403, "Downloads should be routed through Companion when present") - end title = env.params.body["title"]? || "" video_id = env.params.body["id"]? || "" @@ -325,9 +320,10 @@ module Invidious::Routes::Watch env.params.query["label"] = URI.decode_www_form(label.as_s) return Invidious::Routes::API::V1::Videos.captions(env) - elsif itag = download_widget["itag"]?.try &.as_i.to_s + elsif itag = download_widget["itag"]?.try &.as_i # URL params specific to /latest_version env.params.query["id"] = video_id + env.params.query["itag"] = itag.to_s env.params.query["title"] = filename env.params.query["local"] = "true" diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 46b71f1f..ba05da19 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -120,10 +120,8 @@ module Invidious::Routing get "/channel/:ucid/streams", Routes::Channels, :streams get "/channel/:ucid/podcasts", Routes::Channels, :podcasts get "/channel/:ucid/releases", Routes::Channels, :releases - get "/channel/:ucid/courses", Routes::Channels, :courses get "/channel/:ucid/playlists", Routes::Channels, :playlists get "/channel/:ucid/community", Routes::Channels, :community - get "/channel/:ucid/posts", Routes::Channels, :community get "/channel/:ucid/channels", Routes::Channels, :channels get "/channel/:ucid/about", Routes::Channels, :about @@ -238,7 +236,6 @@ module Invidious::Routing get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations get "/api/v1/comments/:id", {{namespace}}::Videos, :comments get "/api/v1/clips/:id", {{namespace}}::Videos, :clips - get "/api/v1/transcripts/:id", {{namespace}}::Videos, :transcripts # Feeds get "/api/v1/trending", {{namespace}}::Feeds, :trending @@ -246,18 +243,17 @@ module Invidious::Routing # Channels get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home - get "/api/v1/channels/:ucid/latest", {{namespace}}::Channels, :latest - get "/api/v1/channels/:ucid/videos", {{namespace}}::Channels, :videos get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases - get "/api/v1/channels/:ucid/courses", {{namespace}}::Channels, :courses - get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists - get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community - get "/api/v1/channels/:ucid/posts", {{namespace}}::Channels, :community + get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels - get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search + + {% for route in {"videos", "latest", "playlists", "community", "search"} %} + get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} + get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} + {% end %} # Posts get "/api/v1/post/:id", {{namespace}}::Channels, :post @@ -275,6 +271,11 @@ module Invidious::Routing # Authenticated + # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr + # + # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index bc2715cf..bf968734 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -75,7 +75,7 @@ module Invidious::Search @type : Type = Type::All, @duration : Duration = Duration::None, @features : Features = Features::None, - @sort : Sort = Sort::Relevance, + @sort : Sort = Sort::Relevance ) end diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index 94a92e23..c8e8cf7f 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -47,7 +47,7 @@ module Invidious::Search def initialize( params : HTTP::Params, @type : Type = Type::Regular, - @region : String? = nil, + @region : String? = nil ) # Get the raw search query string (common to all search types). In # Regular search mode, also look for the `search_query` URL parameter diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index d14cde5d..107d148d 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -31,12 +31,12 @@ def fetch_trending(trending_type, region, locale) # See: https://github.com/iv-org/invidious/issues/2989 next if (itm.contents.size < 24 && deduplicate) - extracted.concat itm.contents.select(SearchItem) + extracted.concat extract_category(itm) else extracted << itm end end # Deduplicate items before returning results - return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid + return extracted.select(SearchVideo).uniq!(&.id), plid end diff --git a/src/invidious/user/captcha.cr b/src/invidious/user/captcha.cr index b175c3b9..8a0f67e5 100644 --- a/src/invidious/user/captcha.cr +++ b/src/invidious/user/captcha.cr @@ -4,6 +4,8 @@ struct Invidious::User module Captcha extend self + private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") + def generate_image(key) second = Random::Secure.rand(12) second_angle = second * 30 @@ -58,5 +60,19 @@ struct Invidious::User tokens: {generate_response(answer, {":login"}, key, use_nonce: true)}, } end + + def generate_text(key) + response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) + response = JSON.parse(response) + + tokens = response["a"].as_a.map do |answer| + generate_response(answer.as_s, {":login"}, key, use_nonce: true) + end + + return { + question: response["q"].as_s, + tokens: tokens, + } + end end end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 007eb666..533c18d9 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -290,39 +290,42 @@ struct Invidious::User end def from_newpipe(user : User, body : String) : Bool - Compress::Zip::File.open(IO::Memory.new(body), true) do |file| - entry = file.entries.find { |file_entry| file_entry.filename == "newpipe.db" } - return false if entry.nil? - entry.open do |file_io| - # Ensure max size of 4MB - io_sized = IO::Sized.new(file_io, 0x400000) + io = IO::Memory.new(body) - begin - temp = File.tempfile(".db") do |tempfile| - begin - File.write(tempfile.path, io_sized.gets_to_end) - rescue - return false - end + Compress::Zip::File.open(io) do |file| + file.entries.each do |entry| + entry.open do |file_io| + # Ensure max size of 4MB + io_sized = IO::Sized.new(file_io, 0x400000) - DB.open("sqlite3://" + tempfile.path) do |db| - user.watched += db.query_all("SELECT url FROM streams", as: String) - .map(&.lchop("https://www.youtube.com/watch?v=")) + next if entry.filename != "newpipe.db" - user.watched.uniq! - Invidious::Database::Users.update_watch_history(user) + tempfile = File.tempfile(".db") - user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String) - .map(&.lchop("https://www.youtube.com/channel/")) - - user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions) - - Invidious::Database::Users.update_subscriptions(user) - end + begin + File.write(tempfile.path, io_sized.gets_to_end) + rescue + return false end - ensure - temp.delete if !temp.nil? + + db = DB.open("sqlite3://" + tempfile.path) + + user.watched += db.query_all("SELECT url FROM streams", as: String) + .map(&.lchop("https://www.youtube.com/watch?v=")) + + user.watched.uniq! + Invidious::Database::Users.update_watch_history(user) + + user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String) + .map(&.lchop("https://www.youtube.com/channel/")) + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + + db.close + tempfile.delete end end end diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index 0a8525f3..b3059403 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -4,7 +4,6 @@ struct Preferences property annotations : Bool = CONFIG.default_user_preferences.annotations property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed - property preload : Bool = CONFIG.default_user_preferences.preload property autoplay : Bool = CONFIG.default_user_preferences.autoplay property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 348a0a66..921132f0 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -15,7 +15,7 @@ struct Video # NOTE: don't forget to bump this number if any change is made to # the `params` structure in videos/parser.cr!!! # - SCHEMA_VERSION = 3 + SCHEMA_VERSION = 2 property id : String @@ -26,6 +26,12 @@ struct Video @[DB::Field(ignore: true)] @captions = [] of Invidious::Videos::Captions::Metadata + @[DB::Field(ignore: true)] + property adaptive_fmts : Array(Hash(String, JSON::Any))? + + @[DB::Field(ignore: true)] + property fmt_stream : Array(Hash(String, JSON::Any))? + @[DB::Field(ignore: true)] property description : String? @@ -92,24 +98,72 @@ struct Video # Methods for parsing streaming data - def fmt_stream : Array(Hash(String, JSON::Any)) - if formats = info.dig?("streamingData", "formats") - return formats - .as_a.map(&.as_h) - .sort_by! { |f| f["width"]?.try &.as_i || 0 } + def convert_url(fmt) + if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } + sp = cfr["sp"] + url = URI.parse(cfr["url"]) + params = url.query_params + + LOGGER.debug("Videos: Decoding '#{cfr}'") + + unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"]) + params[sp] = unsig if unsig else - return [] of Hash(String, JSON::Any) + url = URI.parse(fmt["url"].as_s) + params = url.query_params end + + n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) + params["n"] = n if n + + if token = CONFIG.po_token + params["pot"] = token + end + + params["host"] = url.host.not_nil! + if region = self.info["region"]?.try &.as_s + params["region"] = region + end + + url.query_params = params + LOGGER.trace("Videos: new url is '#{url}'") + + return url.to_s + rescue ex + LOGGER.debug("Videos: Error when parsing video URL") + LOGGER.trace(ex.inspect_with_backtrace) + return "" end - def adaptive_fmts : Array(Hash(String, JSON::Any)) - if formats = info.dig?("streamingData", "adaptiveFormats") - return formats - .as_a.map(&.as_h) - .sort_by! { |f| f["width"]?.try &.as_i || f["audioTrack"]?.try { |a| a["audioIsDefault"]?.try { |v| v.as_bool ? -1 : 0 } } || 0 } - else - return [] of Hash(String, JSON::Any) + def fmt_stream + return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream + + fmt_stream = info.dig?("streamingData", "formats") + .try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) + + fmt_stream.each do |fmt| + fmt["url"] = JSON::Any.new(self.convert_url(fmt)) end + + fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } + @fmt_stream = fmt_stream + return @fmt_stream.as(Array(Hash(String, JSON::Any))) + end + + def adaptive_fmts + return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts + + fmt_stream = info.dig("streamingData", "adaptiveFormats") + .try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) + + fmt_stream.each do |fmt| + fmt["url"] = JSON::Any.new(self.convert_url(fmt)) + end + + fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } + @adaptive_fmts = fmt_stream + + return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) end def video_streams diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index c811cfe1..484e61d2 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -123,7 +123,6 @@ module Invidious::Videos "Esperanto", "Estonian", "Filipino", - "Filipino (auto-generated)", "Finnish", "French", "French (auto-generated)", diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index feb58440..95fa3d79 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -36,13 +36,6 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") - if published_time_text = related["publishedTimeText"]? - decoded_time = decode_date(published_time_text["simpleText"].to_s) - published = decoded_time.to_rfc3339.to_s - else - published = nil - end - # TODO: when refactoring video types, make a struct for related videos # or reuse an existing type, if that fits. return { @@ -54,7 +47,6 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? "view_count" => JSON::Any.new(view_count || "0"), "short_view_count" => JSON::Any.new(short_view_count || "0"), "author_verified" => JSON::Any.new(author_verified), - "published" => JSON::Any.new(published || ""), } end @@ -82,7 +74,7 @@ def extract_video_info(video_id : String) "reason" => JSON::Any.new(reason), } end - elsif video_id != player_response.dig?("videoDetails", "videoId") + elsif video_id != player_response.dig("videoDetails", "videoId") # YouTube may return a different video player response than expected. # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 # Line to be reverted if one day we solve the video not available issue. @@ -108,51 +100,42 @@ def extract_video_info(video_id : String) params = parse_video_info(video_id, player_response) params["reason"] = JSON::Any.new(reason) if reason - if !CONFIG.invidious_companion.present? - if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? - LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.") - players_fallback = {YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile} + new_player_response = nil - players_fallback.each do |player_fallback| - client_config.client_type = player_fallback - - next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config)) - - if player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url") - streaming_data = player_response["streamingData"].as_h - streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"] - player_response["streamingData"] = JSON::Any.new(streaming_data) - break - end - rescue InfoException - next LOGGER.warn("Failed to fetch streams with #{player_fallback}") - end - end - - # Seems like video page can still render even without playable streams. - # its better than nothing. - # - # # Were we able to find playable video streams? - # if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? - # # No :( - # end + # Don't use Android client if po_token is passed because po_token doesn't + # work for Android client. + if reason.nil? && CONFIG.po_token.nil? + # Fetch the video streams using an Android client in order to get the + # decrypted URLs and maybe fix throttling issues (#2194). See the + # following issue for an explanation about decrypted URLs: + # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 + client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite + new_player_response = try_fetch_streaming_data(video_id, client_config) end - {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| + # Last hope + # Only trigger if reason found and po_token or didn't work wth Android client. + # TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required + # if the IP address is not blocked. + if CONFIG.po_token && reason || CONFIG.po_token.nil? && new_player_response.nil? + client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed + new_player_response = try_fetch_streaming_data(video_id, client_config) + end + + # Replace player response and reset reason + if !new_player_response.nil? + # Preserve captions & storyboard data before replacement + new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]? + new_player_response["captions"] = player_response["captions"] if player_response["captions"]? + + player_response = new_player_response + params.delete("reason") + end + + {"captions", "playabilityStatus", "playerConfig", "storyboards", "streamingData"}.each do |f| params[f] = player_response[f] if player_response[f]? end - # Convert URLs, if those are present - if streaming_data = player_response["streamingData"]? - %w[formats adaptiveFormats].each do |key| - streaming_data.as_h[key]?.try &.as_a.each do |format| - format.as_h["url"] = JSON::Any.new(convert_url(format)) - end - end - - params["streamingData"] = streaming_data - end - # Data structure version, for cache control params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) @@ -166,7 +149,7 @@ def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConf playability_status = response["playabilityStatus"]["status"] LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") - if id != response.dig?("videoDetails", "videoId") + if id != response.dig("videoDetails", "videoId") # YouTube may return a different video player response than expected. # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 raise InfoException.new( @@ -202,11 +185,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any end video_details = player_response.dig?("videoDetails") - if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer")) - microformat = {} of String => JSON::Any - end + microformat = player_response.dig?("microformat", "playerMicroformatRenderer") raise BrokenTubeException.new("videoDetails") if !video_details + raise BrokenTubeException.new("microformat") if !microformat # Basic video infos @@ -231,17 +213,8 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") .try { |t| Time.parse_rfc3339(t.as_s) } - premiere_timestamp ||= player_response.dig?( - "playabilityStatus", "liveStreamability", - "liveStreamabilityRenderer", "offlineSlate", - "liveStreamOfflineSlateRenderer", "scheduledStartTime" - ) - .try &.as_s.to_i64 - .try { |t| Time.unix(t) } - live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") - .try &.as_bool - live_now ||= video_details.dig?("isLive").try &.as_bool || false + .try &.as_bool || false post_live_dvr = video_details.dig?("isPostLiveDvr") .try &.as_bool || false @@ -252,7 +225,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any .try &.as_a.map &.as_s || [] of String allow_ratings = video_details["allowRatings"]?.try &.as_bool - family_friendly = microformat["isFamilySafe"]?.try &.as_bool + family_friendly = microformat["isFamilySafe"].try &.as_bool is_listed = video_details["isCrawlable"]?.try &.as_bool is_upcoming = video_details["isUpcoming"]?.try &.as_bool @@ -470,35 +443,3 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any return params end - -private def convert_url(fmt) - if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } - sp = cfr["sp"] - url = URI.parse(cfr["url"]) - params = url.query_params - - LOGGER.debug("convert_url: Decoding '#{cfr}'") - - unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"]) - params[sp] = unsig if unsig - else - url = URI.parse(fmt["url"].as_s) - params = url.query_params - end - - n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) - params["n"] = n if n - - if token = CONFIG.po_token - params["pot"] = token - end - - url.query_params = params - LOGGER.trace("convert_url: new url is '#{url}'") - - return url.to_s -rescue ex - LOGGER.debug("convert_url: Error when parsing video URL") - LOGGER.trace(ex.inspect_with_backtrace) - return "" -end diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr index bd0eef59..a72c2f55 100644 --- a/src/invidious/videos/storyboard.cr +++ b/src/invidious/videos/storyboard.cr @@ -20,7 +20,7 @@ module Invidious::Videos def initialize( *, @url, @width, @height, @count, @interval, - @rows, @columns, @images_count, + @rows, @columns, @images_count ) authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]? diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index ee1272d1..4bd9f820 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -122,40 +122,5 @@ module Invidious::Videos return vtt end - - def to_json(json : JSON::Builder) - json.field "languageCode", @language_code - json.field "autoGenerated", @auto_generated - json.field "label", @label - json.field "body" do - json.array do - @lines.each do |line| - json.object do - if line.is_a? HeadingLine - json.field "type", "heading" - else - json.field "type", "regular" - end - - json.field "startMs", line.start_ms.total_milliseconds - json.field "endMs", line.end_ms.total_milliseconds - json.field "line", line.line - end - end - end - end - end - - def to_json - JSON.build do |json| - json.object do - json.field "transcript" do - json.object do - to_json(json) - end - end - end - end - end end end diff --git a/src/invidious/videos/video_preferences.cr b/src/invidious/videos/video_preferences.cr index 48177bd8..34cf7ff0 100644 --- a/src/invidious/videos/video_preferences.cr +++ b/src/invidious/videos/video_preferences.cr @@ -2,7 +2,6 @@ struct VideoPreferences include JSON::Serializable property annotations : Bool - property preload : Bool property autoplay : Bool property comments : Array(String) property continue : Bool @@ -29,7 +28,6 @@ end def process_video_params(query, preferences) annotations = query["iv_load_policy"]?.try &.to_i? - preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe } autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } comments = query["comments"]?.try &.split(",").map(&.downcase) continue = query["continue"]?.try { |q| (q == "true" || q == "1").to_unsafe } @@ -52,7 +50,6 @@ def process_video_params(query, preferences) if preferences # region ||= preferences.region annotations ||= preferences.annotations.to_unsafe - preload ||= preferences.preload.to_unsafe autoplay ||= preferences.autoplay.to_unsafe comments ||= preferences.comments continue ||= preferences.continue.to_unsafe @@ -73,7 +70,6 @@ def process_video_params(query, preferences) end annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe - preload ||= CONFIG.default_user_preferences.preload.to_unsafe autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe comments ||= CONFIG.default_user_preferences.comments continue ||= CONFIG.default_user_preferences.continue.to_unsafe @@ -93,7 +89,6 @@ def process_video_params(query, preferences) save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe annotations = annotations == 1 - preload = preload == 1 autoplay = autoplay == 1 continue = continue == 1 continue_autoplay = continue_autoplay == 1 @@ -133,7 +128,6 @@ def process_video_params(query, preferences) params = VideoPreferences.new({ annotations: annotations, - preload: preload, autoplay: autoplay, comments: comments, continue: continue, diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 686de6bd..a84e44bc 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -11,7 +11,6 @@ when .channels? then "/channel/#{ucid}/channels" when .podcasts? then "/channel/#{ucid}/podcasts" when .releases? then "/channel/#{ucid}/releases" - when .courses? then "/channel/#{ucid}/courses" else "/channel/#{ucid}" end @@ -21,9 +20,7 @@ page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale, base_url: relative_url, - ctoken: next_continuation, - first_page: continuation.nil?, - params: env.params.query, + ctoken: next_continuation ) %> @@ -43,8 +40,6 @@ <%- end -%> - - <%= author %> - Invidious <% end %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 132e636c..d2a305d3 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -7,7 +7,7 @@ youtube_url = "https://www.youtube.com#{relative_url}" redirect_url = Invidious::Frontend::Misc.redirect_url(env) - selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Posts + selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Community -%> <% content_for "header" do %> diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index a24423df..6d227cfc 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,6 +1,6 @@ <%- thin_mode = env.get("preferences").as(Preferences).thin_mode - item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category | ProblematicTimelineItem) && env.get?("user").try &.as(User).watched.index(item.id) != nil + item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil author_verified = item.responds_to?(:author_verified) && item.author_verified -%> @@ -97,18 +97,6 @@
  • <% when Category %> - <% when ProblematicTimelineItem %> -
    -
    - -

    <%=translate(locale, "timeline_parse_error_placeholder_heading")%>

    -

    <%=translate(locale, "timeline_parse_error_placeholder_message")%>

    -
    -
    - <%=translate(locale, "timeline_parse_error_show_technical_details")%> -
    <%=get_issue_template(env, item.parse_exception)[1]%>
    -
    -
    <% else %> <%- # `endpoint_params` is used for the "video-context-buttons" component @@ -140,7 +128,7 @@
    <%- if env.get? "show_watched" -%> -
    " method="post"> + " method="post"> ">
    <%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%> - <%- form_parameters = "action=remove_video&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> + <%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
    ">
    <% else %> -
    " method="post"> + " method="post"> "> diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 3037f3d7..667cfa37 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -9,6 +9,90 @@

    <%= translate(locale, "JavaScript license information") %>

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - <%- {% for row in run("../../../scripts/generate_js_licenses.cr").stringify.split('\n') %} %> - <%-= {{row.id}} -%> - <% {% end %} -%> + + + + + + +
    + _helpers.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    + handlers.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    + community.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    + embed.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    + notifications.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    + player.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    silvermine-videojs-quality-selector.min.js @@ -37,6 +121,34 @@
    + subscribe_widget.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    + themes.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    videojs-contrib-quality-levels.js @@ -177,9 +289,19 @@
    + watch.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr index 7ac96bc6..2b03d280 100644 --- a/src/invidious/views/user/login.ecr +++ b/src/invidious/views/user/login.ecr @@ -25,17 +25,44 @@ <% end %> <% if captcha %> - <% captcha = captcha.not_nil! %> - - <% captcha[:tokens].each_with_index do |token, i| %> - + <% case captcha_type when %> + <% when "image" %> + <% captcha = captcha.not_nil! %> + + <% captcha[:tokens].each_with_index do |token, i| %> + + <% end %> + + + + <% else # "text" %> + <% captcha = captcha.not_nil! %> + <% captcha[:tokens].each_with_index do |token, i| %> + + <% end %> + + + "> <% end %> - - + + <% case captcha_type when %> + <% when "image" %> + + <% else # "text" %> + + <% end %> <% else %>
    -
    - - checked<% end %>> -
    -
    checked<% end %>> diff --git a/src/invidious/views/user/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr index d566e228..c9801f09 100644 --- a/src/invidious/views/user/subscription_manager.ecr +++ b/src/invidious/views/user/subscription_manager.ecr @@ -37,7 +37,7 @@

    - " method="post"> + " method="post"> "> "> diff --git a/src/invidious/views/user/token_manager.ecr b/src/invidious/views/user/token_manager.ecr index 8431deb0..a73fa048 100644 --- a/src/invidious/views/user/token_manager.ecr +++ b/src/invidious/views/user/token_manager.ecr @@ -29,7 +29,7 @@

    -
    " method="post"> + " method="post"> "> ">
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 6f9ced6f..45c58a16 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -158,7 +158,7 @@ we're going to need to do it here in order to allow for translations. <% if user %> <% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %> <% if !playlists.empty? %> -
    +
    "> +