diff --git a/.gitignore b/.gitignore
index 7a26e1a6..f36ac1ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@
/invidious
/sentry
/config/config.yml
+.DS_Store
diff --git a/assets/css/default.css b/assets/css/default.css
index 2cedcf0c..65e6dd04 100644
--- a/assets/css/default.css
+++ b/assets/css/default.css
@@ -814,5 +814,67 @@ h1, h2, h3, h4, h5, p,
}
#download_widget {
- width: 100%;
+ width: 100%;
+}
+
+/*
+ * Compilations
+ */
+
+input.compilation-video-timestamp {
+ width: 50px;
+ height: 20px;
+}
+
+div.compilation-video-panel {
+ display:flex;
+ justify-content:flex-start;
+ width:calc(100% - 20px);
+ height:100px;
+ border:2px solid #ccc;
+ margin: 10px;
+ /*background: #d9d9d9;*/
+}
+
+div.compilation-order-swap-arrows {
+ display:flex;
+ flex-direction:column;
+ justify-content:space-between;
+}
+
+svg.compilation-video-swap-arrow {
+ border: solid black;
+ width:20px;
+ height:50%;
+ background-color: beige;
+ margin: 10px;
+}
+
+div.compilation-video-input-panel {
+ display:flex;
+ flex-direction:column;
+ min-width: 0;
+ margin: 10px;
+}
+
+div.compilation-video-title {
+ display:flex;
+ justify-content:flex-start;
+}
+
+span.compilation-video-title {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+div.compilation-video-timestamp-set {
+ display:flex;
+ justify-content: flex-start;
+ align-items: center;
+}
+
+div.compilation-video-thumbnail {
+ position: relative;
+ box-sizing: border-box;
}
diff --git a/assets/js/compilation_widget.js b/assets/js/compilation_widget.js
new file mode 100644
index 00000000..16ace89e
--- /dev/null
+++ b/assets/js/compilation_widget.js
@@ -0,0 +1,63 @@
+'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 = '';
+ }
+ });
+}
+
+function move_compilation_video_before(target) {
+ var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode;
+ tile.style.display = 'none';
+
+ var url = '/compilation_ajax?action_move_video_before=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/assets/js/embed.js b/assets/js/embed.js
index b11b5e5a..f7940345 100644
--- a/assets/js/embed.js
+++ b/assets/js/embed.js
@@ -1,6 +1,39 @@
'use strict';
var video_data = JSON.parse(document.getElementById('video_data').textContent);
+function get_compilation(compid) {
+ var compid_url;
+ compid_url = '/api/v1/compilations/' + compid +
+ '?index=' + video_data.index +
+ '&continuation' + video_data.id +
+ '&format=html&hl=' + video_data.preferences.locale;
+
+ helpers.xhr('GET', compid_url, {retries: 5, entity_name: 'compilation'}, {
+ on200: function (response) {
+ if (!response.nextVideo)
+ return;
+
+ player.on('ended', function () {
+ var url = new URL('https://example.com/embed/' + response.nextVideo);
+
+ url.searchParams.set('comp', compid);
+ if (!compid.startsWith('RD'))
+ url.searchParams.set('index', response.index);
+ if (video_data.params.autoplay || video_data.params.continue_autoplay)
+ url.searchParams.set('autoplay', '1');
+ if (video_data.params.listen !== video_data.preferences.listen)
+ url.searchParams.set('listen', video_data.params.listen);
+ if (video_data.params.speed !== video_data.preferences.speed)
+ url.searchParams.set('speed', video_data.params.speed);
+ if (video_data.params.local !== video_data.preferences.local)
+ url.searchParams.set('local', video_data.params.local);
+
+ location.assign(url.pathname + url.search);
+ });
+ }
+ });
+}
+
function get_playlist(plid) {
var plid_url;
if (plid.startsWith('RD')) {
@@ -43,6 +76,8 @@ function get_playlist(plid) {
addEventListener('load', function (e) {
if (video_data.plid) {
get_playlist(video_data.plid);
+ } else if (video_data.compid) {
+ get_compilation(video_data.compid)
} else if (video_data.video_series) {
player.on('ended', function () {
var url = new URL('https://example.com/embed/' + video_data.video_series.shift());
diff --git a/assets/js/handlers.js b/assets/js/handlers.js
index 67cd9081..c43c7768 100644
--- a/assets/js/handlers.js
+++ b/assets/js/handlers.js
@@ -60,12 +60,21 @@
document.querySelectorAll('[data-onclick="add_playlist_video"]').forEach(function (el) {
el.onclick = function () { add_playlist_video(el); };
});
+ document.querySelectorAll('[data-onclick="add_compilation_video"]').forEach(function (el) {
+ el.onclick = function () { add_compilation_video(el); };
+ });
document.querySelectorAll('[data-onclick="add_playlist_item"]').forEach(function (el) {
el.onclick = function () { add_playlist_item(el); };
});
+ document.querySelectorAll('[data-onclick="add_compilation_item"]').forEach(function (el) {
+ el.onclick = function () { add_compilation_item(el); };
+ });
document.querySelectorAll('[data-onclick="remove_playlist_item"]').forEach(function (el) {
el.onclick = function () { remove_playlist_item(el); };
});
+ document.querySelectorAll('[data-onclick="remove_compilation_item"]').forEach(function (el) {
+ el.onclick = function () { remove_compilation_item(el); };
+ });
document.querySelectorAll('[data-onclick="revoke_token"]').forEach(function (el) {
el.onclick = function () { revoke_token(el); };
});
diff --git a/assets/js/player.js b/assets/js/player.js
index f32c9b56..5d849590 100644
--- a/assets/js/player.js
+++ b/assets/js/player.js
@@ -260,8 +260,13 @@ if (video_data.params.video_start > 0 || video_data.params.video_end > 0) {
player.markers({
onMarkerReached: function (marker) {
- if (marker.text === 'End')
- player.loop() ? player.markers.prev('Start') : player.pause();
+ if (marker.text === 'End') {
+ if (video_data.ending_timestamp_seconds) {
+ player.currentTime(player.duration());
+ } else {
+ player.loop() ? player.markers.prev('Start') : player.pause();
+ }
+ }
},
markers: markers
});
diff --git a/assets/js/watch.js b/assets/js/watch.js
index d869d40d..ea14d74a 100644
--- a/assets/js/watch.js
+++ b/assets/js/watch.js
@@ -50,6 +50,59 @@ function continue_autoplay(event) {
}
}
+function get_compilation(compid) {
+ var compilation = document.getElementById('compilation');
+
+ compilation.innerHTML = spinnerHTMLwithHR;
+
+ var compid_url;
+ compid_url = '/api/v1/compilations/' + compid +
+ '?index=' + video_data.index +
+ '&continuation=' + video_data.id +
+ '&format=html&hl=' + video_data.preferences.locale;
+
+ helpers.xhr('GET', compid_url, {retries: 5, entity_name: 'compilation'}, {
+ on200: function (response) {
+ compilation.innerHTML = response.compilationHtml;
+
+ if (!response.nextVideo) return;
+
+ var nextVideo = document.getElementById(response.nextVideo);
+ nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop;
+
+ player.on('ended', function () {
+ var url = new URL('https://example.com/watch?v=' + response.nextVideo);
+
+ url.searchParams.set('comp', compid);
+ if (!compid.startsWith('RD'))
+ url.searchParams.set('index', response.index);
+ if (video_data.params.autoplay || video_data.params.continue_autoplay)
+ url.searchParams.set('autoplay', '1');
+ if (video_data.params.listen !== video_data.preferences.listen)
+ url.searchParams.set('listen', video_data.params.listen);
+ if (video_data.params.speed !== video_data.preferences.speed)
+ url.searchParams.set('speed', video_data.params.speed);
+ if (video_data.params.local !== video_data.preferences.local)
+ url.searchParams.set('local', video_data.params.local);
+ url.searchParams.set('t',video_data.starting_timestamp_seconds);
+ url.searchParams.set('end',video_data.ending_timestamp_seconds);
+
+ location.assign(url.pathname + url.search);
+ });
+ },
+ onNon200: function (xhr) {
+ compilation.innerHTML = '';
+ document.getElementById('continue').style.display = '';
+ },
+ onError: function (xhr) {
+ compilation.innerHTML = spinnerHTMLwithHR;
+ },
+ onTimeout: function (xhr) {
+ compilation.innerHTML = spinnerHTMLwithHR;
+ }
+ });
+}
+
function get_playlist(plid) {
var playlist = document.getElementById('playlist');
@@ -181,7 +234,8 @@ if (video_data.play_next) {
addEventListener('load', function (e) {
if (video_data.plid)
get_playlist(video_data.plid);
-
+ if (video_data.compid)
+ get_compilation(video_data.compid);
if (video_data.params.comments[0] === 'youtube') {
get_youtube_comments();
} else if (video_data.params.comments[0] === 'reddit') {
diff --git a/config/config.example.yml b/config/config.example.yml
index b04e0a30..60c4df60 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -521,6 +521,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/config/sql/compilation_videos.sql b/config/sql/compilation_videos.sql
new file mode 100644
index 00000000..aae5e8a1
--- /dev/null
+++ b/config/sql/compilation_videos.sql
@@ -0,0 +1,21 @@
+-- Table: public.compilation_videos
+
+-- DROP TABLE public.compilation_videos;
+
+CREATE TABLE IF NOT EXISTS public.compilation_videos
+(
+ title text,
+ id text,
+ author text,
+ ucid text,
+ length_seconds integer,
+ starting_timestamp_seconds integer,
+ ending_timestamp_seconds integer,
+ published timestamptz,
+ compid text references compilations(id),
+ index int8,
+ order_index integer,
+ PRIMARY KEY (index,compid)
+);
+
+GRANT ALL ON TABLE public.compilation_videos TO current_user;
diff --git a/config/sql/compilations.sql b/config/sql/compilations.sql
new file mode 100644
index 00000000..60587e44
--- /dev/null
+++ b/config/sql/compilations.sql
@@ -0,0 +1,31 @@
+-- Type: public.compilation_privacy
+
+-- DROP TYPE public.compilation_privacy;
+
+CREATE TYPE public.compilation_privacy AS ENUM
+(
+ 'Unlisted',
+ 'Private'
+);
+
+-- Table: public.compilations
+
+-- DROP TABLE public.compilations;
+
+CREATE TABLE IF NOT EXISTS public.compilations
+(
+ title text,
+ id text primary key,
+ author text,
+ description text,
+ video_count integer,
+ created timestamptz,
+ updated timestamptz,
+ privacy compilation_privacy,
+ index int8[],
+ first_video_id text,
+ first_video_starting_timestamp_seconds integer,
+ first_video_ending_timestamp_seconds integer
+);
+
+GRANT ALL ON public.compilations TO current_user;
diff --git a/docker/init-invidious-db.sh b/docker/init-invidious-db.sh
index 22b4cc5f..f7fe3e7f 100755
--- a/docker/init-invidious-db.sh
+++ b/docker/init-invidious-db.sh
@@ -10,3 +10,5 @@ psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql
psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql
psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql
psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/compilations.sql
+psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/compilation_videos.sql
diff --git a/locales/en-US.json b/locales/en-US.json
index 4f2c2770..73336aec 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -182,10 +182,15 @@
"Delete playlist `x`?": "Delete playlist `x`?",
"Delete playlist": "Delete playlist",
"Create playlist": "Create playlist",
+ "Create compilation": "Create compilation",
"Title": "Title",
"Playlist privacy": "Playlist privacy",
+ "Compilation privacy": "Compilation privacy",
"Editing playlist `x`": "Editing playlist `x`",
+ "Editing compilation `x`": "Editing compilation `x`",
"playlist_button_add_items": "Add videos",
+ "compilation_button_add_items": "Add videos",
+ "compilation_button_play": "Play",
"Show more": "Show more",
"Show less": "Show less",
"Watch on YouTube": "Watch on YouTube",
@@ -247,6 +252,7 @@
"Not a playlist.": "Not a playlist.",
"Playlist does not exist.": "Playlist does not exist.",
"Could not pull trending pages.": "Could not pull trending pages.",
+ "Compilation does not exist.": "Compilation does not exist.",
"Hidden field \"challenge\" is a required field": "Hidden field \"challenge\" is a required field",
"Hidden field \"token\" is a required field": "Hidden field \"token\" is a required field",
"Erroneous challenge": "Erroneous challenge",
@@ -422,6 +428,7 @@
"Audio mode": "Audio mode",
"Video mode": "Video mode",
"Playlists": "Playlists",
+ "Compilations": "Compilations",
"search_filters_title": "Filters",
"search_filters_date_label": "Upload date",
"search_filters_date_option_none": "Any date",
@@ -478,6 +485,7 @@
"download_subtitles": "Subtitles - `x` (.vtt)",
"user_created_playlists": "`x` created playlists",
"user_saved_playlists": "`x` saved playlists",
+ "user_created_compilations": "`x` created compilations",
"Video unavailable": "Video unavailable",
"preferences_save_player_pos_label": "Save playback position: ",
"crash_page_you_found_a_bug": "It looks like you found a bug in Invidious!",
diff --git a/src/invidious.cr b/src/invidious.cr
index 12ffad33..c3b019d4 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -129,7 +129,7 @@ Kemal.config.extra_options do |parser|
puts SOFTWARE.to_pretty_json
exit
end
- parser.on("--migrate", "Run any migrations (beta, use at your own risk!!") do
+ parser.on("--migrate", "Run any migrations (beta, use at your own risk!!)") do
Invidious::Database::Migrator.new(PG_DB).migrate
exit
end
diff --git a/src/invidious/compilations.cr b/src/invidious/compilations.cr
new file mode 100644
index 00000000..e3a4147f
--- /dev/null
+++ b/src/invidious/compilations.cr
@@ -0,0 +1,487 @@
+struct CompilationVideo
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property ucid : String
+ property length_seconds : Int32
+ property starting_timestamp_seconds : Int32
+ property ending_timestamp_seconds : Int32
+ property published : Time
+ property compid : String
+ property index : Int64
+ property order_index : Int32
+
+ def to_xml(xml : XML::Builder)
+ xml.element("entry") do
+ xml.element("id") { xml.text "yt:video:#{self.id}" }
+ xml.element("yt:videoId") { xml.text self.id }
+ xml.element("yt:channelId") { xml.text self.ucid }
+ xml.element("title") { xml.text self.title }
+ xml.element("orderIndex") { xml.text self.order_index }
+ xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?v=#{self.id}")
+
+ xml.element("author") do
+ xml.element("name") { xml.text self.author }
+ xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" }
+ end
+
+ xml.element("content", type: "xhtml") do
+ xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do
+ xml.element("a", href: "#{HOST_URL}/watch?v=#{self.id}") do
+ xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg")
+ end
+ end
+ end
+
+ xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") }
+
+ xml.element("media:group") do
+ xml.element("media:title") { xml.text self.title }
+ xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg",
+ width: "320", height: "180")
+ end
+ end
+ end
+
+ def to_xml(_xml : Nil = nil)
+ XML.build { |xml| to_xml(xml) }
+ end
+
+ def to_json(json : JSON::Builder, index : Int32? = nil)
+ json.object do
+ json.field "title", self.title
+ json.field "videoId", self.id
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "videoThumbnails" do
+ Invidious::JSONify::APIv1.thumbnails(json, self.id)
+ end
+
+ if index
+ json.field "index", index
+ json.field "indexId", self.index.to_u64.to_s(16).upcase
+ else
+ json.field "index", self.index
+ end
+
+ json.field "orderIndex", self.order_index
+ json.field "lengthSeconds", self.length_seconds
+ json.field "startingTimestampSeconds", self.starting_timestamp_seconds
+ json.field "endingTimestampSeconds", self.ending_timestamp_seconds
+ end
+ end
+
+ def to_json(_json : Nil, index : Int32? = nil)
+ JSON.build { |json| to_json(json, index: index) }
+ end
+end
+
+struct Compilation
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property author_thumbnail : String
+ property ucid : String
+ property description : String
+ property description_html : String
+ property video_count : Int32
+ property views : Int64
+ property updated : Time
+ property thumbnail : String?
+ property first_video_id : String
+ property first_video_starting_timestamp_seconds : Int32
+ property first_video_ending_timestamp_seconds : Int32
+
+ def to_json(offset, json : JSON::Builder, video_id : String? = nil)
+ json.object do
+ json.field "type", "compilation"
+ json.field "title", self.title
+ json.field "compilationId", self.id
+ json.field "compilationThumbnail", self.thumbnail
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", "/channel/#{self.ucid}"
+
+ json.field "authorThumbnails" do
+ json.array do
+ qualities = {32, 48, 76, 100, 176, 512}
+
+ qualities.each do |quality|
+ json.object do
+ json.field "url", self.author_thumbnail.not_nil!.gsub(/=\d+/, "=s#{quality}")
+ json.field "width", quality
+ json.field "height", quality
+ end
+ end
+ end
+ end
+
+ json.field "description", self.description
+ json.field "descriptionHtml", self.description_html
+ json.field "videoCount", self.video_count
+
+ json.field "viewCount", self.views
+ json.field "updated", self.updated.to_unix
+
+ json.field "videos" do
+ json.array do
+ videos = get_compilation_videos(self, offset: offset, video_id: video_id)
+ videos.each do |video|
+ video.to_json(json)
+ end
+ end
+ end
+ end
+ end
+
+ def to_json(offset, _json : Nil = nil, video_id : String? = nil)
+ JSON.build do |json|
+ to_json(offset, json, video_id: video_id)
+ end
+ end
+
+ def privacy
+ CompilationPrivacy::Unlisted
+ end
+end
+
+enum CompilationPrivacy
+ Unlisted = 0
+ Private = 1
+end
+
+struct InvidiousCompilation
+ include DB::Serializable
+
+ property title : String
+ property id : String
+ property author : String
+ property description : String = ""
+ property video_count : Int32
+ property created : Time
+ property updated : Time
+
+ @[DB::Field(converter: InvidiousCompilation::CompilationPrivacyConverter)]
+ property privacy : CompilationPrivacy = CompilationPrivacy::Private
+ property index : Array(Int64)
+ property first_video_id : String
+ property first_video_starting_timestamp_seconds : Int32
+ property first_video_ending_timestamp_seconds : Int32
+
+ @[DB::Field(ignore: true)]
+ property thumbnail_id : String?
+
+ module CompilationPrivacyConverter
+ def self.from_rs(rs)
+ return CompilationPrivacy.parse(String.new(rs.read(Slice(UInt8))))
+ end
+ end
+
+ def to_json(offset, json : JSON::Builder, video_id : String? = nil)
+ json.object do
+ json.field "type", "invidiousCompilation"
+ json.field "title", self.title
+ json.field "compilationId", self.id
+
+ json.field "author", self.author
+ json.field "authorId", self.ucid
+ json.field "authorUrl", nil
+ json.field "authorThumbnails", [] of String
+
+ json.field "description", html_to_content(self.description_html)
+ json.field "descriptionHtml", self.description_html
+ json.field "videoCount", self.video_count
+
+ json.field "viewCount", self.views
+ json.field "updated", self.updated.to_unix
+
+ json.field "videos" do
+ json.array do
+ if (!offset || offset == 0) && !video_id.nil?
+ index = Invidious::Database::CompilationVideos.select_index(self.id, video_id)
+ offset = self.index.index(index) || 0
+ end
+
+ videos = get_compilation_videos(self, offset: offset, video_id: video_id)
+ videos.each_with_index do |video, idx|
+ video.to_json(json, offset + idx)
+ end
+ end
+ end
+ end
+ end
+
+ def to_json(offset, _json : Nil = nil, video_id : String? = nil)
+ JSON.build do |json|
+ to_json(offset, json, video_id: video_id)
+ end
+ end
+
+ def thumbnail
+ # TODO: Get compilation thumbnail from compilation data rather than first video
+ @thumbnail_id ||= Invidious::Database::CompilationVideos.select_one_id(self.id, self.index) || "-----------"
+ "/vi/#{@thumbnail_id}/mqdefault.jpg"
+ end
+
+ def author_thumbnail
+ nil
+ end
+
+ def ucid
+ nil
+ end
+
+ def views
+ 0_i64
+ end
+
+ def description_html
+ HTML.escape(self.description)
+ end
+end
+
+def create_compilation(title, privacy, user)
+ compid = "IVCMP#{Random::Secure.urlsafe_base64(24)[0, 31]}"
+
+ compilation = InvidiousCompilation.new({
+ title: title.byte_slice(0, 150),
+ id: compid,
+ author: user.email,
+ description: "", # Max 5000 characters
+ video_count: 0,
+ created: Time.utc,
+ updated: Time.utc,
+ privacy: privacy,
+ index: [] of Int64,
+ first_video_id: "",
+ first_video_starting_timestamp_seconds: 0,
+ first_video_ending_timestamp_seconds: 0,
+ })
+
+ Invidious::Database::Compilations.insert(compilation)
+
+ return compilation
+end
+
+def subscribe_compilation(user, compilation)
+ compilation = InvidiousCompilation.new({
+ title: compilation.title.byte_slice(0, 150),
+ id: compilation.id,
+ author: user.email,
+ description: "", # Max 5000 characters
+ video_count: compilation.video_count,
+ created: Time.utc,
+ updated: compilation.updated,
+ privacy: CompilationPrivacy::Private,
+ index: [] of Int64,
+ first_video_id: "",
+ first_video_starting_timestamp_seconds: 0,
+ first_video_ending_timestamp_seconds: 0,
+ })
+
+ Invidious::Database::Compilations.insert(compilation)
+
+ return compilation
+end
+
+def produce_compilation_continuation(id, index)
+ if id.starts_with? "UC"
+ id = "UU" + id.lchop("UC")
+ end
+ compid = "VL" + id
+
+ # Emulate a "request counter" increment, to make perfectly valid
+ # ctokens, even if at the time of writing, it's ignored by youtube.
+ request_count = (index / 100).to_i64 || 1_i64
+
+ data = {"1:varint" => index.to_i64}
+ .try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i, padding: false) }
+
+ object = {
+ "80226972:embedded" => {
+ "2:string" => plid,
+ "3:base64" => {
+ "1:varint" => request_count,
+ "15:string" => "PT:#{data}",
+ "104:embedded" => {"1:0:varint" => 0_i64},
+ },
+ "35:string" => id,
+ },
+ }
+
+ continuation = object.try { |i| Protodec::Any.cast_json(i) }
+ .try { |i| Protodec::Any.from_json(i) }
+ .try { |i| Base64.urlsafe_encode(i) }
+ .try { |i| URI.encode_www_form(i) }
+
+ return continuation
+end
+
+def get_compilation(compid : String)
+ if compilation = Invidious::Database::Compilations.select(id: compid)
+ update_first_video_params(compid)
+ return compilation
+ else
+ raise NotFoundException.new("Compilation does not exist.")
+ end
+end
+
+def update_first_video_params(compid : String)
+ if compilation = Invidious::Database::Compilations.select(id: compid)
+ compilation_index_array = compilation.index
+ if (compilation_index_array.size > 0)
+ first_index = compilation_index_array[0]
+ first_id = Invidious::Database::CompilationVideos.select_id_from_index(first_index)
+ if !first_id.nil?
+ timestamps = Invidious::Database::CompilationVideos.select_timestamps(compid, first_id)
+ if (!timestamps.nil?)
+ starting_timestamp_seconds = timestamps[0]
+ ending_timestamp_seconds = timestamps[1]
+ Invidious::Database::Compilations.update_first_video_params(compid, first_id, starting_timestamp_seconds, ending_timestamp_seconds)
+ end
+ end
+ end
+ else
+ raise NotFoundException.new("Compilation does not exist.")
+ end
+end
+
+def get_compilation_videos(compilation : InvidiousCompilation | Compilation, offset : Int32, video_id = nil)
+ # Show empty compilation if requested page is out of range
+ # (e.g, when a new compilation has been created, offset will be negative)
+ if offset >= compilation.video_count || offset < 0
+ return [] of CompilationVideo
+ end
+
+ if compilation.is_a? InvidiousCompilation
+ Invidious::Database::CompilationVideos.select(compilation.id, compilation.index, offset, limit: 100)
+ else
+ if video_id
+ initial_data = YoutubeAPI.next({
+ "videoId" => video_id,
+ "compilationId" => compilation.id,
+ })
+ offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "compilation", "compilation", "currentIndex").try &.as_i || offset
+ end
+
+ videos = [] of CompilationVideo
+
+ until videos.size >= 200 || videos.size == compilation.video_count || offset >= compilation.video_count
+ # 100 videos per request
+ ctoken = produce_compilation_continuation(compilation.id, offset)
+ initial_data = YoutubeAPI.browse(ctoken)
+ videos += extract_compilation_videos(initial_data)
+
+ offset += 100
+ end
+
+ return videos
+ end
+end
+
+def extract_compilation_videos(initial_data : Hash(String, JSON::Any))
+ videos = [] of CompilationVideo
+
+ if initial_data["contents"]?
+ tabs = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]
+ tabs_renderer = tabs.as_a.select(&.["tabRenderer"]["selected"]?.try &.as_bool)[0]["tabRenderer"]
+
+ # Watch out the two versions, with and without "s"
+ if tabs_renderer["contents"]? || tabs_renderer["content"]?
+ # Initial compilation data
+ tabs_contents = tabs_renderer.["contents"]? || tabs_renderer.["content"]
+
+ list_renderer = tabs_contents.["sectionListRenderer"]["contents"][0]
+ item_renderer = list_renderer.["itemSectionRenderer"]["contents"][0]
+ contents = item_renderer.["compilationVideoListRenderer"]["contents"].as_a
+ else
+ # Continuation data
+ contents = initial_data["onResponseReceivedActions"][0]?
+ .try &.["appendContinuationItemsAction"]["continuationItems"].as_a
+ end
+ else
+ contents = initial_data["response"]?.try &.["continuationContents"]["compilationVideoListContinuation"]["contents"].as_a
+ end
+
+ contents.try &.each do |item|
+ if i = item["compilationVideoRenderer"]?
+ video_id = i["navigationEndpoint"]["watchEndpoint"]["videoId"].as_s
+ compid = i["navigationEndpoint"]["watchEndpoint"]["compilationId"].as_s
+ index = i["navigationEndpoint"]["watchEndpoint"]["index"].as_i64
+
+ title = i["title"].try { |t| t["simpleText"]? || t["runs"]?.try &.[0]["text"]? }.try &.as_s || ""
+ author = i["shortBylineText"]?.try &.["runs"][0]["text"].as_s || ""
+ ucid = i["shortBylineText"]?.try &.["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s || ""
+ length_seconds = i["lengthSeconds"]?.try &.as_s.to_i
+ live = false
+
+ if !length_seconds
+ live = true
+ length_seconds = 0
+ end
+
+ videos << CompilationVideo.new({
+ title: title,
+ id: video_id,
+ author: author,
+ ucid: ucid,
+ length_seconds: length_seconds,
+ starting_timestamp_seconds: starting_timestamp_seconds,
+ ending_timestamp_seconds: ending_timestamp_seconds,
+ published: Time.utc,
+ compid: compid,
+ index: index,
+ order_index: order_index,
+ })
+ end
+ end
+
+ return videos
+end
+
+def template_compilation(compilation)
+ html = <<-END_HTML
+
+
+
+ END_HTML
+
+ html
+end
diff --git a/src/invidious/config.cr b/src/invidious/config.cr
index 453256b5..9247b4c9 100644
--- a/src/invidious/config.cr
+++ b/src/invidious/config.cr
@@ -38,7 +38,7 @@ struct ConfigPreferences
property quality : String = "hd720"
property quality_dash : String = "auto"
property default_home : String? = "Popular"
- property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
+ property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists", "Compilations"]
property automatic_instance_redirect : Bool = false
property region : String = "US"
property related_videos : Bool = true
@@ -167,6 +167,9 @@ class Config
# Playlist length limit
property playlist_length_limit : Int32 = 500
+ # Compilation length limit
+ property compilation_length_limit : Int32 = 500
+
def disabled?(option)
case disabled = CONFIG.disable_proxy
when Bool
diff --git a/src/invidious/database/base.cr b/src/invidious/database/base.cr
index 0fb1b6af..9cd8d705 100644
--- a/src/invidious/database/base.cr
+++ b/src/invidious/database/base.cr
@@ -10,11 +10,14 @@ module Invidious::Database
def check_integrity(cfg)
return if !cfg.check_tables
Invidious::Database.check_enum("privacy", PlaylistPrivacy)
+ Invidious::Database.check_enum("compilation_privacy", CompilationPrivacy)
Invidious::Database.check_table("channels", InvidiousChannel)
Invidious::Database.check_table("channel_videos", ChannelVideo)
Invidious::Database.check_table("playlists", InvidiousPlaylist)
Invidious::Database.check_table("playlist_videos", PlaylistVideo)
+ Invidious::Database.check_table("compilations", InvidiousCompilation)
+ Invidious::Database.check_table("compilation_videos", CompilationVideo)
Invidious::Database.check_table("nonces", Nonce)
Invidious::Database.check_table("session_ids", SessionId)
Invidious::Database.check_table("users", User)
diff --git a/src/invidious/database/compilations.cr b/src/invidious/database/compilations.cr
new file mode 100644
index 00000000..9d82a3eb
--- /dev/null
+++ b/src/invidious/database/compilations.cr
@@ -0,0 +1,359 @@
+require "./base.cr"
+
+#
+# This module contains functions related to the "compilations" table.
+#
+module Invidious::Database::Compilations
+ extend self
+
+ # -------------------
+ # Insert / delete
+ # -------------------
+
+ def insert(compilation : InvidiousCompilation)
+ compilation_array = compilation.to_a
+
+ request = <<-SQL
+ INSERT INTO compilations
+ VALUES (#{arg_array(compilation_array)})
+ SQL
+
+ PG_DB.exec(request, args: compilation_array)
+ end
+
+ # deletes the given compilation and connected compilation videos
+ def delete(id : String)
+ CompilationVideos.delete_by_compilation(id)
+ request = <<-SQL
+ DELETE FROM compilations *
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, id)
+ end
+
+ # -------------------
+ # Update
+ # -------------------
+
+ def update(id : String, title : String, privacy, description, updated)
+ request = <<-SQL
+ UPDATE compilations
+ SET title = $1, privacy = $2, description = $3, updated = $4
+ WHERE id = $5
+ SQL
+
+ PG_DB.exec(request, title, privacy, description, updated, id)
+ end
+
+ def update_description(id : String, description)
+ request = <<-SQL
+ UPDATE compilations
+ SET description = $1
+ WHERE id = $2
+ SQL
+
+ PG_DB.exec(request, description, id)
+ end
+
+ def update_video_added(id : String, index : String | Int64)
+ request = <<-SQL
+ UPDATE compilations
+ SET index = array_append(index, $1),
+ video_count = cardinality(index) + 1,
+ updated = now()
+ WHERE id = $2
+ SQL
+
+ PG_DB.exec(request, index, id)
+ end
+
+ def update_video_removed(id : String, index : String | Int64)
+ request = <<-SQL
+ UPDATE compilations
+ SET index = array_remove(index, $1),
+ video_count = cardinality(index) - 1,
+ updated = now()
+ WHERE id = $2
+ SQL
+
+ PG_DB.exec(request, index, id)
+ end
+
+ def move_video_position(id : String, index : Array(Int64))
+ request = <<-SQL
+ UPDATE compilations
+ SET index = $2
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, id, index)
+ end
+
+ def update_first_video_params(id : String, first_video_id : String, starting_timestamp_seconds : Int32, ending_timestamp_seconds : Int32)
+ request = <<-SQL
+ UPDATE compilations
+ SET first_video_id = $2,
+ first_video_starting_timestamp_seconds = $3,
+ first_video_ending_timestamp_seconds = $4
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, id, first_video_id, starting_timestamp_seconds, ending_timestamp_seconds)
+ end
+
+ # -------------------
+ # Select
+ # -------------------
+
+ def select(*, id : String) : InvidiousCompilation?
+ request = <<-SQL
+ SELECT * FROM compilations
+ WHERE id = $1
+ SQL
+
+ return PG_DB.query_one?(request, id, as: InvidiousCompilation)
+ end
+
+ def select_all(*, author : String) : Array(InvidiousCompilation)
+ request = <<-SQL
+ SELECT * FROM compilations
+ WHERE author = $1
+ SQL
+
+ return PG_DB.query_all(request, author, as: InvidiousCompilation)
+ end
+
+ def select_index_array(id : String)
+ request = <<-SQL
+ SELECT index FROM compilations
+ WHERE id = $1
+ LIMIT 1
+ SQL
+
+ PG_DB.query_one?(request, id, as: Array(Int64))
+ end
+
+ # -------------------
+ # Select (filtered)
+ # -------------------
+
+ def select_like_iv(email : String) : Array(InvidiousCompilation)
+ request = <<-SQL
+ SELECT * FROM compilations
+ WHERE author = $1 AND id LIKE 'IV%'
+ ORDER BY created
+ SQL
+
+ PG_DB.query_all(request, email, as: InvidiousCompilation)
+ end
+
+ def select_not_like_iv(email : String) : Array(InvidiousCompilation)
+ request = <<-SQL
+ SELECT * FROM compilations
+ WHERE author = $1 AND id NOT LIKE 'IV%'
+ ORDER BY created
+ SQL
+
+ PG_DB.query_all(request, email, as: InvidiousCompilation)
+ end
+
+ def select_user_created_compilations(email : String) : Array({String, String})
+ request = <<-SQL
+ SELECT id,title FROM compilations
+ WHERE author = $1 AND id LIKE 'IV%'
+ SQL
+
+ PG_DB.query_all(request, email, as: {String, String})
+ end
+
+ # -------------------
+ # Misc checks
+ # -------------------
+
+ # Check if given compilation ID exists
+ def exists?(id : String) : Bool
+ request = <<-SQL
+ SELECT id FROM compilations
+ WHERE id = $1
+ SQL
+
+ return PG_DB.query_one?(request, id, as: String).nil?
+ end
+
+ # Count how many compilations a user has created.
+ def count_owned_by(author : String) : Int64
+ request = <<-SQL
+ SELECT count(*) FROM compilations
+ WHERE author = $1
+ SQL
+
+ return PG_DB.query_one?(request, author, as: Int64) || 0_i64
+ end
+end
+
+#
+# This module contains functions related to the "compilation_videos" table.
+#
+module Invidious::Database::CompilationVideos
+ extend self
+
+ private alias VideoIndex = Int64 | Array(Int64)
+
+ # -------------------
+ # Insert / Delete
+ # -------------------
+
+ def insert(video : CompilationVideo)
+ video_array = video.to_a
+
+ request = <<-SQL
+ INSERT INTO compilation_videos
+ VALUES (#{arg_array(video_array)})
+ SQL
+
+ PG_DB.exec(request, args: video_array)
+ end
+
+ def delete(index)
+ request = <<-SQL
+ DELETE FROM compilation_videos *
+ WHERE index = $1
+ SQL
+
+ PG_DB.exec(request, index)
+ end
+
+ def delete_by_compilation(compid : String)
+ request = <<-SQL
+ DELETE FROM compilation_videos *
+ WHERE compid = $1
+ SQL
+
+ PG_DB.exec(request, compid)
+ end
+
+ # -------------------
+ # Select
+ # -------------------
+
+ def select(compid : String, index : VideoIndex, offset, limit = 100) : Array(CompilationVideo)
+ request = <<-SQL
+ SELECT * FROM compilation_videos
+ WHERE compid = $1
+ ORDER BY array_position($2, index)
+ LIMIT $3
+ OFFSET $4
+ SQL
+
+ return PG_DB.query_all(request, compid, index, limit, offset, as: CompilationVideo)
+ end
+
+ def select_video(compid : String, index : VideoIndex, video_index, offset, limit = 100) : Array(CompilationVideo)
+ request = <<-SQL
+ SELECT * FROM compilation_videos
+ WHERE compid = $1 AND index = $3
+ ORDER BY array_position($2, index)
+ LIMIT $5
+ OFFSET $4
+ SQL
+
+ return PG_DB.query_all(request, compid, index, video_index, offset, limit, as: CompilationVideo)
+ end
+
+ def select_timestamps(compid : String, vid : String)
+ request = <<-SQL
+ SELECT starting_timestamp_seconds,ending_timestamp_seconds FROM compilation_videos
+ WHERE compid = $1 AND id = $2
+ LIMIT 1
+ SQL
+
+ return PG_DB.query_one?(request, compid, vid, as: {Int32, Int32})
+ end
+
+ def select_id_from_order_index(order_index : Int32)
+ request = <<-SQL
+ SELECT id FROM compilation_videos
+ WHERE order_index = $1
+ LIMIT 1
+ SQL
+
+ return PG_DB.query_one?(request, order_index, as: String)
+ end
+
+ def select_id_from_index(index : Int64)
+ request = <<-SQL
+ SELECT id FROM compilation_videos
+ WHERE index = $1
+ LIMIT 1
+ SQL
+
+ return PG_DB.query_one?(request, index, as: String)
+ end
+
+ def select_index_from_order_index(order_index : Int32)
+ request = <<-SQL
+ SELECT index FROM compilation_videos
+ WHERE order_index = $1
+ LIMIT 1
+ SQL
+
+ return PG_DB.query_one?(request, order_index, as: VideoIndex)
+ end
+
+ def select_index(compid : String, vid : String) : Int64?
+ request = <<-SQL
+ SELECT index FROM compilation_videos
+ WHERE compid = $1 AND id = $2
+ LIMIT 1
+ SQL
+
+ return PG_DB.query_one?(request, compid, vid, as: Int64)
+ end
+
+ def select_one_id(compid : String, index : VideoIndex) : String?
+ request = <<-SQL
+ SELECT id FROM compilation_videos
+ WHERE compid = $1
+ ORDER BY array_position($2, index)
+ LIMIT 1
+ SQL
+
+ return PG_DB.query_one?(request, compid, index, as: String)
+ end
+
+ def select_ids(compid : String, index : VideoIndex, limit = 500) : Array(String)
+ request = <<-SQL
+ SELECT id FROM compilation_videos
+ WHERE compid = $1
+ ORDER BY array_position($2, index)
+ LIMIT $3
+ SQL
+
+ return PG_DB.query_all(request, compid, index, limit, as: String)
+ end
+
+ # -------------------
+ # Update
+ # -------------------
+
+ def update_start_timestamp(id : String, starting_timestamp_seconds : Int32)
+ request = <<-SQL
+ UPDATE compilation_videos
+ SET starting_timestamp_seconds = $2
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, id, starting_timestamp_seconds)
+ end
+
+ def update_end_timestamp(id : String, ending_timestamp_seconds : Int32)
+ request = <<-SQL
+ UPDATE compilation_videos
+ SET ending_timestamp_seconds = $2
+ WHERE id = $1
+ SQL
+
+ PG_DB.exec(request, id, ending_timestamp_seconds)
+ end
+end
diff --git a/src/invidious/database/migrations/0011_create_compilations_table.cr b/src/invidious/database/migrations/0011_create_compilations_table.cr
new file mode 100644
index 00000000..363f2107
--- /dev/null
+++ b/src/invidious/database/migrations/0011_create_compilations_table.cr
@@ -0,0 +1,52 @@
+module Invidious::Database::Migrations
+ class CreateCompilationsTable < Migration
+ version 11
+
+ def up(conn : DB::Connection)
+ if !compilation_privacy_type_exists?(conn)
+ conn.exec <<-SQL
+ CREATE TYPE public.compilation_privacy AS ENUM
+ (
+ 'Unlisted',
+ 'Private'
+ );
+ SQL
+ end
+
+ conn.exec <<-SQL
+ CREATE TABLE IF NOT EXISTS public.compilations
+ (
+ title text,
+ id text primary key,
+ author text,
+ description text,
+ video_count integer,
+ created timestamptz,
+ updated timestamptz,
+ privacy compilation_privacy,
+ index int8[],
+ first_video_id text,
+ first_video_starting_timestamp_seconds integer,
+ first_video_ending_timestamp_seconds integer
+ );
+ SQL
+
+ conn.exec <<-SQL
+ GRANT ALL ON public.compilations TO current_user;
+ SQL
+ end
+
+ private def compilation_privacy_type_exists?(conn : DB::Connection) : Bool
+ request = <<-SQL
+ SELECT 1 AS one
+ FROM pg_type
+ INNER JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace
+ WHERE pg_namespace.nspname = 'public'
+ AND pg_type.typname = 'compilation_privacy'
+ LIMIT 1;
+ SQL
+
+ !conn.query_one?(request, as: Int32).nil?
+ end
+ end
+end
diff --git a/src/invidious/database/migrations/0012_create_compilation_videos_table.cr b/src/invidious/database/migrations/0012_create_compilation_videos_table.cr
new file mode 100644
index 00000000..6d891066
--- /dev/null
+++ b/src/invidious/database/migrations/0012_create_compilation_videos_table.cr
@@ -0,0 +1,30 @@
+module Invidious::Database::Migrations
+ class CreateCompilationVideosTable < Migration
+ version 12
+
+ def up(conn : DB::Connection)
+ conn.exec <<-SQL
+ CREATE TABLE IF NOT EXISTS public.compilation_videos
+ (
+ title text,
+ id text,
+ author text,
+ ucid text,
+ length_seconds integer,
+ starting_timestamp_seconds integer,
+ ending_timestamp_seconds integer,
+ published timestamptz,
+ compid text references compilations(id),
+ index int8,
+ order_index integer,
+ PRIMARY KEY (index,compid)
+ );
+ SQL
+
+ conn.exec <<-SQL
+ GRANT ALL ON TABLE public.playlist_videos TO current_user;
+ SQL
+ end
+ end
+ end
+
\ No newline at end of file
diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr
index 08aa719a..5c7b3cce 100644
--- a/src/invidious/database/playlists.cr
+++ b/src/invidious/database/playlists.cr
@@ -91,7 +91,7 @@ module Invidious::Database::Playlists
end
# -------------------
- # Salect
+ # Select
# -------------------
def select(*, id : String) : InvidiousPlaylist?
@@ -113,7 +113,7 @@ module Invidious::Database::Playlists
end
# -------------------
- # Salect (filtered)
+ # Select (filtered)
# -------------------
def select_like_iv(email : String) : Array(InvidiousPlaylist)
@@ -160,7 +160,7 @@ module Invidious::Database::Playlists
return PG_DB.query_one?(request, id, as: String).nil?
end
- # Count how many playlist a user has created.
+ # Count how many playlists a user has created.
def count_owned_by(author : String) : Int64
request = <<-SQL
SELECT count(*) FROM playlists
@@ -213,7 +213,7 @@ module Invidious::Database::PlaylistVideos
end
# -------------------
- # Salect
+ # Select
# -------------------
def select(plid : String, index : VideoIndex, offset, limit = 100) : Array(PlaylistVideo)
diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr
index 4d9bb28d..370b74c6 100644
--- a/src/invidious/helpers/utils.cr
+++ b/src/invidious/helpers/utils.cr
@@ -35,8 +35,10 @@ def decode_length_seconds(string)
end
def recode_length_seconds(time)
- if time <= 0
+ if time < 0
return ""
+ elsif time == 0
+ return "0:00"
else
time = time.seconds
text = "#{time.minutes.to_s.rjust(2, '0')}:#{time.seconds.to_s.rjust(2, '0')}"
diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr
index a35d2f2b..59a380ae 100644
--- a/src/invidious/routes/api/v1/authenticated.cr
+++ b/src/invidious/routes/api/v1/authenticated.cr
@@ -192,6 +192,21 @@ module Invidious::Routes::API::V1::Authenticated
env.response.status_code = 204
end
+ def self.list_compilations(env)
+ env.response.content_type = "application/json"
+ user = env.get("user").as(User)
+
+ compilations = Invidious::Database::Compilations.select_all(author: user.email)
+
+ JSON.build do |json|
+ json.array do
+ compilations.each do |compilation|
+ compilation.to_json(0, json)
+ end
+ end
+ end
+ end
+
def self.list_playlists(env)
env.response.content_type = "application/json"
user = env.get("user").as(User)
@@ -207,6 +222,32 @@ module Invidious::Routes::API::V1::Authenticated
end
end
+ def self.create_compilation(env)
+ env.response.content_type = "application/json"
+ user = env.get("user").as(User)
+
+ 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 { |p| CompilationPrivacy.parse(p.as(String).downcase) }
+ if !privacy
+ return error_json(400, "Invalid privacy setting.")
+ end
+
+ if Invidious::Database::Compilations.count_owned_by(user.email) >= 100
+ return error_json(400, "User cannot have more than 100 compilations.")
+ end
+
+ compilation = create_compilation(title, privacy, user)
+ env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/compilations/#{compilation.id}"
+ env.response.status_code = 201
+ {
+ "title" => title,
+ "compilationId" => compilation.id,
+ }.to_json
+ end
+
def self.create_playlist(env)
env.response.content_type = "application/json"
user = env.get("user").as(User)
diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr
index 4f5b58da..bfcd3a08 100644
--- a/src/invidious/routes/api/v1/misc.cr
+++ b/src/invidious/routes/api/v1/misc.cr
@@ -26,6 +26,72 @@ module Invidious::Routes::API::V1::Misc
end
end
+ def self.get_compilation(env : HTTP::Server::Context)
+ env.response.content_type = "application/json"
+ compid = env.params.url["compid"]
+ offset = env.params.query["index"]?.try &.to_i?
+ offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 }
+ offset ||= 0
+
+ video_id = env.params.query["continuation"]?
+
+ format = env.params.query["format"]?
+ format ||= "json"
+
+ if compid.starts_with? "RD"
+ return env.redirect "/api/v1/mixes/#{compid}"
+ end
+
+ begin
+ compilation = get_compilation(compid)
+ rescue ex : InfoException
+ return error_json(404, ex)
+ rescue ex
+ return error_json(404, "Compilation does not exist.")
+ end
+
+ user = env.get?("user").try &.as(User)
+ if !compilation || compilation.privacy.private? && compilation.author != user.try &.email
+ return error_json(404, "Compilation does not exist.")
+ end
+
+ # includes into the compilation a maximum of 50 videos, before the offset
+ if offset > 0
+ lookback = offset < 50 ? offset : 50
+ response = compilation.to_json(offset - lookback)
+ json_response = JSON.parse(response)
+ else
+ # Unless the continuation is really the offset 0, it becomes expensive.
+ # It happens when the offset is not set.
+ # First we find the actual offset, and then we lookback
+ # it shouldn't happen often though
+
+ lookback = 0
+ response = compilation.to_json(offset, video_id: video_id)
+ json_response = JSON.parse(response)
+
+ if json_response["videos"].as_a[0]["index"] != offset
+ offset = json_response["videos"].as_a[0]["index"].as_i
+ lookback = offset < 50 ? offset : 50
+ response = compilation.to_json(offset - lookback)
+ json_response = JSON.parse(response)
+ end
+ end
+
+ if format == "html"
+ compilation_html = template_compilation(json_response)
+ index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil}
+
+ response = {
+ "compilationHtml" => compilation_html,
+ "index" => index,
+ "nextVideo" => next_video,
+ }.to_json
+ end
+
+ response
+ 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
diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr
index 5695dee9..916cb262 100644
--- a/src/invidious/routes/before_all.cr
+++ b/src/invidious/routes/before_all.cr
@@ -85,6 +85,7 @@ module Invidious::Routes::BeforeAll
csrf_token = generate_response(sid, {
":authorize_token",
":playlist_ajax",
+ ":compilation_ajax",
":signout",
":subscription_ajax",
":token_ajax",
diff --git a/src/invidious/routes/compilations.cr b/src/invidious/routes/compilations.cr
new file mode 100644
index 00000000..ccc6819d
--- /dev/null
+++ b/src/invidious/routes/compilations.cr
@@ -0,0 +1,538 @@
+{% 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?comp=#{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["comp"]?
+ 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["comp"]?
+ 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["comp"]?
+ if !compid || !compid.starts_with?("IVCMP")
+ 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["comp"]?
+ 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?comp=#{compid}"
+ end
+
+ def self.adjust_timestamps(env)
+ locale = env.get("preferences").as(Preferences).locale
+ env.response.content_type = "application/json"
+ user = env.get("user")
+ sid = env.get? "sid"
+
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ compid = env.params.query["comp"]?
+ 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
+
+ if !compid || compid.empty?
+ return error_json(400, "A compilation ID is required")
+ end
+
+ compilation = Invidious::Database::Compilations.select(id: compid)
+ if !compilation || compilation.author != user.email && compilation.privacy.private?
+ return error_json(404, "Compilation does not exist.")
+ end
+
+ if compilation.author != user.email
+ return error_json(403, "Invalid user")
+ end
+
+ title = env.params.body["title"]?.try &.delete("<>") || ""
+ privacy = CompilationPrivacy.parse(env.params.body["privacy"]? || "Private")
+
+ Invidious::Database::Compilations.update(compid, title, privacy, "", compilation.updated)
+
+ (0..compilation.index.size - 1).each do |index|
+ compilation_video_index = compilation.index[index]
+ compilation_video = Invidious::Database::CompilationVideos.select_video(compid, compilation.index, compilation_video_index, 0, 1)
+ json_timestamp_query_start = compilation_video_index.to_s + "_start_timestamp"
+ start_timestamp = env.params.body[json_timestamp_query_start]?.try &.as(String).byte_slice(0, 8)
+ if !start_timestamp.nil? && !compilation_video[0].id.nil?
+ start_timestamp_seconds = decode_length_seconds(start_timestamp)
+ if !start_timestamp_seconds.nil?
+ if start_timestamp_seconds >= 0 && start_timestamp_seconds <= compilation_video[0].length_seconds
+ Invidious::Database::CompilationVideos.update_start_timestamp(compilation_video[0].id, start_timestamp_seconds.to_i)
+ end
+ end
+ end
+ compilation_video = Invidious::Database::CompilationVideos.select_video(compid, compilation.index, compilation_video_index, 0, 1)
+ json_timestamp_query_end = compilation_video_index.to_s + "_end_timestamp"
+ end_timestamp = env.params.body[json_timestamp_query_end]?.try &.as(String).byte_slice(0, 8)
+ if !end_timestamp.nil? && !compilation_video[0].id.nil?
+ end_timestamp_seconds = decode_length_seconds(end_timestamp)
+ if !end_timestamp_seconds.nil?
+ if end_timestamp_seconds >= 0 && end_timestamp_seconds <= compilation_video[0].length_seconds && end_timestamp_seconds > compilation_video[0].starting_timestamp_seconds
+ Invidious::Database::CompilationVideos.update_end_timestamp(compilation_video[0].id, end_timestamp_seconds.to_i)
+ end
+ end
+ end
+ end
+
+ update_first_video_params(compid)
+
+ env.redirect "/compilation?comp=#{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["comp"]?
+ if !compid || !compid.starts_with?("IVCMP")
+ 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)
+ items = query.process.select(SearchVideo).map(&.as(SearchVideo))
+ rescue ex
+ items = [] of SearchVideo
+ end
+
+ query_encoded = URI.encode_www_form(query.try &.text || "", space_to_plus: true)
+ page_nav_html = Frontend::Pagination.nav_numeric(locale,
+ base_url: "/add_compilation_items?comp=#{compilation.id}&q=#{query_encoded}",
+ current_page: page,
+ show_next: (items.size >= 20)
+ )
+
+ 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"
+ elsif env.params.query["action_move_video_after"]?
+ action = "action_move_video_after"
+ 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: 0,
+ 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),
+ order_index: compilation.index.size,
+ })
+
+ Invidious::Database::CompilationVideos.insert(compilation_video)
+ Invidious::Database::Compilations.update_video_added(compilation_id, compilation_video.index)
+ update_first_video_params(compilation_id)
+ 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)
+ update_first_video_params(compilation_id)
+ when "action_move_video_before"
+ # TODO: Compilation stub
+ video_index = env.params.query["video_index"]
+ begin
+ compilation_video = Invidious::Database::CompilationVideos.select_video(compilation_id, compilation.index, video_index, 0, 1)
+ compilation_index_array = compilation.index
+ 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_index_array_position = compilation_index_array.index(compilation_video[0].index)
+ if !compilation_index_array_position.nil?
+ compilation_index_array.delete_at(compilation_index_array_position)
+ compilation_index_array.insert(compilation_index_array_position - 1, compilation_video[0].index)
+ Invidious::Database::Compilations.move_video_position(compilation_id, compilation_index_array)
+ end
+ update_first_video_params(compilation_id)
+ when "action_move_video_after"
+ # TODO: Compilation stub
+ video_index = env.params.query["video_index"]
+ begin
+ compilation_video = Invidious::Database::CompilationVideos.select_video(compilation_id, compilation.index, video_index, 0, 1)
+ compilation_index_array = compilation.index
+ 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_index_array_position = compilation_index_array.index(compilation_video[0].index)
+ if !compilation_index_array_position.nil?
+ compilation_index_array.delete_at(compilation_index_array_position)
+ if (compilation_index_array_position == compilation_index_array.size)
+ compilation_index_array.insert(compilation_index_array_position, compilation_video[0].index)
+ else
+ compilation_index_array.insert(compilation_index_array_position + 1, compilation_video[0].index)
+ end
+ Invidious::Database::Compilations.move_video_position(compilation_id, compilation_index_array)
+ end
+ update_first_video_params(compilation_id)
+ 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["comp"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
+ if !compid
+ return env.redirect "/"
+ end
+
+ page = env.params.query["page"]?.try &.to_i?
+ page ||= 1
+
+ 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?comp=#{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
diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr
index 7f9a0edb..f237994f 100644
--- a/src/invidious/routes/feeds.cr
+++ b/src/invidious/routes/feeds.cr
@@ -5,6 +5,36 @@ module Invidious::Routes::Feeds
env.redirect "/feed/playlists"
end
+ def self.view_all_compilations_redirect(env)
+ env.redirect "/feed/compilations"
+ end
+
+ def self.compilations(env)
+ locale = env.get("preferences").as(Preferences).locale
+
+ user = env.get? "user"
+ referer = get_referer(env)
+
+ return env.redirect "/" if user.nil?
+
+ user = user.as(User)
+
+ # TODO: make a single DB call and separate the items here?
+ items_created = Invidious::Database::Compilations.select_like_iv(user.email)
+ items_created.map! do |item|
+ item.author = ""
+ item
+ end
+
+ items_saved = Invidious::Database::Compilations.select_not_like_iv(user.email)
+ items_saved.map! do |item|
+ item.author = ""
+ item
+ end
+
+ templated "feeds/compilations"
+ end
+
def self.playlists(env)
locale = env.get("preferences").as(Preferences).locale
diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr
index 0b868755..b337e03a 100644
--- a/src/invidious/routes/misc.cr
+++ b/src/invidious/routes/misc.cr
@@ -23,6 +23,12 @@ module Invidious::Routes::Misc
else
env.redirect "/feed/popular"
end
+ when "Compilations"
+ if user
+ env.redirect "/feed/compilations"
+ else
+ env.redirect "/feed/popular"
+ end
else
templated "search_homepage", navbar_search: false
end
diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr
index 39ca77c0..0f6f710a 100644
--- a/src/invidious/routes/preferences.cr
+++ b/src/invidious/routes/preferences.cr
@@ -103,7 +103,7 @@ module Invidious::Routes::PreferencesRoute
default_home = env.params.body["default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home
feed_menu = [] of String
- 4.times do |index|
+ 5.times do |index|
option = env.params.body["feed_menu[#{index}]"]?.try &.as(String) || ""
if !option.empty?
feed_menu << option
@@ -191,7 +191,7 @@ module Invidious::Routes::PreferencesRoute
CONFIG.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home
admin_feed_menu = [] of String
- 4.times do |index|
+ 5.times do |index|
option = env.params.body["admin_feed_menu[#{index}]"]?.try &.as(String) || ""
if !option.empty?
admin_feed_menu << option
diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr
index 1f384546..5f53e891 100644
--- a/src/invidious/routes/watch.cr
+++ b/src/invidious/routes/watch.cr
@@ -30,8 +30,43 @@ module Invidious::Routes::Watch
return env.redirect "/"
end
- plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
- continuation = process_continuation(env.params.query, plid, id)
+ embed_link = "/embed/#{id}"
+ if env.params.query.size > 1
+ embed_params = HTTP::Params.parse(env.params.query.to_s)
+ embed_params.delete_all("v")
+ embed_link += "?"
+ embed_link += embed_params.to_s
+ end
+
+ if env.params.query["list"]?.try &.starts_with? "IVPL"
+ plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
+ continuation = process_continuation(env.params.query, plid, id)
+ elsif env.params.query["comp"]?.try &.starts_with? "IVCMP"
+ compid = env.params.query["comp"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
+ if (!compid.nil?)
+ index = Invidious::Database::CompilationVideos.select_index(compid, id)
+ indices_array = Invidious::Database::Compilations.select_index_array(compid)
+ if (!indices_array.nil?)
+ position_of_index = indices_array.index(index)
+ if (!position_of_index.nil? && position_of_index != indices_array.size - 1)
+ next_index = indices_array[position_of_index + 1]
+ else
+ next_index = indices_array[0]
+ end
+ if (!next_index.nil?)
+ next_id = Invidious::Database::CompilationVideos.select_id_from_index(next_index)
+ if (!next_id.nil?)
+ timestamps = Invidious::Database::CompilationVideos.select_timestamps(compid, next_id)
+ if (!timestamps.nil?)
+ starting_timestamp_seconds = timestamps[0]
+ ending_timestamp_seconds = timestamps[1]
+ end
+ end
+ end
+ end
+ end
+ continuation = process_continuation(env.params.query, compid, id)
+ end
nojs = env.params.query["nojs"]?
diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr
index 46b71f1f..7f6980c5 100644
--- a/src/invidious/routing.cr
+++ b/src/invidious/routing.cr
@@ -26,7 +26,9 @@ module Invidious::Routing
self.register_watch_routes
self.register_iv_playlist_routes
+ self.register_iv_compilation_routes
self.register_yt_playlist_routes
+ self.register_compilation_routes
self.register_search_routes
@@ -80,6 +82,15 @@ module Invidious::Routing
get "/subscription_manager", Routes::Subscriptions, :subscription_manager
end
+ def register_iv_compilation_routes
+ get "/create_compilation", Routes::Compilations, :new
+ post "/create_compilation", Routes::Compilations, :create
+ post "/compilation_ajax", Routes::Compilations, :compilation_ajax
+ get "/add_compilation_items", Routes::Compilations, :add_compilation_items_page
+ get "/edit_compilation", Routes::Compilations, :edit
+ post "/edit_compilation", Routes::Compilations, :adjust_timestamps
+ end
+
def register_iv_playlist_routes
get "/create_playlist", Routes::Playlists, :new
post "/create_playlist", Routes::Playlists, :create
@@ -99,6 +110,7 @@ module Invidious::Routing
get "/feed/popular", Routes::Feeds, :popular
get "/feed/trending", Routes::Feeds, :trending
get "/feed/subscriptions", Routes::Feeds, :subscriptions
+ get "/feed/compilations", Routes::Feeds, :compilations
get "/feed/history", Routes::Feeds, :history
# RSS Feeds
@@ -180,6 +192,10 @@ module Invidious::Routing
get "/watch_videos", Routes::Playlists, :watch_videos
end
+ def register_compilation_routes
+ get "/compilation", Routes::Compilations, :show
+ end
+
def register_search_routes
get "/opensearch.xml", Routes::Search, :opensearch
get "/results", Routes::Search, :results
@@ -292,6 +308,9 @@ module Invidious::Routing
post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel
delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel
+ post "/api/v1/auth/compilations", {{namespace}}::Authenticated, :create_compilation
+ get "/api/v1/auth/compilations", {{namespace}}::Authenticated, :list_compilations
+
get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists
post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist
patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute
@@ -312,6 +331,8 @@ module Invidious::Routing
get "/api/v1/stats", {{namespace}}::Misc, :stats
get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist
get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist
+ get "/api/v1/compilations/:compid", {{namespace}}::Misc, :get_compilation
+ get "/api/v1/auth/compilations/:compid", {{namespace}}::Misc, :get_compilation
get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes
get "/api/v1/resolveurl", {{namespace}}::Misc, :resolve_url
{% end %}
diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr
index 94a92e23..93efcf33 100644
--- a/src/invidious/search/query.cr
+++ b/src/invidious/search/query.cr
@@ -8,6 +8,7 @@ module Invidious::Search
# Types specific to Invidious
Subscriptions # Search user subscriptions
Playlist # "Add playlist item" search
+ Compilation # "Add compilation item" search
end
getter type : Type = Type::Regular
@@ -86,6 +87,12 @@ module Invidious::Search
#
@filters, _, @query, _ = Filters.from_legacy_filters(@raw_query)
#
+ when .compilation?
+ # In "add compilation item" mode, filters are parsed from the query
+ # string itself (legacy), and the channel is ignored.
+ #
+ @filters, _, @query, _ = Filters.from_legacy_filters(@raw_query)
+ #
when .subscriptions?, .regular?
if params["sp"]?
# Parse the `sp` URL parameter (youtube compatibility)
@@ -123,7 +130,7 @@ module Invidious::Search
return items if self.empty_raw_query?
case @type
- when .regular?, .playlist?
+ when .regular?, .playlist?, .compilation?
items = Processors.regular(self)
#
when .channel?
diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr
index 962f87bd..06eeadfe 100644
--- a/src/invidious/videos.cr
+++ b/src/invidious/videos.cr
@@ -345,9 +345,9 @@ def fetch_video(id, region)
return video
end
-def process_continuation(query, plid, id)
+def process_continuation(query, list_id, id)
continuation = nil
- if plid
+ if list_id
if index = query["index"]?.try &.to_i?
continuation = index
else
diff --git a/src/invidious/views/add_compilation_items.ecr b/src/invidious/views/add_compilation_items.ecr
new file mode 100644
index 00000000..d8874930
--- /dev/null
+++ b/src/invidious/views/add_compilation_items.ecr
@@ -0,0 +1,34 @@
+<% content_for "header" do %>
+<%= compilation.title %> - Invidious
+
+<% end %>
+
+
+
+
+
+
+<%= rendered "components/items_paginated" %>
diff --git a/src/invidious/views/compilation.ecr b/src/invidious/views/compilation.ecr
new file mode 100644
index 00000000..c1d994f9
--- /dev/null
+++ b/src/invidious/views/compilation.ecr
@@ -0,0 +1,97 @@
+<% title = HTML.escape(compilation.title) %>
+<% author = HTML.escape(compilation.author) %>
+
+<% content_for "header" do %>
+<%= title %> - Invidious
+
+<% end %>
+
+
+
+
+
+ <% if compilation.is_a? InvidiousCompilation %>
+
+ <% if compilation.author == user.try &.email %>
+ <%= author %> |
+ <% else %>
+ <%= author %> |
+ <% end %>
+ <%= translate_count(locale, "generic_videos_count", compilation.video_count) %> |
+ <%= translate(locale, "Updated `x` ago", recode_date(compilation.updated, locale)) %> |
+ <% case compilation.as(InvidiousCompilation).privacy when %>
+ <% when CompilationPrivacy::Unlisted %>
+ <%= translate(locale, "Unlisted") %>
+ <% when CompilationPrivacy::Private %>
+ <%= translate(locale, "Private") %>
+ <% end %>
+
+ <% else %>
+
+ <%= author %> |
+ <%= translate_count(locale, "generic_videos_count", compilation.video_count) %> |
+ <%= translate(locale, "Updated `x` ago", recode_date(compilation.updated, locale)) %>
+
+ <% end %>
+
+
+
+
+
<%= compilation.description_html %>
+
+
+
+
+
+
+<% if compilation.is_a?(InvidiousCompilation) && compilation.author == user.try &.email %>
+
+
+<% end %>
+
+
+<% videos.each do |compilation_video| %>
+ <%= rendered "components/compilation_video" %>
+<% end %>
+
diff --git a/src/invidious/views/components/compilation_video.ecr b/src/invidious/views/components/compilation_video.ecr
new file mode 100644
index 00000000..cca1f5cb
--- /dev/null
+++ b/src/invidious/views/components/compilation_video.ecr
@@ -0,0 +1,43 @@
+
+
+
+
+ <%- form_parameters = "action_move_video_before=1&video_index=#{compilation_video.index}&compilation_id=#{compilation_video.compid}" -%>
+
+ <%- form_parameters = "action_move_video_after=1&video_index=#{compilation_video.index}&compilation_id=#{compilation_video.compid}" -%>
+
+
+

+
+
+
+
diff --git a/src/invidious/views/components/feed_menu.ecr b/src/invidious/views/components/feed_menu.ecr
index 3dbeaf37..180a263f 100644
--- a/src/invidious/views/components/feed_menu.ecr
+++ b/src/invidious/views/components/feed_menu.ecr
@@ -1,7 +1,7 @@
- <% when SearchPlaylist, InvidiousPlaylist %>
+ <% when SearchPlaylist, InvidiousPlaylist, InvidiousCompilation %>
<%-
if item.id.starts_with? "RD"
link_url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}"
+ elsif item.id.starts_with? "IVCMP"
+ link_url = "/compilation?comp=#{item.id}"
else
link_url = "/playlist?list=#{item.id}"
end
@@ -144,6 +146,14 @@
+ <%- elsif compid_form = env.get?("add_compilation_items") -%>
+ <%- form_parameters = "action_add_video=1&video_id=#{item.id}&compilation_id=#{compid_form}&referer=#{env.get("current_page")}" -%>
+
<%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%>
<%- form_parameters = "action=remove_video&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
diff --git a/src/invidious/views/feeds/compilations.ecr b/src/invidious/views/feeds/compilations.ecr
new file mode 100644
index 00000000..7e56dd93
--- /dev/null
+++ b/src/invidious/views/feeds/compilations.ecr
@@ -0,0 +1,29 @@
+<% content_for "header" do %>
+<%= translate(locale, "Compilations") %> - Invidious
+<% end %>
+
+<%= rendered "components/feed_menu" %>
+
+
+
+
<%= translate(locale, "user_created_compilations", %(#{items_created.size})) %>
+
+
+
+
+
+
+<% items_created.each do |item| %>
+ <%= rendered "components/item" %>
+<% end %>
+
diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr
index cf8b5593..6e732d10 100644
--- a/src/invidious/views/user/preferences.ecr
+++ b/src/invidious/views/user/preferences.ecr
@@ -170,7 +170,7 @@
<% if env.get?("user") %>
- <% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %>
+ <% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists", "Compilations"} %>
<% else %>
<% feed_options = {"", "Popular", "Trending"} %>
<% end %>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 6f9ced6f..55039d7e 100644
--- a/src/invidious/views/watch.ecr
+++ b/src/invidious/views/watch.ecr
@@ -50,6 +50,9 @@ we're going to need to do it here in order to allow for translations.
"id" => video.id,
"index" => continuation,
"plid" => plid,
+ "compid" => compid,
+ "starting_timestamp_seconds" => starting_timestamp_seconds,
+ "ending_timestamp_seconds" => ending_timestamp_seconds,
"length_seconds" => video.length_seconds.to_f,
"play_next" => !video.related_videos.empty? && !plid && params.continue,
"next_video" => video.related_videos.select { |rv| rv["id"]? }[0]?.try &.["id"],
@@ -157,6 +160,34 @@ we're going to need to do it here in order to allow for translations.
<% if user %>
<% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %>
+ <% compilations = Invidious::Database::Compilations.select_user_created_compilations(user.email) %>
+ <% if !compilations.empty? %>
+
+
+
+ <% end %>
<% if !playlists.empty? %>