diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9f17bb40a..9ca093681 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,6 @@ +# 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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 02bc3795a..4c1a63307 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 +130,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 +152,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 7c5ef1185..f3e3b9510 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) @@ -61,13 +60,28 @@ class Kemal::ExceptionHandler end end -class FilteredCompressHandler < HTTP::CompressHandler +class FilteredCompressHandler < Kemal::Handler exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/sb/*", "/ggpht/*", "/api/v1/auth/notifications"] exclude ["/api/v1/auth/notifications", "/data_control"], "POST" - def call(context) - return call_next context if exclude_match? context - super + def call(env) + return call_next env if exclude_match? env + + {% if flag?(:without_zlib) %} + call_next env + {% else %} + request_headers = env.request.headers + + if request_headers.includes_word?("Accept-Encoding", "gzip") + env.response.headers["Content-Encoding"] = "gzip" + env.response.output = Compress::Gzip::Writer.new(env.response.output, sync_close: true) + elsif request_headers.includes_word?("Accept-Encoding", "deflate") + env.response.headers["Content-Encoding"] = "deflate" + env.response.output = Compress::Deflate::Writer.new(env.response.output, sync_close: true) + end + + call_next env + {% end %} end end diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index bca2edda9..1ba3ea61a 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -54,7 +54,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/macros.cr b/src/invidious/helpers/macros.cr index 848473218..43e7171ba 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 2796a8dc6..1fef5f934 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -24,7 +24,6 @@ struct SearchVideo property length_seconds : Int32 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) @@ -89,24 +88,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 @@ -242,7 +223,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 +272,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 +314,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/utils.cr b/src/invidious/helpers/utils.cr index 5637e5338..4d9bb28dc 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 @@ -383,22 +383,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/notification_job.cr b/src/invidious/jobs/notification_job.cr index 968ee47f8..b445107b4 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 58805af22..08cd533f6 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 28ff0ff6e..823ca85b0 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 7c584d153..a51e88b48 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -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 c8db207c2..dd65e7a60 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 c27caad73..d89e752cd 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 503b8c051..588bbc2a7 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -368,35 +368,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 @@ -436,7 +407,7 @@ module Invidious::Routes::API::V1::Channels if ucid.nil? response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") return error_json(400, "Invalid post ID") if response["error"]? - ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s) + ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s else ucid = ucid.to_s end @@ -460,15 +431,13 @@ module Invidious::Routes::API::V1::Channels format = env.params.query["format"]? format ||= "json" - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "top" continuation = env.params.query["continuation"]? case continuation when nil, "" ucid = env.params.query["ucid"] - comments = Comments.fetch_community_post_comments(ucid, id, sort_by: sort_by) + comments = Comments.fetch_community_post_comments(ucid, id) else comments = YoutubeAPI.browse(continuation: continuation) end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 4ae877a8b..093669fe0 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 = { @@ -190,30 +186,15 @@ module Invidious::Routes::API::V1::Misc sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint params = sub_endpoint.try &.dig?("params") - - if sub_endpoint["browseId"]?.try &.as_s == "FEpost_detail" - decoded_protobuf = params.try &.as_s.try { |i| URI.decode_www_form(i) } - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } - - ucid = decoded_protobuf.try(&.["56:0:embedded"]["2:0:string"].as_s) - post_id = decoded_protobuf.try(&.["56:0:embedded"]["3:1:string"].as_s) - else - ucid = sub_endpoint["browseId"]? if sub_endpoint["browseId"]? && sub_endpoint["browseId"]?.try &.as_s.starts_with? "UC" - post_id = nil - end rescue ex return error_json(500, ex) end JSON.build do |json| json.object do - json.field "browseId", sub_endpoint["browseId"].as_s if sub_endpoint["browseId"]? - json.field "ucid", ucid if ucid != nil + json.field "ucid", sub_endpoint["browseId"].as_s if sub_endpoint["browseId"]? json.field "videoId", sub_endpoint["videoId"].as_s if sub_endpoint["videoId"]? json.field "playlistId", sub_endpoint["playlistId"].as_s if sub_endpoint["playlistId"]? json.field "startTimeSeconds", sub_endpoint["startTimeSeconds"].as_i if sub_endpoint["startTimeSeconds"]? - json.field "postId", post_id if post_id != nil json.field "params", params.try &.as_s json.field "pageType", page_type end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 6a3eb8ae3..368304ac2 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 63b935ec6..5695dee92 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, @@ -63,7 +71,6 @@ module Invidious::Routes::BeforeAll "/videoplayback", "/latest_version", "/download", - "/companion/", }.any? { |r| env.request.resource.starts_with? r } if env.request.cookies.has_key? "SID" @@ -103,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 6d2b4465c..7d634cbb6 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -197,29 +197,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 +214,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 @@ -284,7 +262,7 @@ module Invidious::Routes::Channels response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") return error_template(400, "Invalid post ID") if response["error"]? - ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s) + ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) end @@ -329,8 +307,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/companion.cr b/src/invidious/routes/companion.cr deleted file mode 100644 index 11c2e3f59..000000000 --- a/src/invidious/routes/companion.cr +++ /dev/null @@ -1,43 +0,0 @@ -module Invidious::Routes::Companion - # /companion - def self.get_companion(env) - url = env.request.path - if env.request.query - url += "?#{env.request.query}" - end - - begin - COMPANION_POOL.client do |wrapper| - wrapper.client.get(url, env.request.headers) do |resp| - return self.proxy_companion(env, resp) - end - end - rescue ex - end - end - - def self.options_companion(env) - url = env.request.path - if env.request.query - url += "?#{env.request.query}" - end - - begin - COMPANION_POOL.client do |wrapper| - wrapper.client.options(url, env.request.headers) do |resp| - return self.proxy_companion(env, resp) - end - end - rescue ex - end - end - - private def self.proxy_companion(env, response) - env.response.status_code = response.status_code - response.headers.each do |key, value| - env.response.headers[key] = value - end - - return IO.copy response.body_io, env.response - end -end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 6b0887d52..266f7ba4b 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.id}?#{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,21 +201,6 @@ module Invidious::Routes::Embed return env.redirect url end - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion| - uri = - "#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}" - end.join(" ") - - if !invidious_companion_urls.empty? - env.response.headers["Content-Security-Policy"] = - env.response.headers["Content-Security-Policy"] - .gsub("media-src", "media-src #{invidious_companion_urls}") - .gsub("connect-src", "connect-src #{invidious_companion_urls}") - end - end - rendered "embed" end end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 070c96eb8..ea7fb3965 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,41 @@ 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, 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 +300,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 +309,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 +423,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 +448,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 51d85dfec..639697db5 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -111,7 +111,7 @@ 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 + if get_ytimg_pool("i9").client &.head(thumbnail_resource_path, headers).status_code == 200 name = thumb[:url] + ".jpg" break end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index e7de5018d..d0f7ac229 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 0b8687557..8b620d63e 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -42,17 +42,12 @@ module Invidious::Routes::Misc 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 + if instance_list.empty? 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] + instance_url = instance_list.sample(1)[0][1] end env.redirect "https://#{instance_url}#{referer}" diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index f2213da44..9c6843e99 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 9936e5230..39ca77c06 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -144,8 +144,6 @@ module Invidious::Routes::PreferencesRoute notifications_only ||= "off" notifications_only = notifications_only == "on" - default_playlist = env.params.body["default_playlist"]?.try &.as(String) - # Convert to JSON and back again to take advantage of converters used for compatibility preferences = Preferences.from_json({ annotations: annotations, @@ -182,7 +180,6 @@ module Invidious::Routes::PreferencesRoute vr_mode: vr_mode, show_nick: show_nick, save_player_pos: save_player_pos, - default_playlist: default_playlist, }.to_json) if user = env.get? "user" diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index b195c7b37..449709228 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 1de655d20..7f9ec5926 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 083087a91..26852d068 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,8 +37,7 @@ 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 @@ -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 @@ -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 8a4fa2468..aabe8dfc2 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,21 +190,6 @@ module Invidious::Routes::Watch captions: video.captions ) - if CONFIG.invidious_companion.present? - invidious_companion = CONFIG.invidious_companion.sample - invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion| - uri = - "#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}" - end.join(" ") - - if !invidious_companion_urls.empty? - env.response.headers["Content-Security-Policy"] = - env.response.headers["Content-Security-Policy"] - .gsub("media-src", "media-src #{invidious_companion_urls}") - .gsub("connect-src", "connect-src #{invidious_companion_urls}") - end - end - templated "watch" end @@ -258,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}") @@ -300,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"]? || "" @@ -332,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 a51bb4b67..9009062f1 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -46,7 +46,6 @@ module Invidious::Routing self.register_api_v1_routes self.register_api_manifest_routes self.register_video_playback_routes - self.register_companion_routes end # ------------------- @@ -121,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 @@ -189,7 +186,7 @@ module Invidious::Routing end # ------------------- - # Proxy routes + # Media proxy routes # ------------------- def register_api_manifest_routes @@ -224,13 +221,6 @@ module Invidious::Routing get "/vi/:id/:name", Routes::Images, :thumbnails end - def register_companion_routes - if CONFIG.invidious_companion.present? - get "/companion/*", Routes::Companion, :get_companion - options "/companion/*", Routes::Companion, :options_companion - end - end - # ------------------- # API routes # ------------------- @@ -246,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 @@ -260,10 +249,8 @@ module Invidious::Routing 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 diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index bc2715cf1..bf968734c 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 94a92e23d..c8e8cf7f9 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 d14cde5d9..107d148d0 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 b175c3b92..8a0f67e56 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 007eb666b..533c18d96 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 df195dd69..0a8525f36 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -56,7 +56,6 @@ struct Preferences property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc property volume : Int32 = CONFIG.default_user_preferences.volume property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos - property default_playlist : String? = nil module BoolToString def self.to_json(value : String, json : JSON::Builder) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 348a0a66a..ae09e736e 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 @@ -106,7 +106,7 @@ struct Video 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 } + .sort_by! { |f| f["width"]?.try &.as_i || 0 } else return [] of Hash(String, JSON::Any) end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 6b1dedd69..915c9baf8 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. @@ -102,44 +94,33 @@ def extract_video_info(video_id : String) # Don't fetch the next endpoint if the video is unavailable. if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) - # Remove the microformat returned by the /next endpoint on some videos - # to prevent player_response microformat from being overwritten. - next_response.delete("microformat") player_response = player_response.merge(next_response) end 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::TvSimply, YoutubeAPI::ClientType::WebMobile} + new_player_response = nil - players_fallback.each do |player_fallback| - client_config.client_type = player_fallback + # Don't use Android test suite client if po_token is passed because po_token doesn't + # work for Android test suite 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 - next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config)) + # 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"]? - adaptive_formats = player_fallback_response.dig?("streamingData", "adaptiveFormats") - if adaptive_formats && (adaptive_formats.dig?(0, "url") || adaptive_formats.dig?(0, "signatureCipher")) - streaming_data = player_response["streamingData"].as_h - streaming_data["adaptiveFormats"] = adaptive_formats - 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 + player_response = new_player_response + params.delete("reason") end {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| @@ -150,11 +131,7 @@ def extract_video_info(video_id : String) if streaming_data = player_response["streamingData"]? %w[formats adaptiveFormats].each do |key| streaming_data.as_h[key]?.try &.as_a.each do |format| - format = format.as_h - if format["url"]?.nil? - format["url"] = format["signatureCipher"] - end - format["url"] = JSON::Any.new(convert_url(format)) + format.as_h["url"] = JSON::Any.new(convert_url(format)) end end @@ -174,7 +151,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( diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr index bd0eef598..a72c2f559 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 ee1272d16..4bd9f820a 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/views/channel.ecr b/src/invidious/views/channel.ecr index 686de6bd4..a84e44bc2 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 132e636ce..d2a305d3c 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 a24423df9..6d227cfc9 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 3037f3d7a..667cfa374 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/data_control.ecr b/src/invidious/views/user/data_control.ecr index e57926f50..9ce42c994 100644 --- a/src/invidious/views/user/data_control.ecr +++ b/src/invidious/views/user/data_control.ecr @@ -14,7 +14,7 @@
    diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr index 7ac96bc6f..2b03d280b 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 %>
    - <% if user = env.get?("user").try &.as(User) %> - <% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %> -
    - - -
    - <% end %> - <%= translate(locale, "preferences_category_visual") %>
    diff --git a/src/invidious/views/user/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr index d566e2285..c9801f094 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 8431deb04..a73fa048c 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 fada6361b..45c58a162 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -158,17 +158,18 @@ 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? %> -
    +
    "> +