diff --git a/assets/js/compilation_widget.js b/assets/js/compilation_widget.js
new file mode 100644
index 000000000..7e8e6356e
--- /dev/null
+++ b/assets/js/compilation_widget.js
@@ -0,0 +1,48 @@
+'use strict';
+var compilation_data = JSON.parse(document.getElementById('compilation_data').textContent);
+var payload = 'csrf_token=' + compilation_data.csrf_token;
+
+function add_compilation_video(target) {
+ var select = target.parentNode.children[0].children[1];
+ var option = select.children[select.selectedIndex];
+
+ var url = '/compilation_ajax?action_add_video=1&redirect=false' +
+ '&video_id=' + target.getAttribute('data-id') +
+ '&compilation_id=' + option.getAttribute('data-compid');
+
+ helpers.xhr('POST', url, {payload: payload}, {
+ on200: function (response) {
+ option.textContent = '✓' + option.textContent;
+ }
+ });
+}
+
+function add_compilation_item(target) {
+ var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
+ tile.style.display = 'none';
+
+ var url = '/compilation_ajax?action_add_video=1&redirect=false' +
+ '&video_id=' + target.getAttribute('data-id') +
+ '&compilation_id=' + target.getAttribute('data-compid');
+
+ helpers.xhr('POST', url, {payload: payload}, {
+ onNon200: function (xhr) {
+ tile.style.display = '';
+ }
+ });
+}
+
+function remove_compilation_item(target) {
+ var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
+ tile.style.display = 'none';
+
+ var url = '/compilation_ajax?action_remove_video=1&redirect=false' +
+ '&set_video_id=' + target.getAttribute('data-index') +
+ '&compilation_id=' + target.getAttribute('data-compid');
+
+ helpers.xhr('POST', url, {payload: payload}, {
+ onNon200: function (xhr) {
+ tile.style.display = '';
+ }
+ });
+}
diff --git a/config/config.example.yml b/config/config.example.yml
index e925a5e3f..ff568a5b9 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -483,6 +483,14 @@ hmac_key: "CHANGE_ME!!"
##
#playlist_length_limit: 500
+##
+## Maximum custom compilation length limit.
+##
+## Accepted values: Integer
+## Default: 500
+##
+#compilation_length_limit: 500
+
#########################################
#
# Default user preferences
diff --git a/locales/en-US.json b/locales/en-US.json
index e206bc0e5..3905de6ec 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -177,6 +177,7 @@
"Create compilation": "Create compilation",
"Title": "Title",
"Playlist privacy": "Playlist privacy",
+ "Compilation privacy": "Compilation privacy",
"Editing playlist `x`": "Editing playlist `x`",
"playlist_button_add_items": "Add videos",
"Show more": "Show more",
diff --git a/src/invidious/routes/compilations.cr b/src/invidious/routes/compilations.cr
new file mode 100644
index 000000000..0189b60bc
--- /dev/null
+++ b/src/invidious/routes/compilations.cr
@@ -0,0 +1,410 @@
+{% skip_file if flag?(:api_only) %}
+
+module Invidious::Routes::Compilations
+ def self.new(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+ csrf_token = generate_response(sid, {":create_compilation"}, HMAC_KEY)
+
+ templated "create_compilation"
+ end
+
+ def self.create(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
+ rescue ex
+ return error_template(400, ex)
+ end
+
+ title = env.params.body["title"]?.try &.as(String)
+ if !title || title.empty?
+ return error_template(400, "Title cannot be empty.")
+ end
+
+ privacy = CompilationPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "")
+ if !privacy
+ return error_template(400, "Invalid privacy setting.")
+ end
+
+ if Invidious::Database::Compilations.count_owned_by(user.email) >= 100
+ return error_template(400, "User cannot have more than 100 compilations.")
+ end
+
+ compilation = create_compilation(title, privacy, user)
+
+ env.redirect "/compilation?list=#{compilation.id}"
+ end
+
+ def self.delete_page(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+
+ compid = env.params.query["list"]?
+ if !compid || compid.empty?
+ return error_template(400, "A compilation ID is required")
+ end
+
+ compilation = Invidious::Database::Compilations.select(id: compid)
+ if !compilation || compilation.author != user.email
+ return env.redirect referer
+ end
+
+ csrf_token = generate_response(sid, {":delete_compilation"}, HMAC_KEY)
+
+ templated "delete_compilation"
+ end
+
+ def self.delete(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ compid = env.params.query["list"]?
+ return env.redirect referer if compid.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
+ rescue ex
+ return error_template(400, ex)
+ end
+
+ compilation = Invidious::Database::Compilations.select(id: compid)
+ if !compilation || compilation.author != user.email
+ return env.redirect referer
+ end
+
+ Invidious::Database::Compilations.delete(compid)
+
+ env.redirect "/feed/compilations"
+ end
+
+ def self.edit(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+
+ compid = env.params.query["list"]?
+ if !compid || !compid.starts_with?("IV")
+ return env.redirect referer
+ end
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ compilation = Invidious::Database::Compilations.select(id: compid)
+ if !compilation || compilation.author != user.email
+ return env.redirect referer
+ end
+
+ begin
+ videos = get_compilation_videos(compilation, offset: (page - 1) * 100)
+ rescue ex
+ videos = [] of CompilationVideo
+ end
+
+ csrf_token = generate_response(sid, {":edit_compilation"}, HMAC_KEY)
+
+ templated "edit_compilation"
+ end
+
+ def self.update(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ compid = env.params.query["list"]?
+ return env.redirect referer if compid.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
+ rescue ex
+ return error_template(400, ex)
+ end
+
+ compilation = Invidious::Database::Compilations.select(id: compid)
+ if !compilation || compilation.author != user.email
+ return env.redirect referer
+ end
+
+ title = env.params.body["title"]?.try &.delete("<>") || ""
+ privacy = CompilationPrivacy.parse(env.params.body["privacy"]? || "Unlisted")
+ description = env.params.body["description"]?.try &.delete("\r") || ""
+
+ if title != compilation.title ||
+ compilation != compilation.privacy ||
+ description != compilation.description
+ updated = Time.utc
+ else
+ updated = compilation.updated
+ end
+
+ Invidious::Database::Compilations.update(compid, title, privacy, description, updated)
+
+ env.redirect "/compilation?list=#{compid}"
+ end
+
+ def self.add_compilation_items_page(env)
+ prefs = env.get("preferences").as(Preferences)
+ locale = prefs.locale
+
+ region = env.params.query["region"]? || prefs.region
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+ sid = sid.as(String)
+
+ compid = env.params.query["list"]?
+ if !compid || !compid.starts_with?("IV")
+ return env.redirect referer
+ end
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ compilation = Invidious::Database::Compilations.select(id: compid)
+ if !compilation || compilation.author != user.email
+ return env.redirect referer
+ end
+
+ begin
+ query = Invidious::Search::Query.new(env.params.query, :compilation, region)
+ videos = query.process.select(SearchVideo).map(&.as(SearchVideo))
+ rescue ex
+ videos = [] of SearchVideo
+ end
+
+ env.set "add_compilation_items", compid
+ templated "add_compilation_items"
+ end
+
+ def self.compilation_ajax(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ sid = env.get? "sid"
+ referer = get_referer(env, "/")
+
+ redirect = env.params.query["redirect"]?
+ redirect ||= "true"
+ redirect = redirect == "true"
+ if !user
+ if redirect
+ return env.redirect referer
+ else
+ return error_json(403, "No such user")
+ end
+ end
+
+ user = user.as(User)
+ sid = sid.as(String)
+ token = env.params.body["csrf_token"]?
+
+ begin
+ validate_request(token, sid, env.request, HMAC_KEY, locale)
+ rescue ex
+ if redirect
+ return error_template(400, ex)
+ else
+ return error_json(400, ex)
+ end
+ end
+
+ if env.params.query["action_create_compilation"]?
+ action = "action_create_compilation"
+ elsif env.params.query["action_delete_compilation"]?
+ action = "action_delete_compilation"
+ elsif env.params.query["action_edit_compilation"]?
+ action = "action_edit_compilation"
+ elsif env.params.query["action_add_video"]?
+ action = "action_add_video"
+ video_id = env.params.query["video_id"]
+ elsif env.params.query["action_remove_video"]?
+ action = "action_remove_video"
+ elsif env.params.query["action_move_video_before"]?
+ action = "action_move_video_before"
+ else
+ return env.redirect referer
+ end
+
+ begin
+ compilation_id = env.params.query["compilation_id"]
+ compilation = get_compilation(compilation_id).as(InvidiousCompilation)
+ raise "Invalid user" if compilation.author != user.email
+ rescue ex : NotFoundException
+ return error_json(404, ex)
+ rescue ex
+ if redirect
+ return error_template(400, ex)
+ else
+ return error_json(400, ex)
+ end
+ end
+
+ email = user.email
+
+ case action
+ when "action_edit_compilation"
+ # TODO: Compilation stub
+ when "action_add_video"
+ if compilation.index.size >= CONFIG.compilation_length_limit
+ if redirect
+ return error_template(400, "Compilation cannot have more than #{CONFIG.compilation_length_limit} videos")
+ else
+ return error_json(400, "Compilation cannot have more than #{CONFIG.compilation_length_limit} videos")
+ end
+ end
+
+ video_id = env.params.query["video_id"]
+
+ begin
+ video = get_video(video_id)
+ rescue ex : NotFoundException
+ return error_json(404, ex)
+ rescue ex
+ if redirect
+ return error_template(500, ex)
+ else
+ return error_json(500, ex)
+ end
+ end
+
+ compilation_video = CompilationVideo.new({
+ title: video.title,
+ id: video.id,
+ author: video.author,
+ ucid: video.ucid,
+ length_seconds: video.length_seconds,
+ starting_timestamp_seconds: video.length_seconds,
+ ending_timestamp_seconds: video.length_seconds,
+ published: video.published,
+ compid: compilation_id,
+ live_now: video.live_now,
+ index: Random::Secure.rand(0_i64..Int64::MAX),
+ })
+
+ Invidious::Database::CompilationVideos.insert(compilation_video)
+ Invidious::Database::Compilations.update_video_added(compilation_id, compilation_video.index)
+ when "action_remove_video"
+ index = env.params.query["set_video_id"]
+ Invidious::Database::CompilationVideos.delete(index)
+ Invidious::Database::Compilations.update_video_removed(compilation_id, index)
+ when "action_move_video_before"
+ # TODO: Compilation stub
+ else
+ return error_json(400, "Unsupported action #{action}")
+ end
+
+ if redirect
+ env.redirect referer
+ else
+ env.response.content_type = "application/json"
+ "{}"
+ end
+ end
+
+ def self.show(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get?("user").try &.as(User)
+ referer = get_referer(env)
+
+ compid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
+ if !compid
+ return env.redirect "/"
+ end
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ if compid.starts_with? "RD"
+ return env.redirect "/mix?list=#{compid}"
+ end
+
+ begin
+ compilation = get_compilation(compid)
+ rescue ex : NotFoundException
+ return error_template(404, ex)
+ rescue ex
+ return error_template(500, ex)
+ end
+
+ page_count = (compilation.video_count / 200).to_i
+ page_count += 1 if (compilation.video_count % 200) > 0
+
+ if page > page_count
+ return env.redirect "/compilation?list=#{compid}&page=#{page_count}"
+ end
+
+ if compilation.privacy == CompilationPrivacy::Private && compilation.author != user.try &.email
+ return error_template(403, "This compilation is private.")
+ end
+
+ begin
+ videos = get_compilation_videos(compilation, offset: (page - 1) * 200)
+ rescue ex
+ return error_template(500, "Error encountered while retrieving compilation videos.
#{ex.message}")
+ end
+
+ if compilation.author == user.try &.email
+ env.set "remove_compilation_items", compid
+ end
+
+ templated "compilation"
+ end
+end
\ No newline at end of file
diff --git a/src/invidious/views/add_compilation_items.ecr b/src/invidious/views/add_compilation_items.ecr
new file mode 100644
index 000000000..28494d983
--- /dev/null
+++ b/src/invidious/views/add_compilation_items.ecr
@@ -0,0 +1,40 @@
+<% content_for "header" do %>
+