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 @@ -130,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) @@ -152,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 1ba3ea61..bca2edda 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -54,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/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 1fef5f93..2796a8dc 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -24,6 +24,7 @@ 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) @@ -88,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 @@ -223,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 @@ -272,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 @@ -314,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/utils.cr b/src/invidious/helpers/utils.cr index 4d9bb28d..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 @@ -383,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/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

@@ -95,7 +95,7 @@ def template_mix(mix) 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 a51e88b4..7c584d15 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 + 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

    @@ -519,7 +521,7 @@ def template_playlist(playlist) 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 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(/[^<]+<\/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 - + # 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 "#{url}" end return manifest end - adaptive_fmts = video.adaptive_fmts - + # Ditto, only proxify URLs if `local=true` is used if local - adaptive_fmts.each do |fmt| - fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}") + video.adaptive_fmts.each do |fmt| + fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s, absolute: true)) end end @@ -70,17 +68,23 @@ 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: fmt["bitrate"].to_s + "k") do + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: "#{displayname} [#{bitrate}k]", lang: lang) 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: i == 0 ? "main" : "alternate") + xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: is_default ? "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", @@ -177,8 +181,9 @@ module Invidious::Routes::API::Manifest manifest = response.body if local - manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match| - path = URI.parse(match).path + manifest = manifest.gsub(/https:\/\/[^\n"]*/m) do |match| + uri = URI.parse(match) + path = uri.path path = path.lchop("/videoplayback/") path = path.rchop("/") @@ -207,7 +212,7 @@ module Invidious::Routes::API::Manifest raw_params["fvip"] = fvip["fvip"] end - raw_params["local"] = "true" + raw_params["host"] = uri.host.not_nil! "#{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 588bbc2a..a940ee68 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -368,6 +368,35 @@ module Invidious::Routes::API::V1::Channels end end + def self.courses(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_courses(channel.ucid, channel.author, continuation) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + def self.community(env) locale = env.get("preferences").as(Preferences).locale diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 093669fe..4f5b58da 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -42,6 +42,9 @@ 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 @@ -85,7 +88,7 @@ module Invidious::Routes::API::V1::Misc end if format == "html" - playlist_html = template_playlist(json_response) + playlist_html = template_playlist(json_response, listen) 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 = { @@ -111,6 +114,9 @@ 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) @@ -141,9 +147,7 @@ module Invidious::Routes::API::V1::Misc json.field "authorUrl", "/channel/#{video.ucid}" json.field "videoThumbnails" do - json.array do - Invidious::JSONify::APIv1.thumbnails(json, video.id) - end + Invidious::JSONify::APIv1.thumbnails(json, video.id) end json.field "index", video.index @@ -157,7 +161,7 @@ module Invidious::Routes::API::V1::Misc if format == "html" response = JSON.parse(response) - playlist_html = template_mix(response) + playlist_html = template_mix(response, listen) next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] response = { diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 368304ac..6a3eb8ae 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -429,4 +429,90 @@ 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 5695dee9..b5269668 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -20,14 +20,6 @@ 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:" @@ -45,7 +37,7 @@ module Invidious::Routes::BeforeAll "font-src 'self' data:", "connect-src 'self'", "manifest-src 'self'", - "media-src 'self' blob:" + extra_media_csp, + "media-src 'self' blob:", "child-src 'self' blob:", "frame-src 'self'", "frame-ancestors " + frame_ancestors, @@ -110,6 +102,21 @@ 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 7d634cbb..508aa3e4 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -197,7 +197,29 @@ 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 @@ -214,7 +236,7 @@ module Invidious::Routes::Channels continuation = env.params.query["continuation"]? - if !channel.tabs.includes? "community" + if !channel.tabs.includes? "community" && "posts" return env.redirect "/channel/#{channel.ucid}" end @@ -307,7 +329,8 @@ module Invidious::Routes::Channels private KNOWN_TABS = { "home", "videos", "shorts", "streams", "podcasts", - "releases", "playlists", "community", "channels", "about", + "releases", "courses", "playlists", "community", "channels", "about", + "posts", } # Redirects brand url channels to a normal /channel/:ucid route diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 266f7ba4..930e4915 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -12,13 +12,15 @@ 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/#{videos[0].id}?#{env.params.query}" + url = "/embed/#{first_playlist_video}?#{env.params.query}" if env.params.query.size > 0 url += "?#{env.params.query}" @@ -72,13 +74,15 @@ 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/#{videos[0].id}" + url = "/embed/#{first_playlist_video.id}" elsif video_series url = "/embed/#{video_series.shift}" env.params.query["playlist"] = video_series.join(",") @@ -157,10 +161,12 @@ module Invidious::Routes::Embed adaptive_fmts = video.adaptive_fmts if params.local - 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) } + fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } 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 @@ -201,6 +207,14 @@ module Invidious::Routes::Embed return env.redirect url end + if CONFIG.invidious_companion.present? + invidious_companion = CONFIG.invidious_companion.sample + env.response.headers["Content-Security-Policy"] = + env.response.headers["Content-Security-Policy"] + .gsub("media-src", "media-src #{invidious_companion.public_url}") + .gsub("connect-src", "connect-src #{invidious_companion.public_url}") + end + rendered "embed" end end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index ea7fb396..070c96eb 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -143,32 +143,25 @@ 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" - ucid = env.params.url["ucid"] + if env.params.url["ucid"].matches?(/^[\w-]+$/) + ucid = env.params.url["ucid"] + else + return error_atom(400, InfoException.new("Invalid channel ucid provided.")) + end 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=#{channel.ucid}") + 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 rss = XML.parse(response.body) videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry| @@ -179,7 +172,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 - ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content + video_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 @@ -187,41 +180,44 @@ module Invidious::Routes::Feeds title: title, id: video_id, author: author, - ucid: ucid, + ucid: video_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:#{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("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("author") do - xml.element("name") { xml.text channel.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } + xml.element("name") { xml.text author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } end xml.element("image") do - xml.element("url") { xml.text channel.author_thumbnail } - xml.element("title") { xml.text channel.author } + xml.element("url") { xml.text "" } + xml.element("title") { xml.text author } xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") end videos.each do |video| - video.to_xml(channel.auto_generated, params, xml) + video.to_xml(false, params, xml) end end end @@ -300,7 +296,13 @@ module Invidious::Routes::Feeds xml.element("name") { xml.text playlist.author } end - videos.each &.to_xml(xml) + videos.each do |video| + if video.is_a? PlaylistVideo + video.to_xml(xml) + else + video.to_xml(env, locale, xml) + end + end end end else @@ -309,8 +311,9 @@ module Invidious::Routes::Feeds end response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") - document = XML.parse(response.body) + 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 @@ -423,16 +426,6 @@ 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, @@ -448,11 +441,7 @@ module Invidious::Routes::Feeds was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) if was_insert - if CONFIG.enable_user_notifications - Invidious::Database::Users.add_notification(video) - else - Invidious::Database::Users.feed_needs_update(video) - end + NOTIFICATION_CHANNEL.send(VideoNotification.from_video(video)) end end end diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 639697db..51d85dfe 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("i9").client &.head(thumbnail_resource_path, headers).status_code == 200 + if get_ytimg_pool("i").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 d0f7ac22..e7de5018 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -21,9 +21,6 @@ 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 @@ -88,34 +85,14 @@ 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"]? - 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 + account_type = "invidious" + captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v } - answer ||= "" - captcha_type ||= "image" - - case captcha_type - when "image" + if answer answer = answer.lstrip('0') answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) @@ -124,27 +101,8 @@ module Invidious::Routes::Login rescue ex return error_template(400, ex) end - 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 + else + return templated "user/login" end end diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index 8b620d63..0b868755 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -42,12 +42,17 @@ module Invidious::Routes::Misc referer = get_referer(env) instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"] - if instance_list.empty? + # Filter out the current instance + other_available_instances = instance_list.reject { |_, domain| domain == CONFIG.domain } + + if other_available_instances.empty? + # If the current instance is the only one, use the redirect URL as fallback instance_url = "redirect.invidious.io" else + # Select other random instance # Sample returns an array # Instances are packaged as {region, domain} in the instance list - instance_url = instance_list.sample(1)[0][1] + instance_url = other_available_instances.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 9c6843e9..f2213da4 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -304,23 +304,6 @@ 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) @@ -335,12 +318,8 @@ module Invidious::Routes::Playlists end end - email = user.email - - case action - when "action_edit_playlist" - # TODO: Playlist stub - when "action_add_video" + case action = env.params.query["action"]? + when "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") @@ -377,12 +356,14 @@ module Invidious::Routes::Playlists Invidious::Database::PlaylistVideos.insert(playlist_video) Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index) - when "action_remove_video" + when "remove_video" index = env.params.query["set_video_id"] Invidious::Database::PlaylistVideos.delete(index) Invidious::Database::Playlists.update_video_removed(playlist_id, index) - when "action_move_video_before" + when "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/search.cr b/src/invidious/routes/search.cr index 44970922..b195c7b3 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -58,7 +58,11 @@ module Invidious::Routes::Search end begin - items = query.process + if user + items = query.process(user.as(User)) + else + items = query.process + end 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 7f9ec592..1de655d2 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -32,24 +32,16 @@ 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 - when "action_create_subscription_to_channel" + case action = env.params.query["action"]? + when "create_subscription_to_channel" if !user.subscriptions.includes? channel_id get_channel(channel_id) Invidious::Database::Users.subscribe_channel(user, channel_id) end - when "action_remove_subscriptions" + when "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 26852d06..083087a9 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.com/) + if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/) return error_template(400, "Invalid \"host\" parameter.") end @@ -37,7 +37,8 @@ module Invidious::Routes::VideoPlayback # See: https://github.com/iv-org/invidious/issues/3302 range_header = env.request.headers["Range"]? - if range_header.nil? + sq = query_params["sq"]? + if range_header.nil? && sq.nil? range_for_head = query_params["range"]? || "0-640" headers["Range"] = "bytes=#{range_for_head}" end @@ -164,10 +165,13 @@ module Invidious::Routes::VideoPlayback env.response.headers["Access-Control-Allow-Origin"] = "*" if location = resp.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" + url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region) - env.redirect location + if title = query_params["title"]? + url = "#{url}&title=#{URI.encode_www_form(title)}" + end + + env.redirect url break end @@ -253,6 +257,11 @@ 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 aabe8dfc..e777b3f1 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -121,10 +121,12 @@ module Invidious::Routes::Watch adaptive_fmts = video.adaptive_fmts if params.local - 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) } + fmt_stream.each { |fmt| fmt["url"] = JSON::Any.new(HttpServer::Utils.proxy_video_url(fmt["url"].as_s)) } 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 @@ -190,6 +192,14 @@ module Invidious::Routes::Watch captions: video.captions ) + if CONFIG.invidious_companion.present? + invidious_companion = CONFIG.invidious_companion.sample + env.response.headers["Content-Security-Policy"] = + env.response.headers["Content-Security-Policy"] + .gsub("media-src", "media-src #{invidious_companion.public_url}") + .gsub("connect-src", "connect-src #{invidious_companion.public_url}") + end + templated "watch" end @@ -241,18 +251,10 @@ module Invidious::Routes::Watch end end - 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" + case action = env.params.query["action"]? + when "mark_watched" Invidious::Database::Users.mark_watched(user, id) - when "action_mark_unwatched" + when "mark_unwatched" Invidious::Database::Users.mark_unwatched(user, id) else return error_json(400, "Unsupported action #{action}") @@ -291,6 +293,9 @@ 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"]? || "" @@ -320,10 +325,9 @@ 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 + elsif itag = download_widget["itag"]?.try &.as_i.to_s # 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 9009062f..46b71f1f 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -120,8 +120,10 @@ 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 @@ -236,6 +238,7 @@ 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 @@ -249,8 +252,10 @@ 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 bf968734..bc2715cf 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 c8e8cf7f..94a92e23 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 107d148d..d14cde5d 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 extract_category(itm) + extracted.concat itm.contents.select(SearchItem) else extracted << itm end end # Deduplicate items before returning results - return extracted.select(SearchVideo).uniq!(&.id), plid + return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid end diff --git a/src/invidious/user/captcha.cr b/src/invidious/user/captcha.cr index 8a0f67e5..b175c3b9 100644 --- a/src/invidious/user/captcha.cr +++ b/src/invidious/user/captcha.cr @@ -4,8 +4,6 @@ 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 @@ -60,19 +58,5 @@ 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 533c18d9..007eb666 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -290,42 +290,39 @@ struct Invidious::User end def from_newpipe(user : User, body : String) : Bool - io = IO::Memory.new(body) + 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) - 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) + begin + temp = File.tempfile(".db") do |tempfile| + begin + File.write(tempfile.path, io_sized.gets_to_end) + rescue + return false + end - next if entry.filename != "newpipe.db" + 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=")) - tempfile = File.tempfile(".db") + user.watched.uniq! + Invidious::Database::Users.update_watch_history(user) - begin - File.write(tempfile.path, io_sized.gets_to_end) - rescue - return false + 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 end - - 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 + ensure + temp.delete if !temp.nil? end end end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index ae09e736..348a0a66 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 = 2 + SCHEMA_VERSION = 3 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 || 0 } + .sort_by! { |f| f["width"]?.try &.as_i || f["audioTrack"]?.try { |a| a["audioIsDefault"]?.try { |v| v.as_bool ? -1 : 0 } } || 0 } else return [] of Hash(String, JSON::Any) end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 915c9baf..15bd00b6 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -36,6 +36,13 @@ 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 { @@ -47,6 +54,7 @@ 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 @@ -100,27 +108,22 @@ def extract_video_info(video_id : String) params = parse_video_info(video_id, player_response) params["reason"] = JSON::Any.new(reason) if reason - new_player_response = nil - - # 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 - - # Replace player response and reset reason - if !new_player_response.nil? - # Preserve captions & storyboard data before replacement - new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]? - new_player_response["captions"] = player_response["captions"] if player_response["captions"]? - - player_response = new_player_response - params.delete("reason") + if !CONFIG.invidious_companion.present? + if player_response["streamingData"]? && player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? + LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.") + players_fallback = [YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile] + players_fallback.each do |player_fallback| + client_config.client_type = player_fallback + player_fallback_response = try_fetch_streaming_data(video_id, client_config) + if player_fallback_response && player_fallback_response["streamingData"]? && + player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url") + streaming_data = player_response["streamingData"].as_h + streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"] + player_response["streamingData"] = JSON::Any.new(streaming_data) + break + end + end + end end {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr index a72c2f55..bd0eef59 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 4bd9f820..ee1272d1 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -122,5 +122,40 @@ 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 a84e44bc..686de6bd 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -11,6 +11,7 @@ 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 @@ -20,7 +21,9 @@ page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale, base_url: relative_url, - ctoken: next_continuation + ctoken: next_continuation, + first_page: continuation.nil?, + params: env.params.query, ) %> @@ -40,6 +43,8 @@ <%- end -%> + + <%= author %> - Invidious <% end %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index d2a305d3..132e636c 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::Community + selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Posts -%> <% content_for "header" do %> diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 6d227cfc..a24423df 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) && env.get?("user").try &.as(User).watched.index(item.id) != nil + item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category | ProblematicTimelineItem) && env.get?("user").try &.as(User).watched.index(item.id) != nil author_verified = item.responds_to?(:author_verified) && item.author_verified -%> @@ -97,6 +97,18 @@
  • <% 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 @@ -128,7 +140,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=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> + <%- form_parameters = "action=remove_video&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 667cfa37..3037f3d7 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -9,90 +9,6 @@

    <%= 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 @@ -121,34 +37,6 @@
    - subscribe_widget.js - - AGPL-3.0 - - <%= translate(locale, "source") %> -
    - themes.js - - AGPL-3.0 - - <%= translate(locale, "source") %> -
    videojs-contrib-quality-levels.js @@ -289,19 +177,9 @@
    - watch.js - - AGPL-3.0 - - <%= translate(locale, "source") %> -
    diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr index 2b03d280..7ac96bc6 100644 --- a/src/invidious/views/user/login.ecr +++ b/src/invidious/views/user/login.ecr @@ -25,44 +25,17 @@ <% end %> <% if captcha %> - <% 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 %> - - - "> + <% captcha = captcha.not_nil! %> + + <% captcha[:tokens].each_with_index do |token, i| %> + <% end %> + + - - <% case captcha_type when %> - <% when "image" %> - - <% else # "text" %> - - <% end %> <% else %>