summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/invidious.cr529
-rw-r--r--src/invidious/routes/api/v1/authenticated.cr412
-rw-r--r--src/invidious/routes/api/v1/misc.cr123
-rw-r--r--src/invidious/routes/api/v1/routes.cr36
4 files changed, 573 insertions, 527 deletions
diff --git a/src/invidious.cr b/src/invidious.cr
index 85852b9a..1962ae65 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -1639,132 +1639,12 @@ end
end
end
-# API Endpoints
-
-{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route|
- get route do |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"
- next env.redirect "/api/v1/mixes/#{plid}"
- end
-
- begin
- playlist = get_playlist(PG_DB, plid, locale)
- rescue ex : InfoException
- next error_json(404, ex)
- rescue ex
- next 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
- next 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
-end
-
-get "/api/v1/mixes/:rdid" do |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
- next 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
-
# Authenticated endpoints
+# The notification APIs can't be extracted yet
+# due to the requirement of the `connection_channel`
+# used by the `NotificationJob`
+
get "/api/v1/auth/notifications" do |env|
env.response.content_type = "text/event-stream"
@@ -1783,407 +1663,6 @@ post "/api/v1/auth/notifications" do |env|
create_notification_stream(env, topics, connection_channel)
end
-get "/api/v1/auth/preferences" do |env|
- env.response.content_type = "application/json"
- user = env.get("user").as(User)
- user.preferences.to_json
-end
-
-post "/api/v1/auth/preferences" do |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
-
-get "/api/v1/auth/feed" do |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
-
-get "/api/v1/auth/subscriptions" do |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
-
-post "/api/v1/auth/subscriptions/:ucid" do |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
-
-delete "/api/v1/auth/subscriptions/:ucid" do |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
-
-get "/api/v1/auth/playlists" do |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
-
-post "/api/v1/auth/playlists" do |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
- next error_json(400, "Invalid title.")
- end
-
- privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) }
- if !privacy
- next error_json(400, "Invalid privacy setting.")
- end
-
- if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100
- next 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
-
-patch "/api/v1/auth/playlists/:plid" do |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?
- next error_json(404, "Playlist does not exist.")
- end
-
- if playlist.author != user.email
- next 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
-
-delete "/api/v1/auth/playlists/:plid" do |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?
- next error_json(404, "Playlist does not exist.")
- end
-
- if playlist.author != user.email
- next 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
-
-post "/api/v1/auth/playlists/:plid/videos" do |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?
- next error_json(404, "Playlist does not exist.")
- end
-
- if playlist.author != user.email
- next error_json(403, "Invalid user")
- end
-
- if playlist.index.size >= 500
- next error_json(400, "Playlist cannot have more than 500 videos")
- end
-
- video_id = env.params.json["videoId"].try &.as(String)
- if !video_id
- next error_json(403, "Invalid videoId")
- end
-
- begin
- video = get_video(video_id, PG_DB)
- rescue ex
- next 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
-
-delete "/api/v1/auth/playlists/:plid/videos/:index" do |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?
- next error_json(404, "Playlist does not exist.")
- end
-
- if playlist.author != user.email
- next error_json(403, "Invalid user")
- end
-
- if !playlist.index.includes? index
- next 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
-
-# patch "/api/v1/auth/playlists/:plid/videos/:index" do |env|
-# TODO: Playlist stub
-# end
-
-get "/api/v1/auth/tokens" do |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
-
-post "/api/v1/auth/tokens/register" do |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
- next 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)
- next 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
-
-post "/api/v1/auth/tokens/unregister" do |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
- next error_json(400, "Cannot revoke session #{session}")
- end
-
- env.response.status_code = 204
-end
-
get "/ggpht/*" do |env|
url = env.request.path.lchop("/ggpht")
diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr
new file mode 100644
index 00000000..4201f26d
--- /dev/null
+++ b/src/invidious/routes/api/v1/authenticated.cr
@@ -0,0 +1,412 @@
+module Invidious::Routes::APIv1::Authenticated
+ # 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
diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr
index c7c32ca9..afb61fc1 100644
--- a/src/invidious/routes/api/v1/misc.cr
+++ b/src/invidious/routes/api/v1/misc.cr
@@ -10,4 +10,127 @@ module Invidious::Routes::APIv1::Misc
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
diff --git a/src/invidious/routes/api/v1/routes.cr b/src/invidious/routes/api/v1/routes.cr
index 4f06bdb4..9e3c03be 100644
--- a/src/invidious/routes/api/v1/routes.cr
+++ b/src/invidious/routes/api/v1/routes.cr
@@ -1,8 +1,6 @@
# There is far too many API routes to define in invidious.cr
# so we'll just do it here instead with a macro.
macro define_v1_api_routes(base_url = "/api/v1")
- Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1::Misc, :stats
-
# Videos
Invidious::Routing.get "#{{{base_url}}}/videos/:id", Invidious::Routes::APIv1::Videos, :videos
Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1::Videos, :storyboards
@@ -32,4 +30,38 @@ macro define_v1_api_routes(base_url = "/api/v1")
# Search
Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1::Search, :search
Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1::Search, :search_suggestions
+
+ # Authenticated
+ # Invidious::Routing.get "#{{{base_url}}}/auth/notifications", Invidious::Routes::APIv1::Authenticated, :notifications
+ # Invidious::Routing.post "#{{{base_url}}}/auth/notifications", Invidious::Routes::APIv1::Authenticated, :notifications
+
+ Invidious::Routing.get "#{{{base_url}}}/auth/preferences", Invidious::Routes::APIv1::Authenticated, :get_preferences
+ Invidious::Routing.post "#{{{base_url}}}/auth/preferences", Invidious::Routes::APIv1::Authenticated, :set_preferences
+
+ Invidious::Routing.get "#{{{base_url}}}/auth/feed", Invidious::Routes::APIv1::Authenticated, :feed
+
+ Invidious::Routing.get "#{{{base_url}}}/auth/subscriptions", Invidious::Routes::APIv1::Authenticated, :get_subscriptions
+ Invidious::Routing.post "#{{{base_url}}}/auth/subscriptions/:ucid", Invidious::Routes::APIv1::Authenticated, :subscribe_channel
+ Invidious::Routing.delete "#{{{base_url}}}/auth/subscriptions/:ucid", Invidious::Routes::APIv1::Authenticated, :unsubscribe_channel
+
+
+ Invidious::Routing.get "#{{{base_url}}}/auth/playlists", Invidious::Routes::APIv1::Authenticated, :list_playlists
+ Invidious::Routing.post "#{{{base_url}}}/auth/playlists", Invidious::Routes::APIv1::Authenticated, :create_playlist
+ Invidious::Routing.patch "#{{{base_url}}}/auth/playlists/:ucid", Invidious::Routes::APIv1::Authenticated, :update_playlist_attribute
+ Invidious::Routing.delete "#{{{base_url}}}/auth/playlists/:ucid", Invidious::Routes::APIv1::Authenticated, :delete_playlist
+
+
+ Invidious::Routing.post "#{{{base_url}}}/auth/playlists/:ucid/videos", Invidious::Routes::APIv1::Authenticated, :insert_video_into_playlist
+ Invidious::Routing.delete "#{{{base_url}}}/auth/playlists/:ucid/videos/:index", Invidious::Routes::APIv1::Authenticated, :delete_video_in_playlist
+
+ Invidious::Routing.get "#{{{base_url}}}/auth/tokens", Invidious::Routes::APIv1::Authenticated, :get_tokens
+ Invidious::Routing.post "#{{{base_url}}}/auth/tokens/register", Invidious::Routes::APIv1::Authenticated, :register_token
+ Invidious::Routing.post "#{{{base_url}}}/auth/tokens/unregister", Invidious::Routes::APIv1::Authenticated, :unregister_token
+
+ # Misc
+ Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1::Misc, :stats
+ Invidious::Routing.get "#{{{base_url}}}/playlists/:plid", Invidious::Routes::APIv1::Misc, :get_playlist
+ Invidious::Routing.get "#{{{base_url}}}/auth/playlists/:plid", Invidious::Routes::APIv1::Misc, :get_playlist
+ Invidious::Routing.get "#{{{base_url}}}//mixes/:rdid", Invidious::Routes::APIv1::Misc, :mixes
+
end