mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-11-04 06:31:57 +00:00 
			
		
		
		
	Extract API routes from invidious.cr (2/?)
- Video playback endpoints - Search feed api - Video info api
This commit is contained in:
		
							
								
								
									
										575
									
								
								src/invidious.cr
									
									
									
									
									
								
							
							
						
						
									
										575
									
								
								src/invidious.cr
									
									
									
									
									
								
							@@ -364,6 +364,8 @@ Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :up
 | 
			
		||||
Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
 | 
			
		||||
 | 
			
		||||
define_v1_api_routes()
 | 
			
		||||
define_api_manifest_routes()
 | 
			
		||||
define_video_playback_routes()
 | 
			
		||||
 | 
			
		||||
# Users
 | 
			
		||||
 | 
			
		||||
@@ -1639,69 +1641,6 @@ end
 | 
			
		||||
 | 
			
		||||
# API Endpoints
 | 
			
		||||
 | 
			
		||||
get "/api/v1/videos/:id" do |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)
 | 
			
		||||
    next error_json(302, "Video is unavailable", {"videoId" => ex.video_id})
 | 
			
		||||
  rescue ex
 | 
			
		||||
    next error_json(500, ex)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  video.to_json(locale)
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/api/v1/search" do |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
 | 
			
		||||
    next 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
 | 
			
		||||
 | 
			
		||||
{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route|
 | 
			
		||||
  get route do |env|
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
@@ -2245,516 +2184,6 @@ post "/api/v1/auth/tokens/unregister" do |env|
 | 
			
		||||
  env.response.status_code = 204
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/api/manifest/dash/id/videoplayback" do |env|
 | 
			
		||||
  env.response.headers.delete("Content-Type")
 | 
			
		||||
  env.response.headers["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
  env.redirect "/videoplayback?#{env.params.query}"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/api/manifest/dash/id/videoplayback/*" do |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
 | 
			
		||||
 | 
			
		||||
get "/api/manifest/dash/id/:id" do |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
 | 
			
		||||
    next env.redirect env.request.resource.gsub(id, ex.video_id)
 | 
			
		||||
  rescue ex
 | 
			
		||||
    env.response.status_code = 403
 | 
			
		||||
    next
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
    next 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
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/api/manifest/hls_variant/*" do |env|
 | 
			
		||||
  response = YT_POOL.client &.get(env.request.path)
 | 
			
		||||
 | 
			
		||||
  if response.status_code != 200
 | 
			
		||||
    env.response.status_code = response.status_code
 | 
			
		||||
    next
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
get "/api/manifest/hls_playlist/*" do |env|
 | 
			
		||||
  response = YT_POOL.client &.get(env.request.path)
 | 
			
		||||
 | 
			
		||||
  if response.status_code != 200
 | 
			
		||||
    env.response.status_code = response.status_code
 | 
			
		||||
    next
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
# YouTube /videoplayback links expire after 6 hours,
 | 
			
		||||
# so we have a mechanism here to redirect to the latest version
 | 
			
		||||
get "/latest_version" do |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"]?
 | 
			
		||||
      env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}"
 | 
			
		||||
      next
 | 
			
		||||
    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
 | 
			
		||||
    env.response.status_code = 400
 | 
			
		||||
    next
 | 
			
		||||
  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
 | 
			
		||||
    env.response.status_code = 404
 | 
			
		||||
    next
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  url = URI.parse(url).request_target.not_nil! if local
 | 
			
		||||
  url = "#{url}&title=#{title}" if title
 | 
			
		||||
 | 
			
		||||
  env.redirect url
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
options "/videoplayback" do |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
 | 
			
		||||
 | 
			
		||||
options "/videoplayback/*" do |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
 | 
			
		||||
 | 
			
		||||
options "/api/manifest/dash/id/videoplayback" do |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
 | 
			
		||||
 | 
			
		||||
options "/api/manifest/dash/id/videoplayback/*" do |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
 | 
			
		||||
 | 
			
		||||
get "/videoplayback/*" do |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"] = "*"
 | 
			
		||||
  env.redirect "/videoplayback?#{query_params}"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/videoplayback" do |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.status_code = response.status_code
 | 
			
		||||
    env.response.content_type = "text/plain"
 | 
			
		||||
    next error
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if url.includes? "&file=seg.ts"
 | 
			
		||||
    if CONFIG.disabled?("livestreams")
 | 
			
		||||
      next 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
 | 
			
		||||
 | 
			
		||||
          next 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")
 | 
			
		||||
      next 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
 | 
			
		||||
 | 
			
		||||
get "/ggpht/*" do |env|
 | 
			
		||||
  url = env.request.path.lchop("/ggpht")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -56,3 +56,12 @@ end
 | 
			
		||||
macro rendered(filename)
 | 
			
		||||
  render "src/invidious/views/#{{{filename}}}.ecr"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
# Similar to Kemals halt method but works in a
 | 
			
		||||
# method.
 | 
			
		||||
macro haltf(env, status_code = 200, response = "")
 | 
			
		||||
  {{env}}.response.status_code = {{status_code}}
 | 
			
		||||
  {{env}}.response.print {{response}}
 | 
			
		||||
  {{env}}.response.close
 | 
			
		||||
  return
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										237
									
								
								src/invidious/routes/api/manifest.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										237
									
								
								src/invidious/routes/api/manifest.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,237 @@
 | 
			
		||||
module Invidious::Routes::APIManifest
 | 
			
		||||
  # /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
 | 
			
		||||
 | 
			
		||||
macro define_api_manifest_routes
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::APIManifest, :get_dash_video_id
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::APIManifest, :get_dash_video_playback
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::APIManifest, :get_dash_video_playback_greedy
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::APIManifest, :options_dash_video_playback
 | 
			
		||||
  Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::APIManifest, :options_dash_video_playback
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::APIManifest, :get_hls_playlist
 | 
			
		||||
  Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::APIManifest, :get_hls_variant
 | 
			
		||||
end
 | 
			
		||||
@@ -78,7 +78,6 @@ module Invidious::Routes::APIv1
 | 
			
		||||
        json.field "subCount", channel.sub_count
 | 
			
		||||
        json.field "totalViews", channel.total_views
 | 
			
		||||
        json.field "joined", channel.joined.to_unix
 | 
			
		||||
        json.field "paid", channel.paid
 | 
			
		||||
 | 
			
		||||
        json.field "autoGenerated", channel.auto_generated
 | 
			
		||||
        json.field "isFamilyFriendly", channel.is_family_friendly
 | 
			
		||||
@@ -3,17 +3,19 @@
 | 
			
		||||
macro define_v1_api_routes(base_url = "/api/v1")
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1, :stats
 | 
			
		||||
 | 
			
		||||
  # Widgets
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1, :storyboards
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::APIv1, :captions
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::APIv1, :annotations
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1, :search_suggestions
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::APIv1, :comments
 | 
			
		||||
 | 
			
		||||
  # Feeds
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::APIv1, :trending
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::APIv1, :popular
 | 
			
		||||
 | 
			
		||||
  # Channels
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::APIv1, :home
 | 
			
		||||
 | 
			
		||||
  {% for route in {
 | 
			
		||||
                    {"home", "home"},
 | 
			
		||||
                    {"videos", "videos"},
 | 
			
		||||
@@ -25,6 +27,11 @@ macro define_v1_api_routes(base_url = "/api/v1")
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::APIv1, :{{route[1]}}
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::APIv1, :{{route[1]}}
 | 
			
		||||
 | 
			
		||||
  {% end %}
 | 
			
		||||
 | 
			
		||||
  # Search
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1, :search
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/videos/:id", Invidious::Routes::APIv1, :videos
 | 
			
		||||
  Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1, :search
 | 
			
		||||
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										101
									
								
								src/invidious/routes/api/v1/search.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/invidious/routes/api/v1/search.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
module Invidious::Routes::APIv1
 | 
			
		||||
  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.channel_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
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
@@ -1,10 +1,5 @@
 | 
			
		||||
module Invidious::Routes::APIv1
 | 
			
		||||
  # 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)
 | 
			
		||||
  def self.videos(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
@@ -18,66 +13,10 @@ module Invidious::Routes::APIv1
 | 
			
		||||
      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
 | 
			
		||||
      env.response.status_code = 500
 | 
			
		||||
      return
 | 
			
		||||
      return error_json(500, ex)
 | 
			
		||||
    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?
 | 
			
		||||
      env.response.status_code = 404
 | 
			
		||||
      return
 | 
			
		||||
    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
 | 
			
		||||
    video.to_json(locale)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.captions(env)
 | 
			
		||||
@@ -206,6 +145,87 @@ module Invidious::Routes::APIv1
 | 
			
		||||
    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
 | 
			
		||||
      env.response.status_code = 500
 | 
			
		||||
      return
 | 
			
		||||
    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?
 | 
			
		||||
      env.response.status_code = 404
 | 
			
		||||
      return
 | 
			
		||||
    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]?
 | 
			
		||||
 | 
			
		||||
@@ -280,40 +300,6 @@ module Invidious::Routes::APIv1
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
  def self.comments(env)
 | 
			
		||||
    locale = LOCALES[env.get("preferences").as(Preferences).locale]?
 | 
			
		||||
    region = env.params.query["region"]?
 | 
			
		||||
@@ -1,24 +0,0 @@
 | 
			
		||||
module Invidious::Routes::APIv1
 | 
			
		||||
  def self.channel_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
 | 
			
		||||
end
 | 
			
		||||
@@ -1,2 +0,0 @@
 | 
			
		||||
module Invidious::Routes::APIv1
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										290
									
								
								src/invidious/routes/video_playback.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										290
									
								
								src/invidious/routes/video_playback.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,290 @@
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
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