mirror of
https://github.com/iv-org/invidious.git
synced 2024-12-23 22:39:31 -05:00
Extract API routes (#2271)
* Extract API routes from invidious.cr * Remove deprecated APIs - insights - top feed
This commit is contained in:
commit
4fcd0964cd
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
|
if !author
|
||||||
raise InfoException.new("Deleted or invalid channel")
|
raise InfoException.new("Deleted or invalid channel")
|
||||||
end
|
end
|
||||||
|
|
||||||
author = author.content
|
author = author.content
|
||||||
|
|
||||||
# Auto-generated channels
|
# 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 %}
|
||||||
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
|
||||||
|
Loading…
Reference in New Issue
Block a user