mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-11-04 06:31:57 +00:00 
			
		
		
		
	Extract API routes (#2271)
* Extract API routes from invidious.cr * Remove deprecated APIs - insights - top feed
This commit is contained in:
		
							
								
								
									
										1833
									
								
								src/invidious.cr
									
									
									
									
									
								
							
							
						
						
									
										1833
									
								
								src/invidious.cr
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -185,6 +185,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil)
 | 
			
		||||
  if !author
 | 
			
		||||
    raise InfoException.new("Deleted or invalid channel")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  author = author.content
 | 
			
		||||
 | 
			
		||||
  # Auto-generated channels
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										224
									
								
								src/invidious/routes/api/manifest.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								src/invidious/routes/api/manifest.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,224 @@
 | 
			
		||||
module Invidious::Routes::API::Manifest
 | 
			
		||||
  # /api/manifest/dash/id/:id
 | 
			
		||||
  def self.get_dash_video_id(env)
 | 
			
		||||
    env.response.headers.add("Access-Control-Allow-Origin", "*")
 | 
			
		||||
    env.response.content_type = "application/dash+xml"
 | 
			
		||||
 | 
			
		||||
    local = env.params.query["local"]?.try &.== "true"
 | 
			
		||||
    id = env.params.url["id"]
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
    # 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 }
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      video = get_video(id, PG_DB, region: region)
 | 
			
		||||
    rescue ex : VideoRedirect
 | 
			
		||||
      return env.redirect env.request.resource.gsub(id, ex.video_id)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      haltf env, status_code: 403
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if dashmpd = video.dash_manifest_url
 | 
			
		||||
      manifest = YT_POOL.client &.get(URI.parse(dashmpd).request_target).body
 | 
			
		||||
 | 
			
		||||
      manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl|
 | 
			
		||||
        url = baseurl.lchop("<BaseURL>")
 | 
			
		||||
        url = url.rchop("</BaseURL>")
 | 
			
		||||
 | 
			
		||||
        if local
 | 
			
		||||
          uri = URI.parse(url)
 | 
			
		||||
          url = "#{uri.request_target}host/#{uri.host}/"
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        "<BaseURL>#{url}</BaseURL>"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      return manifest
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    adaptive_fmts = video.adaptive_fmts
 | 
			
		||||
 | 
			
		||||
    if local
 | 
			
		||||
      adaptive_fmts.each do |fmt|
 | 
			
		||||
        fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    audio_streams = video.audio_streams
 | 
			
		||||
    video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse
 | 
			
		||||
 | 
			
		||||
    manifest = XML.build(indent: "  ", encoding: "UTF-8") do |xml|
 | 
			
		||||
      xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011",
 | 
			
		||||
        "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static",
 | 
			
		||||
        mediaPresentationDuration: "PT#{video.length_seconds}S") do
 | 
			
		||||
        xml.element("Period") do
 | 
			
		||||
          i = 0
 | 
			
		||||
 | 
			
		||||
          {"audio/mp4", "audio/webm"}.each do |mime_type|
 | 
			
		||||
            mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type }
 | 
			
		||||
            next if mime_streams.empty?
 | 
			
		||||
 | 
			
		||||
            xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do
 | 
			
		||||
              mime_streams.each do |fmt|
 | 
			
		||||
                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("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do
 | 
			
		||||
                  xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011",
 | 
			
		||||
                    value: "2")
 | 
			
		||||
                  xml.element("BaseURL") { xml.text url }
 | 
			
		||||
                  xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do
 | 
			
		||||
                    xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}")
 | 
			
		||||
                  end
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            i += 1
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144}
 | 
			
		||||
 | 
			
		||||
          {"video/mp4", "video/webm"}.each do |mime_type|
 | 
			
		||||
            mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type }
 | 
			
		||||
            next if mime_streams.empty?
 | 
			
		||||
 | 
			
		||||
            heights = [] of Int32
 | 
			
		||||
            xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do
 | 
			
		||||
              mime_streams.each do |fmt|
 | 
			
		||||
                codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"')
 | 
			
		||||
                bandwidth = fmt["bitrate"].as_i
 | 
			
		||||
                itag = fmt["itag"].as_i
 | 
			
		||||
                url = fmt["url"].as_s
 | 
			
		||||
                width = fmt["width"].as_i
 | 
			
		||||
                height = fmt["height"].as_i
 | 
			
		||||
 | 
			
		||||
                # Resolutions reported by YouTube player (may not accurately reflect source)
 | 
			
		||||
                height = potential_heights.min_by { |i| (height - i).abs }
 | 
			
		||||
                next if unique_res && heights.includes? height
 | 
			
		||||
                heights << height
 | 
			
		||||
 | 
			
		||||
                xml.element("Representation", id: itag, codecs: codecs, width: width, height: height,
 | 
			
		||||
                  startWithSAP: "1", maxPlayoutRate: "1",
 | 
			
		||||
                  bandwidth: bandwidth, frameRate: fmt["fps"]) do
 | 
			
		||||
                  xml.element("BaseURL") { xml.text url }
 | 
			
		||||
                  xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do
 | 
			
		||||
                    xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}")
 | 
			
		||||
                  end
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            i += 1
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    return manifest
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # /api/manifest/dash/id/videoplayback
 | 
			
		||||
  def self.get_dash_video_playback(env)
 | 
			
		||||
    env.response.headers.delete("Content-Type")
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
    env.redirect "/videoplayback?#{env.params.query}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # /api/manifest/dash/id/videoplayback/*
 | 
			
		||||
  def self.get_dash_video_playback_greedy(env)
 | 
			
		||||
    env.response.headers.delete("Content-Type")
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
    env.redirect env.request.path.lchop("/api/manifest/dash/id")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # /api/manifest/dash/id/videoplayback && /api/manifest/dash/id/videoplayback/*
 | 
			
		||||
  def self.options_dash_video_playback(env)
 | 
			
		||||
    env.response.headers.delete("Content-Type")
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # /api/manifest/hls_playlist/*
 | 
			
		||||
  def self.get_hls_playlist(env)
 | 
			
		||||
    response = YT_POOL.client &.get(env.request.path)
 | 
			
		||||
 | 
			
		||||
    if response.status_code != 200
 | 
			
		||||
      haltf env, status_code: response.status_code
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    local = env.params.query["local"]?.try &.== "true"
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/x-mpegURL"
 | 
			
		||||
    env.response.headers.add("Access-Control-Allow-Origin", "*")
 | 
			
		||||
 | 
			
		||||
    manifest = response.body
 | 
			
		||||
 | 
			
		||||
    if local
 | 
			
		||||
      manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match|
 | 
			
		||||
        path = URI.parse(match).path
 | 
			
		||||
 | 
			
		||||
        path = path.lchop("/videoplayback/")
 | 
			
		||||
        path = path.rchop("/")
 | 
			
		||||
 | 
			
		||||
        path = path.gsub(/mime\/\w+\/\w+/) do |mimetype|
 | 
			
		||||
          mimetype = mimetype.split("/")
 | 
			
		||||
          mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2]
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        path = path.split("/")
 | 
			
		||||
 | 
			
		||||
        raw_params = {} of String => Array(String)
 | 
			
		||||
        path.each_slice(2) do |pair|
 | 
			
		||||
          key, value = pair
 | 
			
		||||
          value = URI.decode_www_form(value)
 | 
			
		||||
 | 
			
		||||
          if raw_params[key]?
 | 
			
		||||
            raw_params[key] << value
 | 
			
		||||
          else
 | 
			
		||||
            raw_params[key] = [value]
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        raw_params = HTTP::Params.new(raw_params)
 | 
			
		||||
        if fvip = raw_params["hls_chunk_host"].match(/r(?<fvip>\d+)---/)
 | 
			
		||||
          raw_params["fvip"] = fvip["fvip"]
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        raw_params["local"] = "true"
 | 
			
		||||
 | 
			
		||||
        "#{HOST_URL}/videoplayback?#{raw_params}"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    manifest
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # /api/manifest/hls_variant/*
 | 
			
		||||
  def self.get_hls_variant(env)
 | 
			
		||||
    response = YT_POOL.client &.get(env.request.path)
 | 
			
		||||
 | 
			
		||||
    if response.status_code != 200
 | 
			
		||||
      haltf env, status_code: response.status_code
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    local = env.params.query["local"]?.try &.== "true"
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/x-mpegURL"
 | 
			
		||||
    env.response.headers.add("Access-Control-Allow-Origin", "*")
 | 
			
		||||
 | 
			
		||||
    manifest = response.body
 | 
			
		||||
 | 
			
		||||
    if local
 | 
			
		||||
      manifest = manifest.gsub("https://www.youtube.com", HOST_URL)
 | 
			
		||||
      manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    manifest
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										415
									
								
								src/invidious/routes/api/v1/authenticated.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										415
									
								
								src/invidious/routes/api/v1/authenticated.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,415 @@
 | 
			
		||||
module Invidious::Routes::API::V1::Authenticated
 | 
			
		||||
  # The notification APIs cannot be extracted yet!
 | 
			
		||||
  # They require the *local* notifications constant defined in invidious.cr
 | 
			
		||||
  #
 | 
			
		||||
  # def self.notifications(env)
 | 
			
		||||
  #   env.response.content_type = "text/event-stream"
 | 
			
		||||
 | 
			
		||||
  #   topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000)
 | 
			
		||||
  #   topics ||= [] of String
 | 
			
		||||
 | 
			
		||||
  #   create_notification_stream(env, topics, connection_channel)
 | 
			
		||||
  # end
 | 
			
		||||
 | 
			
		||||
  def self.get_preferences(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
    user.preferences.to_json
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.set_preferences(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      preferences = Preferences.from_json(env.request.body || "{}")
 | 
			
		||||
    rescue
 | 
			
		||||
      preferences = user.preferences
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email)
 | 
			
		||||
 | 
			
		||||
    env.response.status_code = 204
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.feed(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    max_results = env.params.query["max_results"]?.try &.to_i?
 | 
			
		||||
    max_results ||= user.preferences.max_results
 | 
			
		||||
    max_results ||= CONFIG.default_user_preferences.max_results
 | 
			
		||||
 | 
			
		||||
    page = env.params.query["page"]?.try &.to_i?
 | 
			
		||||
    page ||= 1
 | 
			
		||||
 | 
			
		||||
    videos, notifications = get_subscription_feed(PG_DB, user, max_results, page)
 | 
			
		||||
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.object do
 | 
			
		||||
        json.field "notifications" do
 | 
			
		||||
          json.array do
 | 
			
		||||
            notifications.each do |video|
 | 
			
		||||
              video.to_json(locale, json)
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        json.field "videos" do
 | 
			
		||||
          json.array do
 | 
			
		||||
            videos.each do |video|
 | 
			
		||||
              video.to_json(locale, json)
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.get_subscriptions(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    if user.subscriptions.empty?
 | 
			
		||||
      values = "'{}'"
 | 
			
		||||
    else
 | 
			
		||||
      values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel)
 | 
			
		||||
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        subscriptions.each do |subscription|
 | 
			
		||||
          json.object do
 | 
			
		||||
            json.field "author", subscription.author
 | 
			
		||||
            json.field "authorId", subscription.id
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.subscribe_channel(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
 | 
			
		||||
    if !user.subscriptions.includes? ucid
 | 
			
		||||
      get_channel(ucid, PG_DB, false, false)
 | 
			
		||||
      PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # For Google accounts, access tokens don't have enough information to
 | 
			
		||||
    # make a request on the user's behalf, which is why we don't sync with
 | 
			
		||||
    # YouTube.
 | 
			
		||||
 | 
			
		||||
    env.response.status_code = 204
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.unsubscribe_channel(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
 | 
			
		||||
    PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email)
 | 
			
		||||
 | 
			
		||||
    env.response.status_code = 204
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.list_playlists(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1", user.email, as: InvidiousPlaylist)
 | 
			
		||||
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        playlists.each do |playlist|
 | 
			
		||||
          playlist.to_json(0, locale, json)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.create_playlist(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150)
 | 
			
		||||
    if !title
 | 
			
		||||
      return error_json(400, "Invalid title.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) }
 | 
			
		||||
    if !privacy
 | 
			
		||||
      return error_json(400, "Invalid privacy setting.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100
 | 
			
		||||
      return error_json(400, "User cannot have more than 100 playlists.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    playlist = create_playlist(PG_DB, title, privacy, user)
 | 
			
		||||
    env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}"
 | 
			
		||||
    env.response.status_code = 201
 | 
			
		||||
    {
 | 
			
		||||
      "title"      => title,
 | 
			
		||||
      "playlistId" => playlist.id,
 | 
			
		||||
    }.to_json
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.update_playlist_attribute(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    plid = env.params.url["plid"]
 | 
			
		||||
 | 
			
		||||
    playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
 | 
			
		||||
    if !playlist || playlist.author != user.email && playlist.privacy.private?
 | 
			
		||||
      return error_json(404, "Playlist does not exist.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if playlist.author != user.email
 | 
			
		||||
      return error_json(403, "Invalid user")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title
 | 
			
		||||
    privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } || playlist.privacy
 | 
			
		||||
    description = env.params.json["description"]?.try &.as(String).delete("\r") || playlist.description
 | 
			
		||||
 | 
			
		||||
    if title != playlist.title ||
 | 
			
		||||
       privacy != playlist.privacy ||
 | 
			
		||||
       description != playlist.description
 | 
			
		||||
      updated = Time.utc
 | 
			
		||||
    else
 | 
			
		||||
      updated = playlist.updated
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid)
 | 
			
		||||
    env.response.status_code = 204
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.delete_playlist(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    plid = env.params.url["plid"]
 | 
			
		||||
 | 
			
		||||
    playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
 | 
			
		||||
    if !playlist || playlist.author != user.email && playlist.privacy.private?
 | 
			
		||||
      return error_json(404, "Playlist does not exist.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if playlist.author != user.email
 | 
			
		||||
      return error_json(403, "Invalid user")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid)
 | 
			
		||||
    PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid)
 | 
			
		||||
 | 
			
		||||
    env.response.status_code = 204
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.insert_video_into_playlist(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    plid = env.params.url["plid"]
 | 
			
		||||
 | 
			
		||||
    playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
 | 
			
		||||
    if !playlist || playlist.author != user.email && playlist.privacy.private?
 | 
			
		||||
      return error_json(404, "Playlist does not exist.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if playlist.author != user.email
 | 
			
		||||
      return error_json(403, "Invalid user")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if playlist.index.size >= 500
 | 
			
		||||
      return error_json(400, "Playlist cannot have more than 500 videos")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    video_id = env.params.json["videoId"].try &.as(String)
 | 
			
		||||
    if !video_id
 | 
			
		||||
      return error_json(403, "Invalid videoId")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      video = get_video(video_id, PG_DB)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    playlist_video = PlaylistVideo.new({
 | 
			
		||||
      title:          video.title,
 | 
			
		||||
      id:             video.id,
 | 
			
		||||
      author:         video.author,
 | 
			
		||||
      ucid:           video.ucid,
 | 
			
		||||
      length_seconds: video.length_seconds,
 | 
			
		||||
      published:      video.published,
 | 
			
		||||
      plid:           plid,
 | 
			
		||||
      live_now:       video.live_now,
 | 
			
		||||
      index:          Random::Secure.rand(0_i64..Int64::MAX),
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    video_array = playlist_video.to_a
 | 
			
		||||
    args = arg_array(video_array)
 | 
			
		||||
 | 
			
		||||
    PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array)
 | 
			
		||||
    PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid)
 | 
			
		||||
 | 
			
		||||
    env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}"
 | 
			
		||||
    env.response.status_code = 201
 | 
			
		||||
    playlist_video.to_json(locale, index: playlist.index.size)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.delete_video_in_playlist(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
 | 
			
		||||
    plid = env.params.url["plid"]
 | 
			
		||||
    index = env.params.url["index"].to_i64(16)
 | 
			
		||||
 | 
			
		||||
    playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist)
 | 
			
		||||
    if !playlist || playlist.author != user.email && playlist.privacy.private?
 | 
			
		||||
      return error_json(404, "Playlist does not exist.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if playlist.author != user.email
 | 
			
		||||
      return error_json(403, "Invalid user")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if !playlist.index.includes? index
 | 
			
		||||
      return error_json(404, "Playlist does not contain index")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index)
 | 
			
		||||
    PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, plid)
 | 
			
		||||
 | 
			
		||||
    env.response.status_code = 204
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Invidious::Routing.patch "/api/v1/auth/playlists/:plid/videos/:index"
 | 
			
		||||
  # def modify_playlist_at(env)
 | 
			
		||||
  # TODO
 | 
			
		||||
  # end
 | 
			
		||||
 | 
			
		||||
  def self.get_tokens(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
    scopes = env.get("scopes").as(Array(String))
 | 
			
		||||
 | 
			
		||||
    tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time})
 | 
			
		||||
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        tokens.each do |token|
 | 
			
		||||
          json.object do
 | 
			
		||||
            json.field "session", token[:session]
 | 
			
		||||
            json.field "issued", token[:issued].to_unix
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.register_token(env)
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    case env.request.headers["Content-Type"]?
 | 
			
		||||
    when "application/x-www-form-urlencoded"
 | 
			
		||||
      scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
 | 
			
		||||
      callback_url = env.params.body["callbackUrl"]?
 | 
			
		||||
      expire = env.params.body["expire"]?.try &.to_i?
 | 
			
		||||
    when "application/json"
 | 
			
		||||
      scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s }
 | 
			
		||||
      callback_url = env.params.json["callbackUrl"]?.try &.as(String)
 | 
			
		||||
      expire = env.params.json["expire"]?.try &.as(Int64)
 | 
			
		||||
    else
 | 
			
		||||
      return error_json(400, "Invalid or missing header 'Content-Type'")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if callback_url && callback_url.empty?
 | 
			
		||||
      callback_url = nil
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if callback_url
 | 
			
		||||
      callback_url = URI.parse(callback_url)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if sid = env.get?("sid").try &.as(String)
 | 
			
		||||
      env.response.content_type = "text/html"
 | 
			
		||||
 | 
			
		||||
      csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true)
 | 
			
		||||
      return templated "authorize_token"
 | 
			
		||||
    else
 | 
			
		||||
      env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
      superset_scopes = env.get("scopes").as(Array(String))
 | 
			
		||||
 | 
			
		||||
      authorized_scopes = [] of String
 | 
			
		||||
      scopes.each do |scope|
 | 
			
		||||
        if scopes_include_scope(superset_scopes, scope)
 | 
			
		||||
          authorized_scopes << scope
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB)
 | 
			
		||||
 | 
			
		||||
      if callback_url
 | 
			
		||||
        access_token = URI.encode_www_form(access_token)
 | 
			
		||||
 | 
			
		||||
        if query = callback_url.query
 | 
			
		||||
          query = HTTP::Params.parse(query.not_nil!)
 | 
			
		||||
        else
 | 
			
		||||
          query = HTTP::Params.new
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        query["token"] = access_token
 | 
			
		||||
        callback_url.query = query.to_s
 | 
			
		||||
 | 
			
		||||
        env.redirect callback_url.to_s
 | 
			
		||||
      else
 | 
			
		||||
        access_token
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.unregister_token(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
    scopes = env.get("scopes").as(Array(String))
 | 
			
		||||
 | 
			
		||||
    session = env.params.json["session"]?.try &.as(String)
 | 
			
		||||
    session ||= env.get("session").as(String)
 | 
			
		||||
 | 
			
		||||
    # Allow tokens to revoke other tokens with correct scope
 | 
			
		||||
    if session == env.get("session").as(String)
 | 
			
		||||
      PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session)
 | 
			
		||||
    elsif scopes_include_scope(scopes, "GET:tokens")
 | 
			
		||||
      PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session)
 | 
			
		||||
    else
 | 
			
		||||
      return error_json(400, "Cannot revoke session #{session}")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    env.response.status_code = 204
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										278
									
								
								src/invidious/routes/api/v1/channels.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								src/invidious/routes/api/v1/channels.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,278 @@
 | 
			
		||||
module Invidious::Routes::API::V1::Channels
 | 
			
		||||
  def self.home(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
    sort_by = env.params.query["sort_by"]?.try &.downcase
 | 
			
		||||
    sort_by ||= "newest"
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      channel = get_about_info(ucid, locale)
 | 
			
		||||
    rescue ex : ChannelRedirect
 | 
			
		||||
      env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
 | 
			
		||||
      return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    page = 1
 | 
			
		||||
    if channel.auto_generated
 | 
			
		||||
      videos = [] of SearchVideo
 | 
			
		||||
      count = 0
 | 
			
		||||
    else
 | 
			
		||||
      begin
 | 
			
		||||
        count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
 | 
			
		||||
      rescue ex
 | 
			
		||||
        return error_json(500, ex)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      # TODO: Refactor into `to_json` for InvidiousChannel
 | 
			
		||||
      json.object do
 | 
			
		||||
        json.field "author", channel.author
 | 
			
		||||
        json.field "authorId", channel.ucid
 | 
			
		||||
        json.field "authorUrl", channel.author_url
 | 
			
		||||
 | 
			
		||||
        json.field "authorBanners" do
 | 
			
		||||
          json.array do
 | 
			
		||||
            if channel.banner
 | 
			
		||||
              qualities = {
 | 
			
		||||
                {width: 2560, height: 424},
 | 
			
		||||
                {width: 2120, height: 351},
 | 
			
		||||
                {width: 1060, height: 175},
 | 
			
		||||
              }
 | 
			
		||||
              qualities.each do |quality|
 | 
			
		||||
                json.object do
 | 
			
		||||
                  json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-")
 | 
			
		||||
                  json.field "width", quality[:width]
 | 
			
		||||
                  json.field "height", quality[:height]
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
 | 
			
		||||
              json.object do
 | 
			
		||||
                json.field "url", channel.banner.not_nil!.split("=w1060-")[0]
 | 
			
		||||
                json.field "width", 512
 | 
			
		||||
                json.field "height", 288
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        json.field "authorThumbnails" do
 | 
			
		||||
          json.array do
 | 
			
		||||
            qualities = {32, 48, 76, 100, 176, 512}
 | 
			
		||||
 | 
			
		||||
            qualities.each do |quality|
 | 
			
		||||
              json.object do
 | 
			
		||||
                json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}")
 | 
			
		||||
                json.field "width", quality
 | 
			
		||||
                json.field "height", quality
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        json.field "subCount", channel.sub_count
 | 
			
		||||
        json.field "totalViews", channel.total_views
 | 
			
		||||
        json.field "joined", channel.joined.to_unix
 | 
			
		||||
 | 
			
		||||
        json.field "autoGenerated", channel.auto_generated
 | 
			
		||||
        json.field "isFamilyFriendly", channel.is_family_friendly
 | 
			
		||||
        json.field "description", html_to_content(channel.description_html)
 | 
			
		||||
        json.field "descriptionHtml", channel.description_html
 | 
			
		||||
 | 
			
		||||
        json.field "allowedRegions", channel.allowed_regions
 | 
			
		||||
 | 
			
		||||
        json.field "latestVideos" do
 | 
			
		||||
          json.array do
 | 
			
		||||
            videos.each do |video|
 | 
			
		||||
              video.to_json(locale, json)
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        json.field "relatedChannels" do
 | 
			
		||||
          json.array do
 | 
			
		||||
            channel.related_channels.each do |related_channel|
 | 
			
		||||
              json.object do
 | 
			
		||||
                json.field "author", related_channel.author
 | 
			
		||||
                json.field "authorId", related_channel.ucid
 | 
			
		||||
                json.field "authorUrl", related_channel.author_url
 | 
			
		||||
 | 
			
		||||
                json.field "authorThumbnails" do
 | 
			
		||||
                  json.array do
 | 
			
		||||
                    qualities = {32, 48, 76, 100, 176, 512}
 | 
			
		||||
 | 
			
		||||
                    qualities.each do |quality|
 | 
			
		||||
                      json.object do
 | 
			
		||||
                        json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}")
 | 
			
		||||
                        json.field "width", quality
 | 
			
		||||
                        json.field "height", quality
 | 
			
		||||
                      end
 | 
			
		||||
                    end
 | 
			
		||||
                  end
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.latest(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      videos = get_latest_videos(ucid)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        videos.each do |video|
 | 
			
		||||
          video.to_json(locale, json)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.videos(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
    page = env.params.query["page"]?.try &.to_i?
 | 
			
		||||
    page ||= 1
 | 
			
		||||
    sort_by = env.params.query["sort"]?.try &.downcase
 | 
			
		||||
    sort_by ||= env.params.query["sort_by"]?.try &.downcase
 | 
			
		||||
    sort_by ||= "newest"
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      channel = get_about_info(ucid, locale)
 | 
			
		||||
    rescue ex : ChannelRedirect
 | 
			
		||||
      env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
 | 
			
		||||
      return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        videos.each do |video|
 | 
			
		||||
          video.to_json(locale, json)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.playlists(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
    continuation = env.params.query["continuation"]?
 | 
			
		||||
    sort_by = env.params.query["sort"]?.try &.downcase ||
 | 
			
		||||
              env.params.query["sort_by"]?.try &.downcase ||
 | 
			
		||||
              "last"
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      channel = get_about_info(ucid, locale)
 | 
			
		||||
    rescue ex : ChannelRedirect
 | 
			
		||||
      env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id)
 | 
			
		||||
      return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id})
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by)
 | 
			
		||||
 | 
			
		||||
    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", continuation
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.community(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
 | 
			
		||||
    thin_mode = env.params.query["thin_mode"]?
 | 
			
		||||
    thin_mode = thin_mode == "true"
 | 
			
		||||
 | 
			
		||||
    format = env.params.query["format"]?
 | 
			
		||||
    format ||= "json"
 | 
			
		||||
 | 
			
		||||
    continuation = env.params.query["continuation"]?
 | 
			
		||||
    # sort_by = env.params.query["sort_by"]?.try &.downcase
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      fetch_channel_community(ucid, continuation, locale, format, thin_mode)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.search(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
 | 
			
		||||
    query = env.params.query["q"]?
 | 
			
		||||
    query ||= ""
 | 
			
		||||
 | 
			
		||||
    page = env.params.query["page"]?.try &.to_i?
 | 
			
		||||
    page ||= 1
 | 
			
		||||
 | 
			
		||||
    count, search_results = channel_search(query, page, ucid)
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        search_results.each do |item|
 | 
			
		||||
          item.to_json(locale, json)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # 301 redirect from /api/v1/channels/comments/:ucid
 | 
			
		||||
  # and /api/v1/channels/:ucid/comments to new /api/v1/channels/:ucid/community and
 | 
			
		||||
  # corresponding equivalent URL structure of the other one.
 | 
			
		||||
  def self.channel_comments_redirect(env)
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    ucid = env.params.url["ucid"]
 | 
			
		||||
 | 
			
		||||
    env.response.headers["Location"] = "/api/v1/channels/#{ucid}/community?#{env.params.query}"
 | 
			
		||||
    env.response.status_code = 301
 | 
			
		||||
    return
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										45
									
								
								src/invidious/routes/api/v1/feeds.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/invidious/routes/api/v1/feeds.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
			
		||||
module Invidious::Routes::API::V1::Feeds
 | 
			
		||||
  def self.trending(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
    trending_type = env.params.query["type"]?
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      trending, plid = fetch_trending(trending_type, region, locale)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    videos = JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        trending.each do |video|
 | 
			
		||||
          video.to_json(locale, json)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    videos
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.popular(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    if !CONFIG.popular_enabled
 | 
			
		||||
      error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
 | 
			
		||||
      haltf env, 400, error_message
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        popular_videos.each do |video|
 | 
			
		||||
          video.to_json(locale, json)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										136
									
								
								src/invidious/routes/api/v1/misc.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/invidious/routes/api/v1/misc.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,136 @@
 | 
			
		||||
module Invidious::Routes::API::V1::Misc
 | 
			
		||||
  # Stats API endpoint for Invidious
 | 
			
		||||
  def self.stats(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    if !CONFIG.statistics_enabled
 | 
			
		||||
      return error_json(400, "Statistics are not enabled.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # APIv1 currently uses the same logic for both
 | 
			
		||||
  # user playlists and Invidious playlists. This means that we can't
 | 
			
		||||
  # reasonably split them yet. This should be addressed in APIv2
 | 
			
		||||
  def self.get_playlist(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    plid = env.params.url["plid"]
 | 
			
		||||
 | 
			
		||||
    offset = env.params.query["index"]?.try &.to_i?
 | 
			
		||||
    offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 }
 | 
			
		||||
    offset ||= 0
 | 
			
		||||
 | 
			
		||||
    continuation = env.params.query["continuation"]?
 | 
			
		||||
 | 
			
		||||
    format = env.params.query["format"]?
 | 
			
		||||
    format ||= "json"
 | 
			
		||||
 | 
			
		||||
    if plid.starts_with? "RD"
 | 
			
		||||
      return env.redirect "/api/v1/mixes/#{plid}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      playlist = get_playlist(PG_DB, plid, locale)
 | 
			
		||||
    rescue ex : InfoException
 | 
			
		||||
      return error_json(404, ex)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(404, "Playlist does not exist.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = env.get?("user").try &.as(User)
 | 
			
		||||
    if !playlist || playlist.privacy.private? && playlist.author != user.try &.email
 | 
			
		||||
      return error_json(404, "Playlist does not exist.")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    response = playlist.to_json(offset, locale, continuation: continuation)
 | 
			
		||||
 | 
			
		||||
    if format == "html"
 | 
			
		||||
      response = JSON.parse(response)
 | 
			
		||||
      playlist_html = template_playlist(response)
 | 
			
		||||
      index, next_video = response["videos"].as_a.skip(1).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
 | 
			
		||||
 | 
			
		||||
      response = {
 | 
			
		||||
        "playlistHtml" => playlist_html,
 | 
			
		||||
        "index"        => index,
 | 
			
		||||
        "nextVideo"    => next_video,
 | 
			
		||||
      }.to_json
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    response
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.mixes(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    rdid = env.params.url["rdid"]
 | 
			
		||||
 | 
			
		||||
    continuation = env.params.query["continuation"]?
 | 
			
		||||
    continuation ||= rdid.lchop("RD")[0, 11]
 | 
			
		||||
 | 
			
		||||
    format = env.params.query["format"]?
 | 
			
		||||
    format ||= "json"
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      mix = fetch_mix(rdid, continuation, locale: locale)
 | 
			
		||||
 | 
			
		||||
      if !rdid.ends_with? continuation
 | 
			
		||||
        mix = fetch_mix(rdid, mix.videos[1].id)
 | 
			
		||||
        index = mix.videos.index(mix.videos.select { |video| video.id == continuation }[0]?)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      mix.videos = mix.videos[index..-1]
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    response = JSON.build do |json|
 | 
			
		||||
      json.object do
 | 
			
		||||
        json.field "title", mix.title
 | 
			
		||||
        json.field "mixId", mix.id
 | 
			
		||||
 | 
			
		||||
        json.field "videos" do
 | 
			
		||||
          json.array do
 | 
			
		||||
            mix.videos.each do |video|
 | 
			
		||||
              json.object do
 | 
			
		||||
                json.field "title", video.title
 | 
			
		||||
                json.field "videoId", video.id
 | 
			
		||||
                json.field "author", video.author
 | 
			
		||||
 | 
			
		||||
                json.field "authorId", video.ucid
 | 
			
		||||
                json.field "authorUrl", "/channel/#{video.ucid}"
 | 
			
		||||
 | 
			
		||||
                json.field "videoThumbnails" do
 | 
			
		||||
                  json.array do
 | 
			
		||||
                    generate_thumbnails(json, video.id)
 | 
			
		||||
                  end
 | 
			
		||||
                end
 | 
			
		||||
 | 
			
		||||
                json.field "index", video.index
 | 
			
		||||
                json.field "lengthSeconds", video.length_seconds
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if format == "html"
 | 
			
		||||
      response = JSON.parse(response)
 | 
			
		||||
      playlist_html = template_mix(response)
 | 
			
		||||
      next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"]
 | 
			
		||||
 | 
			
		||||
      response = {
 | 
			
		||||
        "playlistHtml" => playlist_html,
 | 
			
		||||
        "nextVideo"    => next_video,
 | 
			
		||||
      }.to_json
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    response
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										78
									
								
								src/invidious/routes/api/v1/search.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/invidious/routes/api/v1/search.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,78 @@
 | 
			
		||||
module Invidious::Routes::API::V1::Search
 | 
			
		||||
  def self.search(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    query = env.params.query["q"]?
 | 
			
		||||
    query ||= ""
 | 
			
		||||
 | 
			
		||||
    page = env.params.query["page"]?.try &.to_i?
 | 
			
		||||
    page ||= 1
 | 
			
		||||
 | 
			
		||||
    sort_by = env.params.query["sort_by"]?.try &.downcase
 | 
			
		||||
    sort_by ||= "relevance"
 | 
			
		||||
 | 
			
		||||
    date = env.params.query["date"]?.try &.downcase
 | 
			
		||||
    date ||= ""
 | 
			
		||||
 | 
			
		||||
    duration = env.params.query["duration"]?.try &.downcase
 | 
			
		||||
    duration ||= ""
 | 
			
		||||
 | 
			
		||||
    features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase }
 | 
			
		||||
    features ||= [] of String
 | 
			
		||||
 | 
			
		||||
    content_type = env.params.query["type"]?.try &.downcase
 | 
			
		||||
    content_type ||= "video"
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      search_params = produce_search_params(page, sort_by, date, content_type, duration, features)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(400, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    count, search_results = search(query, search_params, region).as(Tuple)
 | 
			
		||||
    JSON.build do |json|
 | 
			
		||||
      json.array do
 | 
			
		||||
        search_results.each do |item|
 | 
			
		||||
          item.to_json(locale, json)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.search_suggestions(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    query = env.params.query["q"]?
 | 
			
		||||
    query ||= ""
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      headers = HTTP::Headers{":authority" => "suggestqueries.google.com"}
 | 
			
		||||
      response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body
 | 
			
		||||
 | 
			
		||||
      body = response[35..-2]
 | 
			
		||||
      body = JSON.parse(body).as_a
 | 
			
		||||
      suggestions = body[1].as_a[0..-2]
 | 
			
		||||
 | 
			
		||||
      JSON.build do |json|
 | 
			
		||||
        json.object do
 | 
			
		||||
          json.field "query", body[0].as_s
 | 
			
		||||
          json.field "suggestions" do
 | 
			
		||||
            json.array do
 | 
			
		||||
              suggestions.each do |suggestion|
 | 
			
		||||
                json.string suggestion[0].as_s
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										363
									
								
								src/invidious/routes/api/v1/videos.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										363
									
								
								src/invidious/routes/api/v1/videos.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,363 @@
 | 
			
		||||
module Invidious::Routes::API::V1::Videos
 | 
			
		||||
  def self.videos(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    id = env.params.url["id"]
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      video = get_video(id, PG_DB, region: region)
 | 
			
		||||
    rescue ex : VideoRedirect
 | 
			
		||||
      env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
 | 
			
		||||
      return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    video.to_json(locale)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.captions(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    id = env.params.url["id"]
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
    # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354
 | 
			
		||||
    # It is possible to use `/api/timedtext?type=list&v=#{id}` and
 | 
			
		||||
    # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly,
 | 
			
		||||
    # but this does not provide links for auto-generated captions.
 | 
			
		||||
    #
 | 
			
		||||
    # In future this should be investigated as an alternative, since it does not require
 | 
			
		||||
    # getting video info.
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      video = get_video(id, PG_DB, region: region)
 | 
			
		||||
    rescue ex : VideoRedirect
 | 
			
		||||
      env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
 | 
			
		||||
      return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
 | 
			
		||||
    rescue ex
 | 
			
		||||
      haltf env, 500
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    captions = video.captions
 | 
			
		||||
 | 
			
		||||
    label = env.params.query["label"]?
 | 
			
		||||
    lang = env.params.query["lang"]?
 | 
			
		||||
    tlang = env.params.query["tlang"]?
 | 
			
		||||
 | 
			
		||||
    if !label && !lang
 | 
			
		||||
      response = JSON.build do |json|
 | 
			
		||||
        json.object do
 | 
			
		||||
          json.field "captions" do
 | 
			
		||||
            json.array do
 | 
			
		||||
              captions.each do |caption|
 | 
			
		||||
                json.object do
 | 
			
		||||
                  json.field "label", caption.name
 | 
			
		||||
                  json.field "languageCode", caption.languageCode
 | 
			
		||||
                  json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}"
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      return response
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "text/vtt; charset=UTF-8"
 | 
			
		||||
 | 
			
		||||
    if lang
 | 
			
		||||
      caption = captions.select { |caption| caption.languageCode == lang }
 | 
			
		||||
    else
 | 
			
		||||
      caption = captions.select { |caption| caption.name == label }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if caption.empty?
 | 
			
		||||
      haltf env, 404
 | 
			
		||||
    else
 | 
			
		||||
      caption = caption[0]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target
 | 
			
		||||
 | 
			
		||||
    # Auto-generated captions often have cues that aren't aligned properly with the video,
 | 
			
		||||
    # as well as some other markup that makes it cumbersome, so we try to fix that here
 | 
			
		||||
    if caption.name.includes? "auto-generated"
 | 
			
		||||
      caption_xml = YT_POOL.client &.get(url).body
 | 
			
		||||
      caption_xml = XML.parse(caption_xml)
 | 
			
		||||
 | 
			
		||||
      webvtt = String.build do |str|
 | 
			
		||||
        str << <<-END_VTT
 | 
			
		||||
        WEBVTT
 | 
			
		||||
        Kind: captions
 | 
			
		||||
        Language: #{tlang || caption.languageCode}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        END_VTT
 | 
			
		||||
 | 
			
		||||
        caption_nodes = caption_xml.xpath_nodes("//transcript/text")
 | 
			
		||||
        caption_nodes.each_with_index do |node, i|
 | 
			
		||||
          start_time = node["start"].to_f.seconds
 | 
			
		||||
          duration = node["dur"]?.try &.to_f.seconds
 | 
			
		||||
          duration ||= start_time
 | 
			
		||||
 | 
			
		||||
          if caption_nodes.size > i + 1
 | 
			
		||||
            end_time = caption_nodes[i + 1]["start"].to_f.seconds
 | 
			
		||||
          else
 | 
			
		||||
            end_time = start_time + duration
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}"
 | 
			
		||||
          end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}"
 | 
			
		||||
 | 
			
		||||
          text = HTML.unescape(node.content)
 | 
			
		||||
          text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "")
 | 
			
		||||
          text = text.gsub(/<\/font>/, "")
 | 
			
		||||
          if md = text.match(/(?<name>.*) : (?<text>.*)/)
 | 
			
		||||
            text = "<v #{md["name"]}>#{md["text"]}</v>"
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          str << <<-END_CUE
 | 
			
		||||
          #{start_time} --> #{end_time}
 | 
			
		||||
          #{text}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
          END_CUE
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    else
 | 
			
		||||
      webvtt = YT_POOL.client &.get("#{url}&format=vtt").body
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if title = env.params.query["title"]?
 | 
			
		||||
      # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/
 | 
			
		||||
      env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    webvtt
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Fetches YouTube storyboards
 | 
			
		||||
  #
 | 
			
		||||
  # Which are sprites containing x * y preview
 | 
			
		||||
  # thumbnails for individual scenes in a video.
 | 
			
		||||
  # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails
 | 
			
		||||
  def self.storyboards(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    id = env.params.url["id"]
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      video = get_video(id, PG_DB, region: region)
 | 
			
		||||
    rescue ex : VideoRedirect
 | 
			
		||||
      env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id)
 | 
			
		||||
      return error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
 | 
			
		||||
    rescue ex
 | 
			
		||||
      haltf env, 500
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    storyboards = video.storyboards
 | 
			
		||||
    width = env.params.query["width"]?
 | 
			
		||||
    height = env.params.query["height"]?
 | 
			
		||||
 | 
			
		||||
    if !width && !height
 | 
			
		||||
      response = JSON.build do |json|
 | 
			
		||||
        json.object do
 | 
			
		||||
          json.field "storyboards" do
 | 
			
		||||
            generate_storyboards(json, id, storyboards)
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      return response
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "text/vtt"
 | 
			
		||||
 | 
			
		||||
    storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" }
 | 
			
		||||
 | 
			
		||||
    if storyboard.empty?
 | 
			
		||||
      haltf env, 404
 | 
			
		||||
    else
 | 
			
		||||
      storyboard = storyboard[0]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    String.build do |str|
 | 
			
		||||
      str << <<-END_VTT
 | 
			
		||||
      WEBVTT
 | 
			
		||||
      END_VTT
 | 
			
		||||
 | 
			
		||||
      start_time = 0.milliseconds
 | 
			
		||||
      end_time = storyboard[:interval].milliseconds
 | 
			
		||||
 | 
			
		||||
      storyboard[:storyboard_count].times do |i|
 | 
			
		||||
        url = storyboard[:url]
 | 
			
		||||
        authority = /(i\d?).ytimg.com/.match(url).not_nil![1]?
 | 
			
		||||
        url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
 | 
			
		||||
        url = "#{HOST_URL}/sb/#{authority}/#{url}"
 | 
			
		||||
 | 
			
		||||
        storyboard[:storyboard_height].times do |j|
 | 
			
		||||
          storyboard[:storyboard_width].times do |k|
 | 
			
		||||
            str << <<-END_CUE
 | 
			
		||||
            #{start_time}.000 --> #{end_time}.000
 | 
			
		||||
            #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            END_CUE
 | 
			
		||||
 | 
			
		||||
            start_time += storyboard[:interval].milliseconds
 | 
			
		||||
            end_time += storyboard[:interval].milliseconds
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.annotations(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "text/xml"
 | 
			
		||||
 | 
			
		||||
    id = env.params.url["id"]
 | 
			
		||||
    source = env.params.query["source"]?
 | 
			
		||||
    source ||= "archive"
 | 
			
		||||
 | 
			
		||||
    if !id.match(/[a-zA-Z0-9_-]{11}/)
 | 
			
		||||
      haltf env, 400
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    annotations = ""
 | 
			
		||||
 | 
			
		||||
    case source
 | 
			
		||||
    when "archive"
 | 
			
		||||
      if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation))
 | 
			
		||||
        annotations = cached_annotation.annotations
 | 
			
		||||
      else
 | 
			
		||||
        index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0')
 | 
			
		||||
 | 
			
		||||
        # IA doesn't handle leading hyphens,
 | 
			
		||||
        # so we use https://archive.org/details/youtubeannotations_64
 | 
			
		||||
        if index == "62"
 | 
			
		||||
          index = "64"
 | 
			
		||||
          id = id.sub(/^-/, 'A')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml")
 | 
			
		||||
 | 
			
		||||
        location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}"))
 | 
			
		||||
 | 
			
		||||
        if !location.headers["Location"]?
 | 
			
		||||
          env.response.status_code = location.status_code
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"]))
 | 
			
		||||
 | 
			
		||||
        if response.body.empty?
 | 
			
		||||
          haltf env, 404
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if response.status_code != 200
 | 
			
		||||
          haltf env, response.status_code
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        annotations = response.body
 | 
			
		||||
 | 
			
		||||
        cache_annotation(PG_DB, id, annotations)
 | 
			
		||||
      end
 | 
			
		||||
    else # "youtube"
 | 
			
		||||
      response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
 | 
			
		||||
 | 
			
		||||
      if response.status_code != 200
 | 
			
		||||
        haltf env, response.status_code
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      annotations = response.body
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    etag = sha256(annotations)[0, 16]
 | 
			
		||||
    if env.request.headers["If-None-Match"]?.try &.== etag
 | 
			
		||||
      haltf env, 304
 | 
			
		||||
    else
 | 
			
		||||
      env.response.headers["ETag"] = etag
 | 
			
		||||
      annotations
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.comments(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
    id = env.params.url["id"]
 | 
			
		||||
 | 
			
		||||
    source = env.params.query["source"]?
 | 
			
		||||
    source ||= "youtube"
 | 
			
		||||
 | 
			
		||||
    thin_mode = env.params.query["thin_mode"]?
 | 
			
		||||
    thin_mode = thin_mode == "true"
 | 
			
		||||
 | 
			
		||||
    format = env.params.query["format"]?
 | 
			
		||||
    format ||= "json"
 | 
			
		||||
 | 
			
		||||
    action = env.params.query["action"]?
 | 
			
		||||
    action ||= "action_get_comments"
 | 
			
		||||
 | 
			
		||||
    continuation = env.params.query["continuation"]?
 | 
			
		||||
    sort_by = env.params.query["sort_by"]?.try &.downcase
 | 
			
		||||
 | 
			
		||||
    if source == "youtube"
 | 
			
		||||
      sort_by ||= "top"
 | 
			
		||||
 | 
			
		||||
      begin
 | 
			
		||||
        comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by)
 | 
			
		||||
      rescue ex
 | 
			
		||||
        return error_json(500, ex)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      return comments
 | 
			
		||||
    elsif source == "reddit"
 | 
			
		||||
      sort_by ||= "confidence"
 | 
			
		||||
 | 
			
		||||
      begin
 | 
			
		||||
        comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by)
 | 
			
		||||
        content_html = template_reddit_comments(comments, locale)
 | 
			
		||||
 | 
			
		||||
        content_html = fill_links(content_html, "https", "www.reddit.com")
 | 
			
		||||
        content_html = replace_links(content_html)
 | 
			
		||||
      rescue ex
 | 
			
		||||
        comments = nil
 | 
			
		||||
        reddit_thread = nil
 | 
			
		||||
        content_html = ""
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if !reddit_thread || !comments
 | 
			
		||||
        haltf env, 404
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if format == "json"
 | 
			
		||||
        reddit_thread = JSON.parse(reddit_thread.to_json).as_h
 | 
			
		||||
        reddit_thread["comments"] = JSON.parse(comments.to_json)
 | 
			
		||||
 | 
			
		||||
        return reddit_thread.to_json
 | 
			
		||||
      else
 | 
			
		||||
        response = {
 | 
			
		||||
          "title"       => reddit_thread.title,
 | 
			
		||||
          "permalink"   => reddit_thread.permalink,
 | 
			
		||||
          "contentHtml" => content_html,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return response.to_json
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										280
									
								
								src/invidious/routes/video_playback.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								src/invidious/routes/video_playback.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,280 @@
 | 
			
		||||
module Invidious::Routes::VideoPlayback
 | 
			
		||||
  # /videoplayback
 | 
			
		||||
  def self.get_video_playback(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
    query_params = env.params.query
 | 
			
		||||
 | 
			
		||||
    fvip = query_params["fvip"]? || "3"
 | 
			
		||||
    mns = query_params["mn"]?.try &.split(",")
 | 
			
		||||
    mns ||= [] of String
 | 
			
		||||
 | 
			
		||||
    if query_params["region"]?
 | 
			
		||||
      region = query_params["region"]
 | 
			
		||||
      query_params.delete("region")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if query_params["host"]? && !query_params["host"].empty?
 | 
			
		||||
      host = "https://#{query_params["host"]}"
 | 
			
		||||
      query_params.delete("host")
 | 
			
		||||
    else
 | 
			
		||||
      host = "https://r#{fvip}---#{mns.pop}.googlevideo.com"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    url = "/videoplayback?#{query_params.to_s}"
 | 
			
		||||
 | 
			
		||||
    headers = HTTP::Headers.new
 | 
			
		||||
    REQUEST_HEADERS_WHITELIST.each do |header|
 | 
			
		||||
      if env.request.headers[header]?
 | 
			
		||||
        headers[header] = env.request.headers[header]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    client = make_client(URI.parse(host), region)
 | 
			
		||||
    response = HTTP::Client::Response.new(500)
 | 
			
		||||
    error = ""
 | 
			
		||||
    5.times do
 | 
			
		||||
      begin
 | 
			
		||||
        response = client.head(url, headers)
 | 
			
		||||
 | 
			
		||||
        if response.headers["Location"]?
 | 
			
		||||
          location = URI.parse(response.headers["Location"])
 | 
			
		||||
          env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
 | 
			
		||||
          new_host = "#{location.scheme}://#{location.host}"
 | 
			
		||||
          if new_host != host
 | 
			
		||||
            host = new_host
 | 
			
		||||
            client.close
 | 
			
		||||
            client = make_client(URI.parse(new_host), region)
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}"
 | 
			
		||||
        else
 | 
			
		||||
          break
 | 
			
		||||
        end
 | 
			
		||||
      rescue Socket::Addrinfo::Error
 | 
			
		||||
        if !mns.empty?
 | 
			
		||||
          mn = mns.pop
 | 
			
		||||
        end
 | 
			
		||||
        fvip = "3"
 | 
			
		||||
 | 
			
		||||
        host = "https://r#{fvip}---#{mn}.googlevideo.com"
 | 
			
		||||
        client = make_client(URI.parse(host), region)
 | 
			
		||||
      rescue ex
 | 
			
		||||
        error = ex.message
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if response.status_code >= 400
 | 
			
		||||
      env.response.content_type = "text/plain"
 | 
			
		||||
      haltf env, response.status_code
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if url.includes? "&file=seg.ts"
 | 
			
		||||
      if CONFIG.disabled?("livestreams")
 | 
			
		||||
        return error_template(403, "Administrator has disabled this endpoint.")
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      begin
 | 
			
		||||
        client.get(url, headers) do |response|
 | 
			
		||||
          response.headers.each do |key, value|
 | 
			
		||||
            if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
 | 
			
		||||
              env.response.headers[key] = value
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
 | 
			
		||||
          if location = response.headers["Location"]?
 | 
			
		||||
            location = URI.parse(location)
 | 
			
		||||
            location = "#{location.request_target}&host=#{location.host}"
 | 
			
		||||
 | 
			
		||||
            if region
 | 
			
		||||
              location += "®ion=#{region}"
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            return env.redirect location
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          IO.copy(response.body_io, env.response)
 | 
			
		||||
        end
 | 
			
		||||
      rescue ex
 | 
			
		||||
      end
 | 
			
		||||
    else
 | 
			
		||||
      if query_params["title"]? && CONFIG.disabled?("downloads") ||
 | 
			
		||||
         CONFIG.disabled?("dash")
 | 
			
		||||
        return error_template(403, "Administrator has disabled this endpoint.")
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      content_length = nil
 | 
			
		||||
      first_chunk = true
 | 
			
		||||
      range_start, range_end = parse_range(env.request.headers["Range"]?)
 | 
			
		||||
      chunk_start = range_start
 | 
			
		||||
      chunk_end = range_end
 | 
			
		||||
 | 
			
		||||
      if !chunk_end || chunk_end - chunk_start > HTTP_CHUNK_SIZE
 | 
			
		||||
        chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # TODO: Record bytes written so we can restart after a chunk fails
 | 
			
		||||
      while true
 | 
			
		||||
        if !range_end && content_length
 | 
			
		||||
          range_end = content_length
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if range_end && chunk_start > range_end
 | 
			
		||||
          break
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if range_end && chunk_end > range_end
 | 
			
		||||
          chunk_end = range_end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}"
 | 
			
		||||
 | 
			
		||||
        begin
 | 
			
		||||
          client.get(url, headers) do |response|
 | 
			
		||||
            if first_chunk
 | 
			
		||||
              if !env.request.headers["Range"]? && response.status_code == 206
 | 
			
		||||
                env.response.status_code = 200
 | 
			
		||||
              else
 | 
			
		||||
                env.response.status_code = response.status_code
 | 
			
		||||
              end
 | 
			
		||||
 | 
			
		||||
              response.headers.each do |key, value|
 | 
			
		||||
                if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range"
 | 
			
		||||
                  env.response.headers[key] = value
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
 | 
			
		||||
              env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
 | 
			
		||||
              if location = response.headers["Location"]?
 | 
			
		||||
                location = URI.parse(location)
 | 
			
		||||
                location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}"
 | 
			
		||||
 | 
			
		||||
                env.redirect location
 | 
			
		||||
                break
 | 
			
		||||
              end
 | 
			
		||||
 | 
			
		||||
              if title = query_params["title"]?
 | 
			
		||||
                # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/
 | 
			
		||||
                env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}"
 | 
			
		||||
              end
 | 
			
		||||
 | 
			
		||||
              if !response.headers.includes_word?("Transfer-Encoding", "chunked")
 | 
			
		||||
                content_length = response.headers["Content-Range"].split("/")[-1].to_i64
 | 
			
		||||
                if env.request.headers["Range"]?
 | 
			
		||||
                  env.response.headers["Content-Range"] = "bytes #{range_start}-#{range_end || (content_length - 1)}/#{content_length}"
 | 
			
		||||
                  env.response.content_length = ((range_end.try &.+ 1) || content_length) - range_start
 | 
			
		||||
                else
 | 
			
		||||
                  env.response.content_length = content_length
 | 
			
		||||
                end
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            proxy_file(response, env)
 | 
			
		||||
          end
 | 
			
		||||
        rescue ex
 | 
			
		||||
          if ex.message != "Error reading socket: Connection reset by peer"
 | 
			
		||||
            break
 | 
			
		||||
          else
 | 
			
		||||
            client.close
 | 
			
		||||
            client = make_client(URI.parse(host), region)
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        chunk_start = chunk_end + 1
 | 
			
		||||
        chunk_end += HTTP_CHUNK_SIZE
 | 
			
		||||
        first_chunk = false
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
    client.close
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # /videoplayback/*
 | 
			
		||||
  def self.get_video_playback_greedy(env)
 | 
			
		||||
    path = env.request.path
 | 
			
		||||
 | 
			
		||||
    path = path.lchop("/videoplayback/")
 | 
			
		||||
    path = path.rchop("/")
 | 
			
		||||
 | 
			
		||||
    path = path.gsub(/mime\/\w+\/\w+/) do |mimetype|
 | 
			
		||||
      mimetype = mimetype.split("/")
 | 
			
		||||
      mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    path = path.split("/")
 | 
			
		||||
 | 
			
		||||
    raw_params = {} of String => Array(String)
 | 
			
		||||
    path.each_slice(2) do |pair|
 | 
			
		||||
      key, value = pair
 | 
			
		||||
      value = URI.decode_www_form(value)
 | 
			
		||||
 | 
			
		||||
      if raw_params[key]?
 | 
			
		||||
        raw_params[key] << value
 | 
			
		||||
      else
 | 
			
		||||
        raw_params[key] = [value]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    query_params = HTTP::Params.new(raw_params)
 | 
			
		||||
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
    return env.redirect "/videoplayback?#{query_params}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # /videoplayback/* && /videoplayback/*
 | 
			
		||||
  def self.options_video_playback(env)
 | 
			
		||||
    env.response.headers.delete("Content-Type")
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
 | 
			
		||||
    env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # /latest_version
 | 
			
		||||
  #
 | 
			
		||||
  # 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 env.params.query["download_widget"]?
 | 
			
		||||
      download_widget = JSON.parse(env.params.query["download_widget"])
 | 
			
		||||
 | 
			
		||||
      id = download_widget["id"].as_s
 | 
			
		||||
      title = download_widget["title"].as_s
 | 
			
		||||
 | 
			
		||||
      if label = download_widget["label"]?
 | 
			
		||||
        return env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}"
 | 
			
		||||
      else
 | 
			
		||||
        itag = download_widget["itag"].as_s.to_i
 | 
			
		||||
        local = "true"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    id ||= env.params.query["id"]?
 | 
			
		||||
    itag ||= env.params.query["itag"]?.try &.to_i
 | 
			
		||||
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
 | 
			
		||||
    local ||= env.params.query["local"]?
 | 
			
		||||
    local ||= "false"
 | 
			
		||||
    local = local == "true"
 | 
			
		||||
 | 
			
		||||
    if !id || !itag
 | 
			
		||||
      haltf env, status_code: 400, response: "TESTING"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    video = get_video(id, PG_DB, region: region)
 | 
			
		||||
 | 
			
		||||
    fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag }
 | 
			
		||||
    url = fmt.try &.["url"]?.try &.as_s
 | 
			
		||||
 | 
			
		||||
    if !url
 | 
			
		||||
      haltf env, status_code: 404
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    url = URI.parse(url).request_target.not_nil! if local
 | 
			
		||||
    url = "#{url}&title=#{title}" if title
 | 
			
		||||
 | 
			
		||||
    return env.redirect url
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -9,3 +9,92 @@ module Invidious::Routing
 | 
			
		||||
 | 
			
		||||
  {% end %}
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
macro define_v1_api_routes
 | 
			
		||||
  {{namespace = Invidious::Routes::API::V1}}
 | 
			
		||||
  # Videos
 | 
			
		||||
  Invidious::Routing.get "/api/v1/videos/:id", {{namespace}}::Videos, :videos
 | 
			
		||||
  Invidious::Routing.get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards
 | 
			
		||||
  Invidious::Routing.get "/api/v1/captions/:id", {{namespace}}::Videos, :captions
 | 
			
		||||
  Invidious::Routing.get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations
 | 
			
		||||
  Invidious::Routing.get "/api/v1/comments/:id", {{namespace}}::Videos, :comments
 | 
			
		||||
 | 
			
		||||
  # Feeds
 | 
			
		||||
  Invidious::Routing.get "/api/v1/trending", {{namespace}}::Feeds, :trending
 | 
			
		||||
  Invidious::Routing.get "/api/v1/popular", {{namespace}}::Feeds, :popular
 | 
			
		||||
 | 
			
		||||
  # Channels
 | 
			
		||||
  Invidious::Routing.get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home
 | 
			
		||||
  {% for route in {"videos", "latest", "playlists", "community", "search"} %}
 | 
			
		||||
    Invidious::Routing.get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}}
 | 
			
		||||
    Invidious::Routing.get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}}
 | 
			
		||||
  {% end %}
 | 
			
		||||
 | 
			
		||||
  # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community
 | 
			
		||||
  Invidious::Routing.get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect
 | 
			
		||||
  Invidious::Routing.get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  # Search
 | 
			
		||||
  Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search
 | 
			
		||||
  Invidious::Routing.get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions
 | 
			
		||||
 | 
			
		||||
  # Authenticated
 | 
			
		||||
 | 
			
		||||
  # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr
 | 
			
		||||
  #
 | 
			
		||||
  # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
 | 
			
		||||
  # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences
 | 
			
		||||
  Invidious::Routing.post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions
 | 
			
		||||
  Invidious::Routing.post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel
 | 
			
		||||
  Invidious::Routing.delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists
 | 
			
		||||
  Invidious::Routing.post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist
 | 
			
		||||
  Invidious::Routing.patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute
 | 
			
		||||
  Invidious::Routing.delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist
 | 
			
		||||
  Invidious::Routing.delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens
 | 
			
		||||
  Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token
 | 
			
		||||
  Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token
 | 
			
		||||
 | 
			
		||||
  # Misc
 | 
			
		||||
  Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats
 | 
			
		||||
  Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
 | 
			
		||||
  Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
 | 
			
		||||
  Invidious::Routing.get "/api/v1//mixes/:rdid", {{namespace}}::Misc, :mixes
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
macro define_api_manifest_routes
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::API::Manifest, :get_dash_video_id
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :get_dash_video_playback
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :get_dash_video_playback_greedy
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :options_dash_video_playback
 | 
			
		||||
  Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :options_dash_video_playback
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::API::Manifest, :get_hls_playlist
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::API::Manifest, :get_hls_variant
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
macro define_video_playback_routes
 | 
			
		||||
  Invidious::Routing.get "/videoplayback", Invidious::Routes::VideoPlayback, :get_video_playback
 | 
			
		||||
  Invidious::Routing.get "/videoplayback/*", Invidious::Routes::VideoPlayback, :get_video_playback_greedy
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback
 | 
			
		||||
  Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user