diff --git a/.ameba.yml b/.ameba.yml index df97b539..36d7c48f 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -38,6 +38,9 @@ Style/RedundantBegin: Style/RedundantReturn: Enabled: false +Style/RedundantNext: + Enabled: false + Style/ParenthesesAroundCondition: Enabled: false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7a2c3760..9f17bb40 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,12 +1,9 @@ -# 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 @thefrenchghosty @SamantazFox @unixfox +config/config.example.yml @SamantazFox @unixfox scripts/ @syeopite shards.lock @syeopite diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4c1a6330..02bc3795 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,8 +10,10 @@ assignees: '' -
#{issue_template}+
#{issue_template}END_HTML @@ -128,7 +139,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) @@ -150,7 +161,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 f3e3b951..13ea9fe9 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -27,6 +27,7 @@ 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 23a1aafc..bca2edda 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,8 +1,22 @@ +# 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 @@ -23,6 +37,7 @@ 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 @@ -39,6 +54,7 @@ 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 b443073e..03349595 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -1,3 +1,5 @@ +require "colorize" + enum LogLevel All = 0 Trace = 1 @@ -10,7 +12,9 @@ enum LogLevel end class Invidious::LogHandler < Kemal::BaseLogHandler - def initialize(@io : IO = STDOUT, @level = LogLevel::Debug) + def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true) + Colorize.enabled = use_color + Colorize.on_tty_only! end def call(context : HTTP::Server::Context) @@ -39,10 +43,22 @@ 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}") + puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}}))) end end {% end %} diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 43e7171b..84847321 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -55,12 +55,11 @@ macro templated(_filename, template = "template", navbar_search = true) {{ layout = "src/invidious/views/" + template + ".ecr" }} __content_filename__ = {{filename}} - content = Kilt.render({{filename}}) - Kilt.render({{layout}}) + render {{filename}}, {{layout}} end macro rendered(filename) - Kilt.render("src/invidious/views/#{{{filename}}}.ecr") + 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 463d5557..2796a8dc 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -1,3 +1,16 @@ +@[Flags] +enum VideoBadges + LiveNow + Premium + ThreeD + FourK + New + EightK + VR180 + VR360 + ClosedCaptions +end + struct SearchVideo include DB::Serializable @@ -9,10 +22,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 @@ -76,6 +89,24 @@ 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 @@ -88,13 +119,20 @@ 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.live_now - json.field "premium", self.premium + json.field "liveNow", self.badges.live_now? + json.field "premium", self.badges.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 @@ -204,7 +242,7 @@ struct SearchChannel qualities.each do |quality| json.object do - json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") json.field "width", quality json.field "height", quality end @@ -253,6 +291,55 @@ 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 @@ -295,4 +382,4 @@ struct Continuation end end -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category | ProblematicTimelineItem diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index 9e72c1c7..6d198a42 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -175,8 +175,9 @@ 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 @@ -186,10 +187,26 @@ module Invidious::SigHelper LOGGER.debug("SigHelper: Multiplexor listening") - # TODO: reopen socket if unexpectedly closed spawn do loop do - receive_data + 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 Fiber.yield end end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 8e9e9a6a..5637e533 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,68 +323,6 @@ 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 @@ -445,3 +383,22 @@ 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 new file mode 100644 index 00000000..cb4280b9 --- /dev/null +++ b/src/invidious/jobs/instance_refresh_job.cr @@ -0,0 +1,97 @@ +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 b445107b..968ee47f 100644 --- a/src/invidious/jobs/notification_job.cr +++ b/src/invidious/jobs/notification_job.cr @@ -1,8 +1,32 @@ +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(@connection_channel, @pg_url) + def initialize(@notification_channel, @connection_channel, @pg_url) end def begin @@ -10,6 +34,70 @@ 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 08cd533f..58805af2 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -267,6 +267,12 @@ 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 823ca85b..28ff0ff6 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) +def template_mix(mix, listen) html = <<-END_HTML
#{recode_length_seconds(video["lengthSeconds"].as_i)}
diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 3e6eef95..7c584d15 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.byte_slice(0, 150), + title: playlist.title[..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 + videos = [] of PlaylistVideo | ProblematicTimelineItem 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 + videos = [] of PlaylistVideo | ProblematicTimelineItem if initial_data["contents"]? tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"] @@ -500,12 +500,14 @@ 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) +def template_playlist(playlist, listen) 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 dd65e7a6..c8db207c 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -328,17 +328,9 @@ module Invidious::Routes::Account end end - 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" + case action = env.params.query["action"]? + when "revoke_token" + session = env.params.query["session"] 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 d89e752c..c27caad7 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -8,6 +8,11 @@ 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 } @@ -27,28 +32,21 @@ module Invidious::Routes::API::Manifest haltf env, status_code: response.status_code end - manifest = response.body - - manifest = manifest.gsub(/<%=translate(locale, "timeline_parse_error_placeholder_message")%>
+<%=get_issue_template(env, item.parse_exception)[1]%>+