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 a47762ec..264283b2 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -797,5 +797,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 539974fb..f36da244 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 71c5e7da..8a6e9566 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -255,8 +255,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 26ad138f..08ab490c 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'); @@ -177,7 +230,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 38085a20..f2764e8b 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -475,6 +475,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 3987f796..f5a8bf5b 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -180,10 +180,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", @@ -245,6 +250,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", @@ -419,6 +425,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", @@ -475,6 +482,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 e0bd0101..270e999a 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -121,7 +121,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 +

+ + #{compilation["title"]} + +

+
+
    + END_HTML + + compilation["videos"].as_a.each do |video| + html += <<-END_HTML +
  1. + +
    + +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    +
    +

    #{video["title"]}

    +

    + #{video["author"]} +

    +
    +
  2. + END_HTML + end + + html += <<-END_HTML +
+
+
+ END_HTML + + html +end diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 09c2168b..97efedb2 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -30,7 +30,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 @@ -137,6 +137,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 c6754a1e..2fed2d95 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) @@ -159,7 +159,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 @@ -212,7 +212,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 e438e3b9..0047f418 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 12942906..1662b688 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 396840a4..3c027027 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 e20a7139..894b1ca9 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 d6bd9571..ea71186b 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 112535bd..232df4f2 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -99,7 +99,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 @@ -186,7 +186,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 aabe8dfc..8ec8bc3f 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 ba05da19..d1dffafc 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 @@ -178,6 +190,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 @@ -293,6 +309,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 @@ -313,6 +332,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 e38845d9..83e9f597 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 @@ -75,6 +76,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) @@ -112,7 +119,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 c218b4ef..52bb635f 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -417,9 +417,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 %> + +
+
+
+
+
+ <%= translate(locale, "Editing compilation `x`", %|"#{HTML.escape(compilation.title)}"|) %> + +
+ value="<%= HTML.escape(query.text) %>"<% end %> + placeholder="<%= translate(locale, "Search for videos") %>"> + +
+
+
+
+
+
+ + + + +<%= 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 %> + +
+

<%= title %>

+ +
+ <%- if compilation.is_a?(InvidiousCompilation) && compilation.author == user.try &.email -%> + <%- if compilation.index.size > 0 -%> + + <%- end -%> + + + + <%- 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}" -%> +
+ "> + +
+
+ +
+
+ <%= HTML.escape(compilation_video.title) %> +
+ <% if compid_form = env.get?("remove_compilation_items") %> +
+

from

+ +

to

+ +
+ <% else %> +
+

from

+ +

to

+ +
+ <% end %> +
+
+
+
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 @@
<% feed_menu = env.get("preferences").as(Preferences).feed_menu.dup %> <% if !env.get?("user") %> - <% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %> + <% feed_menu.reject! {|item| {"Subscriptions", "Playlists", "Compilations"}.includes? item} %> <% end %> <% feed_menu.each do |feed| %> diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 6d227cfc..58644b46 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,6 +1,6 @@ <%- thin_mode = env.get("preferences").as(Preferences).thin_mode - item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil + item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | InvidiousCompilation | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil author_verified = item.responds_to?(:author_verified) && item.author_verified -%> @@ -54,10 +54,12 @@

<%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %>

<%- end -%>
- <% 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=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%>
diff --git a/src/invidious/views/create_compilation.ecr b/src/invidious/views/create_compilation.ecr new file mode 100644 index 00000000..4d8fc03c --- /dev/null +++ b/src/invidious/views/create_compilation.ecr @@ -0,0 +1,39 @@ +<% content_for "header" do %> +<%= translate(locale, "Create compilation") %> - Invidious +<% end %> + +
+
+
+
+ +
+ <%= translate(locale, "Create compilation") %> + +
+ + "> +
+ +
+ + +
+ +
+ +
+ + +
+ +
+
+
+
diff --git a/src/invidious/views/edit_compilation.ecr b/src/invidious/views/edit_compilation.ecr new file mode 100644 index 00000000..a70a0db5 --- /dev/null +++ b/src/invidious/views/edit_compilation.ecr @@ -0,0 +1,60 @@ +<% title = HTML.escape(compilation.title) %> + +<% content_for "header" do %> +<%= title %> - Invidious + +<% end %> + +
+
+ +
+ +
+
+

+
+
+ +
+
+ + <%= HTML.escape(compilation.author) %> | + <%= translate_count(locale, "generic_videos_count", compilation.video_count) %> | + + +
+
+ + + +
+
+
+ +
+ <% videos.each do |compilation_video| %> + <%= rendered "components/compilation_video" %> + <% end %> +
+
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})) %>

+
+
+

+ "><%= translate(locale, "Create compilation") %> +

+
+
+

+ "> + <%= translate(locale, "Import") %> + +

+
+
+ +
+<% 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 55349c5a..78f9fee9 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -165,7 +165,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 7a1cf2c3..c96ff965 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? %>
@@ -302,16 +333,18 @@ we're going to need to do it here in order to allow for translations.
- <% if params.related_videos || plid %> + <% if params.related_videos || plid || compid%>
<% if plid %>
+ <% elsif compid %> +
<% end %> <% if params.related_videos %>
<% if !video.related_videos.empty? %> -
style="display:none"<% end %>> +
style="display:none"<% end %>>
checked<% end %>>