diff --git a/assets/css/darktheme.css b/assets/css/darktheme.css index 1b70956b..92da15b6 100644 --- a/assets/css/darktheme.css +++ b/assets/css/darktheme.css @@ -21,10 +21,9 @@ body { color: #f0f0f0; } -.pure-form > fieldset > input, -.pure-control-group > input, -.pure-form > fieldset > select, -.pure-control-group > select { +input, +select, +textarea { color: rgba(35, 35, 35, 1); } diff --git a/assets/js/embed.js b/assets/js/embed.js index d9af1f5b..074a9d8d 100644 --- a/assets/js/embed.js +++ b/assets/js/embed.js @@ -12,7 +12,8 @@ function get_playlist(plid, retries) { '&format=html&hl=' + video_data.preferences.locale; } else { var plid_url = '/api/v1/playlists/' + plid + - '?continuation=' + video_data.id + + '?index=' + video_data.index + + '&continuation' + video_data.id + '&format=html&hl=' + video_data.preferences.locale; } @@ -45,6 +46,9 @@ function get_playlist(plid, retries) { } url.searchParams.set('list', plid); + if (!plid.startsWith('RD')) { + url.searchParams.set('index', xhr.response.index); + } location.assign(url.pathname + url.search); }); } diff --git a/assets/js/playlist_widget.js b/assets/js/playlist_widget.js new file mode 100644 index 00000000..5d6ddf87 --- /dev/null +++ b/assets/js/playlist_widget.js @@ -0,0 +1,47 @@ +function add_playlist_item(target) { + var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; + tile.style.display = 'none'; + + var url = '/playlist_ajax?action_add_video=1&redirect=false' + + '&video_id=' + target.getAttribute('data-id') + + '&playlist_id=' + target.getAttribute('data-plid'); + var xhr = new XMLHttpRequest(); + xhr.responseType = 'json'; + xhr.timeout = 10000; + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status != 200) { + tile.style.display = ''; + } + } + } + + xhr.send('csrf_token=' + playlist_data.csrf_token); +} + +function remove_playlist_item(target) { + var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; + tile.style.display = 'none'; + + var url = '/playlist_ajax?action_remove_video=1&redirect=false' + + '&set_video_id=' + target.getAttribute('data-index') + + '&playlist_id=' + target.getAttribute('data-plid'); + var xhr = new XMLHttpRequest(); + xhr.responseType = 'json'; + xhr.timeout = 10000; + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status != 200) { + tile.style.display = ''; + } + } + } + + xhr.send('csrf_token=' + playlist_data.csrf_token); +} \ No newline at end of file diff --git a/assets/js/watch.js b/assets/js/watch.js index 0f3e8123..80cb1769 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -133,7 +133,8 @@ function get_playlist(plid, retries) { '&format=html&hl=' + video_data.preferences.locale; } else { var plid_url = '/api/v1/playlists/' + plid + - '?continuation=' + video_data.id + + '?index=' + video_data.index + + '&continuation=' + video_data.id + '&format=html&hl=' + video_data.preferences.locale; } @@ -168,6 +169,9 @@ function get_playlist(plid, retries) { } url.searchParams.set('list', plid); + if (!plid.startsWith('RD')) { + url.searchParams.set('index', xhr.response.index); + } location.assign(url.pathname + url.search); }); } diff --git a/config/sql/playlist_videos.sql b/config/sql/playlist_videos.sql new file mode 100644 index 00000000..b2b8d5c4 --- /dev/null +++ b/config/sql/playlist_videos.sql @@ -0,0 +1,19 @@ +-- Table: public.playlist_videos + +-- DROP TABLE public.playlist_videos; + +CREATE TABLE playlist_videos +( + title text, + id text, + author text, + ucid text, + length_seconds integer, + published timestamptz, + plid text references playlists(id), + index int8, + live_now boolean, + PRIMARY KEY (index,plid) +); + +GRANT ALL ON TABLE public.playlist_videos TO kemal; diff --git a/config/sql/playlists.sql b/config/sql/playlists.sql new file mode 100644 index 00000000..46ff30ec --- /dev/null +++ b/config/sql/playlists.sql @@ -0,0 +1,18 @@ +-- Table: public.playlists + +-- DROP TABLE public.playlists; + +CREATE TABLE public.playlists +( + title text, + id text primary key, + author text, + description text, + video_count integer, + created timestamptz, + updated timestamptz, + privacy privacy, + index int8[] +); + +GRANT ALL ON public.playlists TO kemal; diff --git a/config/sql/privacy.sql b/config/sql/privacy.sql new file mode 100644 index 00000000..4356813e --- /dev/null +++ b/config/sql/privacy.sql @@ -0,0 +1,10 @@ +-- Type: public.privacy + +-- DROP TYPE public.privacy; + +CREATE TYPE public.privacy AS ENUM +( + 'Public', + 'Unlisted', + 'Private' +); diff --git a/locales/ar.json b/locales/ar.json index c29a88ab..182feed5 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.", "View privacy policy.": "عرض سياسة الخصوصية.", "Trending": "الشائع", + "Public": "", "Unlisted": "غير مصنف", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", "Hide annotations": "إخفاء الملاحظات فى الفيديو", "Show annotations": "عرض الملاحظات فى الفيديو", @@ -322,4 +332,4 @@ "Playlists": "قوائم التشغيل", "Community": "المجتمع", "Current version: ": "الإصدار الحالي: " -} +} \ No newline at end of file diff --git a/locales/de.json b/locales/de.json index 03cdd398..2d604115 100644 --- a/locales/de.json +++ b/locales/de.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View privacy policy.": "Datenschutzerklärung einsehen.", "Trending": "Trending", + "Public": "", "Unlisted": "Nicht aufgeführt", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Video auf YouTube ansehen", "Hide annotations": "Anmerkungen ausblenden", "Show annotations": "Anmerkungen anzeigen", @@ -322,4 +332,4 @@ "Playlists": "Wiedergabelisten", "Community": "Gemeinschaft", "Current version: ": "Aktuelle Version: " -} +} \ No newline at end of file diff --git a/locales/el.json b/locales/el.json index 222b7d0a..063d724b 100644 --- a/locales/el.json +++ b/locales/el.json @@ -141,7 +141,17 @@ "View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.", "View privacy policy.": "Προβολή πολιτικής απορρήτου.", "Trending": "Τάσεις", + "Public": "", "Unlisted": "Κρυφό", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Προβολή στο YouTube", "Hide annotations": "Απόκρυψη σημειώσεων", "Show annotations": "Προβολή σημειώσεων", diff --git a/locales/en-US.json b/locales/en-US.json index 8aaeee48..e0b2dab4 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -7,6 +7,10 @@ "([^0-9]|^)1([^,0-9]|$)": "`x` video", "": "`x` videos" }, + "`x` playlists": { + "(\\D|^)1(\\D|$)": "`x` playlist", + "": "`x` playlists" + }, "LIVE": "LIVE", "Shared `x` ago": "Shared `x` ago", "Unsubscribe": "Unsubscribe", @@ -74,11 +78,11 @@ "Show related videos: ": "Show related videos: ", "Show annotations by default: ": "Show annotations by default: ", "Visual preferences": "Visual preferences", - "Player style: ": "", + "Player style: ": "Player style: ", "Dark mode: ": "Dark mode: ", - "Theme: ": "", - "dark": "", - "light": "", + "Theme: ": "Theme: ", + "dark": "dark", + "light": "light", "Thin mode: ": "Thin mode: ", "Subscription preferences": "Subscription preferences", "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", @@ -141,7 +145,17 @@ "View JavaScript license information.": "View JavaScript license information.", "View privacy policy.": "View privacy policy.", "Trending": "Trending", + "Public": "Public", "Unlisted": "Unlisted", + "Private": "Private", + "View all playlists": "View all playlists", + "Updated `x` ago": "Updated `x` ago", + "Delete playlist `x`?": "Delete playlist `x`?", + "Delete playlist": "Delete playlist", + "Create playlist": "Create playlist", + "Title": "Title", + "Playlist privacy": "Playlist privacy", + "Editing playlist `x`": "Editing playlist `x`", "Watch on YouTube": "Watch on YouTube", "Hide annotations": "Hide annotations", "Show annotations": "Show annotations", @@ -162,7 +176,10 @@ "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.", "View YouTube comments": "View YouTube comments", "View more comments on Reddit": "View more comments on Reddit", - "View `x` comments": "View `x` comments", + "View `x` comments": { + "(\\D|^)1(\\D|$)": "View `x` comment", + "": "View `x` comments" + }, "View Reddit comments": "View Reddit comments", "Hide replies": "Hide replies", "Show replies": "Show replies", @@ -359,7 +376,7 @@ "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(edited)", "YouTube comment permalink": "YouTube comment permalink", - "permalink": "", + "permalink": "permalink", "`x` marked it with a ❤": "`x` marked it with a ❤", "Audio mode": "Audio mode", "Video mode": "Video mode", diff --git a/locales/eo.json b/locales/eo.json index cbdccfca..2da8c9ed 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.", "View privacy policy.": "Vidi regularon pri privateco.", "Trending": "Tendencoj", + "Public": "", "Unlisted": "Ne listigita", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Vidi videon en Youtube", "Hide annotations": "Kaŝi prinotojn", "Show annotations": "Montri prinotojn", @@ -322,4 +332,4 @@ "Playlists": "Ludlistoj", "Community": "Komunumo", "Current version: ": "Nuna versio: " -} +} \ No newline at end of file diff --git a/locales/es.json b/locales/es.json index cafbf12e..cca88e76 100644 --- a/locales/es.json +++ b/locales/es.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Ver información de licencia de JavaScript.", "View privacy policy.": "Ver la política de privacidad.", "Trending": "Tendencias", + "Public": "", "Unlisted": "No listado", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Ver el vídeo en Youtube", "Hide annotations": "Ocultar anotaciones", "Show annotations": "Mostrar anotaciones", @@ -322,4 +332,4 @@ "Playlists": "Listas de reproducción", "Community": "", "Current version: ": "Versión actual: " -} +} \ No newline at end of file diff --git a/locales/eu.json b/locales/eu.json index cbdbbefc..c65f38a8 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "", "View privacy policy.": "", "Trending": "", + "Public": "", "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "", "Hide annotations": "", "Show annotations": "", diff --git a/locales/fr.json b/locales/fr.json index 80579a66..4e9d89ed 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Informations des licences JavaScript.", "View privacy policy.": "Politique de confidentialité.", "Trending": "Tendances", + "Public": "", "Unlisted": "Non répertoriée", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Voir la vidéo sur Youtube", "Hide annotations": "Masquer les annotations", "Show annotations": "Afficher les annotations", @@ -322,4 +332,4 @@ "Playlists": "Liste de lecture", "Community": "Communauté", "Current version: ": "Version actuelle : " -} +} \ No newline at end of file diff --git a/locales/is.json b/locales/is.json index 808063c4..bbf0411b 100644 --- a/locales/is.json +++ b/locales/is.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.", "View privacy policy.": "Skoða meðferð persónuupplýsinga.", "Trending": "Vinsælt", + "Public": "", "Unlisted": "Óskráð", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Horfa á YouTube", "Hide annotations": "Fela glósur", "Show annotations": "Sýna glósur", @@ -320,4 +330,4 @@ "Videos": "Myndbönd", "Playlists": "Spilunarlistar", "Current version: ": "Núverandi útgáfa: " -} +} \ No newline at end of file diff --git a/locales/it.json b/locales/it.json index c2cd5d30..3878cca9 100644 --- a/locales/it.json +++ b/locales/it.json @@ -141,7 +141,17 @@ "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", "View privacy policy.": "Vedi la politica sulla privacy", "Trending": "Tendenze", + "Public": "", "Unlisted": "Non elencati", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Guarda su YouTube", "Hide annotations": "Nascondi annotazioni", "Show annotations": "Mostra annotazioni", diff --git a/locales/nb_NO.json b/locales/nb_NO.json index 9028d285..1fba258e 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View privacy policy.": "Vis personvernspraksis.", "Trending": "Trendsettende", + "Public": "", "Unlisted": "Ulistet", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Vis video på YouTube", "Hide annotations": "Skjul merknader", "Show annotations": "Vis merknader", @@ -322,4 +332,4 @@ "Playlists": "Spillelister", "Community": "Gemenskap", "Current version: ": "Nåværende versjon: " -} +} \ No newline at end of file diff --git a/locales/nl.json b/locales/nl.json index 3e2c6c64..5af8ae75 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "JavaScript-licentieinformatie tonen.", "View privacy policy.": "Privacybeleid tonen", "Trending": "Uitgelicht", + "Public": "", "Unlisted": "Verborgen", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Video bekijken op YouTube", "Hide annotations": "Annotaties verbergen", "Show annotations": "Annotaties tonen", diff --git a/locales/pl.json b/locales/pl.json index 1e3a2068..44767751 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Wyświetl informację o licencji JavaScript.", "View privacy policy.": "Polityka prywatności.", "Trending": "Na czasie", + "Public": "", "Unlisted": "", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Zobacz film na YouTube", "Hide annotations": "", "Show annotations": "", diff --git a/locales/ru.json b/locales/ru.json index 90aa4a3b..1fd540a3 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.", "View privacy policy.": "Посмотреть политику конфиденциальности.", "Trending": "В тренде", + "Public": "", "Unlisted": "Нет в списке", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Смотреть на YouTube", "Hide annotations": "Скрыть аннотации", "Show annotations": "Показать аннотации", diff --git a/locales/uk.json b/locales/uk.json index e537008c..53b0c571 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.", "View privacy policy.": "Переглянути політику приватності.", "Trending": "У тренді", + "Public": "", "Unlisted": "Немає в списку", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "Дивитися на YouTube", "Hide annotations": "Приховати анотації", "Show annotations": "Показати анотації", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 23617d04..ba91d34e 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -126,7 +126,17 @@ "View JavaScript license information.": "查看 JavaScript 协议信息。", "View privacy policy.": "查看隐私政策。", "Trending": "时下流行", + "Public": "", "Unlisted": "不公开", + "Private": "", + "View all playlists": "", + "Updated `x` ago": "", + "Delete playlist `x`?": "", + "Delete playlist": "", + "Create playlist": "", + "Title": "", + "Playlist privacy": "", + "Editing playlist `x`": "", "Watch on YouTube": "在 YouTube 观看", "Hide annotations": "隐藏注释", "Show annotations": "显示注释", diff --git a/src/invidious.cr b/src/invidious.cr index 4cdf8932..ad313269 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -126,15 +126,19 @@ Kemal::CLI.new ARGV # Check table integrity if CONFIG.check_tables - analyze_table(PG_DB, logger, "channels", InvidiousChannel) - analyze_table(PG_DB, logger, "channel_videos", ChannelVideo) - analyze_table(PG_DB, logger, "nonces", Nonce) - analyze_table(PG_DB, logger, "session_ids", SessionId) - analyze_table(PG_DB, logger, "users", User) - analyze_table(PG_DB, logger, "videos", Video) + check_enum(PG_DB, logger, "privacy", PlaylistPrivacy) + + check_table(PG_DB, logger, "channels", InvidiousChannel) + check_table(PG_DB, logger, "channel_videos", ChannelVideo) + check_table(PG_DB, logger, "playlists", InvidiousPlaylist) + check_table(PG_DB, logger, "playlist_videos", PlaylistVideo) + check_table(PG_DB, logger, "nonces", Nonce) + check_table(PG_DB, logger, "session_ids", SessionId) + check_table(PG_DB, logger, "users", User) + check_table(PG_DB, logger, "videos", Video) if CONFIG.cache_annotations - analyze_table(PG_DB, logger, "annotations", Annotation) + check_table(PG_DB, logger, "annotations", Annotation) end end @@ -248,7 +252,14 @@ before_all do |env| if !env.request.cookies.has_key? "SSID" if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) - csrf_token = generate_response(sid, {":signout", ":watch_ajax", ":subscription_ajax", ":token_ajax", ":authorize_token"}, HMAC_KEY, PG_DB, 1.week) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, PG_DB, 1.week) preferences = user.preferences @@ -262,7 +273,14 @@ before_all do |env| begin user, sid = get_user(sid, headers, PG_DB, false) - csrf_token = generate_response(sid, {":signout", ":watch_ajax", ":subscription_ajax", ":token_ajax", ":authorize_token"}, HMAC_KEY, PG_DB, 1.week) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, PG_DB, 1.week) preferences = user.preferences @@ -371,6 +389,8 @@ get "/watch" do |env| end plid = env.params.query["list"]? + continuation = process_continuation(PG_DB, env.params.query, plid, id) + nojs = env.params.query["nojs"]? nojs ||= "0" @@ -555,7 +575,9 @@ get "/embed/" do |env| if plid = env.params.query["list"]? begin - videos = fetch_playlist_videos(plid, 1, 1, locale: locale) + playlist = get_playlist(PG_DB, plid, locale: locale) + offset = env.params.query["index"]?.try &.to_i? || 0 + videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) rescue ex error_message = ex.message env.response.status_code = 500 @@ -577,7 +599,9 @@ end get "/embed/:id" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? id = env.params.url["id"] + plid = env.params.query["list"]? + continuation = process_continuation(PG_DB, env.params.query, plid, id) if md = env.params.query["playlist"]? .try &.match(/[a-zA-Z0-9_-]{11}(,[a-zA-Z0-9_-]{11})*/) @@ -607,7 +631,9 @@ get "/embed/:id" do |env| if plid begin - videos = fetch_playlist_videos(plid, 1, 1, locale: locale) + playlist = get_playlist(PG_DB, plid, locale: locale) + offset = env.params.query["index"]?.try &.to_i? || 0 + videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) rescue ex error_message = ex.message env.response.status_code = 500 @@ -757,10 +783,447 @@ end # Playlists +get "/view_all_playlists" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + + items = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 ORDER BY created", user.email, as: InvidiousPlaylist) + items.map! do |item| + item.author = "" + item + end + + templated "view_all_playlists" +end + +get "/create_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB) + + templated "create_playlist" +end + +post "/create_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + rescue ex + error_message = ex.message + env.response.status_code = 400 + next templated "error" + end + + title = env.params.body["title"]?.try &.as(String) + if !title || title.empty? + error_message = "Title cannot be empty." + next templated "error" + end + + privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "") + if !privacy + error_message = "Invalid privacy setting." + next templated "error" + end + + if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + error_message = "User cannot have more than 100 playlists." + next templated "error" + end + + playlist = create_playlist(PG_DB, title, privacy, user) + + env.redirect "/playlist?list=#{playlist.id}" +end + +get "/delete_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + + plid = env.params.query["list"]? + if !plid || !plid.starts_with?("IV") + next env.redirect referer + end + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + + csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB) + + templated "delete_playlist" +end + +post "/delete_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + plid = env.params.query["list"]? + if !plid + next env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + rescue ex + error_message = ex.message + env.response.status_code = 400 + next templated "error" + end + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + + PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) + PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + + env.redirect "/view_all_playlists" +end + +get "/edit_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + + plid = env.params.query["list"]? + if !plid || !plid.starts_with?("IV") + next env.redirect referer + end + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + begin + playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + rescue ex + next env.redirect referer + end + + begin + videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) + rescue ex + videos = [] of PlaylistVideo + end + + csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB) + + templated "edit_playlist" +end + +post "/edit_playlist" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + plid = env.params.query["list"]? + if !plid + next env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + rescue ex + error_message = ex.message + env.response.status_code = 400 + next templated "error" + end + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + + title = env.params.body["title"]?.try &.delete("<>") || "" + privacy = PlaylistPrivacy.parse(env.params.body["privacy"]? || "Public") + description = env.params.body["description"]?.try &.delete("\r") || "" + + if title != playlist.title || + privacy != playlist.privacy || + description != playlist.description + updated = Time.utc + else + updated = playlist.updated + end + + PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) + + env.redirect "/playlist?list=#{plid}" +end + +get "/add_playlist_items" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + next env.redirect "/" + end + + user = user.as(User) + sid = sid.as(String) + + plid = env.params.query["list"]? + if !plid || !plid.starts_with?("IV") + next env.redirect referer + end + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + begin + playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email + next env.redirect referer + end + rescue ex + next env.redirect referer + end + + query = env.params.query["q"]? + if query + begin + search_query, count, items = process_search_query(query, page, user, region: nil) + videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) } + rescue ex + videos = [] of SearchVideo + count = 0 + end + else + videos = [] of SearchVideo + count = 0 + end + + env.set "add_playlist_items", plid + templated "add_playlist_items" +end + +post "/playlist_ajax" do |env| + locale = LOCALES[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 + next env.redirect referer + else + error_message = {"error" => "No such user"}.to_json + env.response.status_code = 403 + next error_message + 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, PG_DB, locale) + rescue ex + if redirect + error_message = ex.message + env.response.status_code = 400 + next templated "error" + else + error_message = {"error" => ex.message}.to_json + env.response.status_code = 400 + next error_message + end + end + + if env.params.query["action_create_playlist"]? + action = "action_create_playlist" + elsif env.params.query["action_delete_playlist"]? + action = "action_delete_playlist" + elsif env.params.query["action_edit_playlist"]? + action = "action_edit_playlist" + elsif env.params.query["action_add_video"]? + action = "action_add_video" + video_id = env.params.query["video_id"] + elsif env.params.query["action_remove_video"]? + action = "action_remove_video" + elsif env.params.query["action_move_video_before"]? + action = "action_move_video_before" + else + next env.redirect referer + end + + begin + playlist_id = env.params.query["playlist_id"] + playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist) + raise "Invalid user" if playlist.author != user.email + rescue ex + if redirect + error_message = ex.message + env.response.status_code = 400 + next templated "error" + else + error_message = {"error" => ex.message}.to_json + env.response.status_code = 400 + next error_message + end + end + + if !user.password + # TODO: Playlist stub, sync with YouTube for Google accounts + # playlist_ajax(playlist_id, action, env.request.headers) + end + email = user.email + + case action + when "action_edit_playlist" + # TODO: Playlist stub + when "action_add_video" + if playlist.index.size >= 500 + env.response.status_code = 400 + if redirect + error_message = "Playlist cannot have more than 500 videos" + next templated "error" + else + error_message = {"error" => "Playlist cannot have more than 500 videos"}.to_json + next error_message + end + end + + video_id = env.params.query["video_id"] + + begin + video = get_video(video_id, PG_DB) + rescue ex + env.response.status_code = 500 + if redirect + error_message = ex.message + next templated "error" + else + error_message = {"error" => ex.message}.to_json + next error_message + end + end + + playlist_video = PlaylistVideo.new( + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: playlist_id, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX) + ) + + video_array = playlist_video.to_a + args = arg_array(video_array) + + PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) + PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = video_count + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id) + when "action_remove_video" + index = env.params.query["set_video_id"] + PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) + PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = video_count - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id) + when "action_move_video_before" + # TODO: Playlist stub + end + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end +end + get "/playlist" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? + user = env.get?("user").try &.as(User) plid = env.params.query["list"]? + referer = get_referer(env) + if !plid next env.redirect "/" end @@ -773,19 +1236,29 @@ get "/playlist" do |env| end begin - playlist = fetch_playlist(plid, locale) + playlist = get_playlist(PG_DB, plid, locale) rescue ex error_message = ex.message env.response.status_code = 500 next templated "error" end + if playlist.privacy == PlaylistPrivacy::Private && playlist.author != user.try &.email + error_message = "This playlist is private." + env.response.status_code = 403 + next templated "error" + end + begin - videos = fetch_playlist_videos(plid, page, playlist.video_count, locale: locale) + videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) rescue ex videos = [] of PlaylistVideo end + if playlist.author == user.try &.email + env.set "remove_playlist_items", plid + end + templated "playlist" end @@ -864,72 +1337,13 @@ get "/search" do |env| page ||= 1 user = env.get? "user" - if user - user = user.as(User) - view_name = "subscriptions_#{sha256(user.email)}" - end - channel = nil - content_type = "all" - date = "" - duration = "" - features = [] of String - sort = "relevance" - subscriptions = nil - - operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) } - operators.each do |operator| - key, value = operator.downcase.split(":") - - case key - when "channel", "user" - channel = operator.split(":")[-1] - when "content_type", "type" - content_type = value - when "date" - date = value - when "duration" - duration = value - when "feature", "features" - features = value.split(",") - when "sort" - sort = value - when "subscriptions" - subscriptions = value == "true" - else - operators.delete(operator) - end - end - - search_query = (query.split(" ") - operators).join(" ") - - if channel - count, videos = channel_search(search_query, page, channel) - elsif subscriptions - if view_name - videos = PG_DB.query_all("SELECT id,title,published,updated,ucid,author,length_seconds FROM ( - SELECT *, - to_tsvector(#{view_name}.title) || - to_tsvector(#{view_name}.author) - as document - FROM #{view_name} - ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", search_query, (page - 1) * 20, as: ChannelVideo) - count = videos.size - else - videos = [] of ChannelVideo - count = 0 - end - else - begin - search_params = produce_search_params(sort: sort, date: date, content_type: content_type, - duration: duration, features: features) - rescue ex - error_message = ex.message - env.response.status_code = 500 - next templated "error" - end - - count, videos = search(search_query, page, search_params, region).as(Tuple) + begin + search_query, count, videos = process_search_query(query, page, user, region: nil) + rescue ex + error_message = ex.message + env.response.status_code = 500 + next templated "error" end env.set "search", query @@ -1746,13 +2160,12 @@ post "/watch_ajax" do |env| begin validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex + env.response.status_code = 400 if redirect error_message = ex.message - env.response.status_code = 400 next templated "error" else error_message = {"error" => ex.message}.to_json - env.response.status_code = 400 next error_message end end @@ -2771,6 +3184,35 @@ get "/feed/playlist/:plid" do |env| host_url = make_host_url(config, Kemal.config) path = env.request.path + if plid.starts_with? "IV" + if playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale) + + next XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", + "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", + "xml:lang": "en-US") do + xml.element("link", rel: "self", href: "#{host_url}#{env.request.resource}") + xml.element("id") { xml.text "iv:playlist:#{plid}" } + xml.element("iv:playlistId") { xml.text plid } + xml.element("title") { xml.text playlist.title } + xml.element("link", rel: "alternate", href: "#{host_url}/playlist?list=#{plid}") + + xml.element("author") do + xml.element("name") { xml.text playlist.author } + end + + videos.each do |video| + video.to_xml(host_url, false, xml) + end + end + end + else + env.response.status_code = 404 + next + end + end + client = make_client(YT_URL) response = client.get("/feeds/videos.xml?playlist_id=#{plid}") document = XML.parse(response.body) @@ -4125,92 +4567,58 @@ get "/api/v1/search/suggestions" do |env| end end -get "/api/v1/playlists/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route| + get route do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - plid = env.params.url["plid"] + env.response.content_type = "application/json" + plid = env.params.url["plid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 + offset = env.params.query["index"]?.try &.to_i? + offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } + offset ||= 0 - format = env.params.query["format"]? - format ||= "json" + continuation = env.params.query["continuation"]? - continuation = env.params.query["continuation"]? + format = env.params.query["format"]? + format ||= "json" - if plid.starts_with? "RD" - next env.redirect "/api/v1/mixes/#{plid}" - end - - begin - playlist = fetch_playlist(plid, locale) - rescue ex - error_message = {"error" => "Playlist is empty"}.to_json - env.response.status_code = 410 - next error_message - end - - begin - videos = fetch_playlist_videos(plid, page, playlist.video_count, continuation, locale) - rescue ex - videos = [] of PlaylistVideo - end - - response = JSON.build do |json| - json.object do - json.field "type", "playlist" - json.field "title", playlist.title - json.field "playlistId", playlist.id - json.field "playlistThumbnail", playlist.thumbnail - - json.field "author", playlist.author - json.field "authorId", playlist.ucid - json.field "authorUrl", "/channel/#{playlist.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", playlist.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "description", html_to_content(playlist.description_html) - json.field "descriptionHtml", playlist.description_html - json.field "videoCount", playlist.video_count - - json.field "viewCount", playlist.views - json.field "updated", playlist.updated.to_unix - - json.field "videos" do - json.array do - videos.each do |video| - video.to_json(locale, config, Kemal.config, json) - end - end - end + if plid.starts_with? "RD" + next env.redirect "/api/v1/mixes/#{plid}" end + + begin + playlist = get_playlist(PG_DB, plid, locale) + rescue ex + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + user = env.get?("user").try &.as(User) + if !playlist || !playlist.privacy.public? && playlist.author != user.try &.email + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + response = playlist.to_json(offset, locale, config, Kemal.config, continuation: continuation) + + if format == "html" + response = JSON.parse(response) + playlist_html = template_playlist(response) + index = response["videos"].as_a[1]?.try &.["index"] + next_video = response["videos"].as_a[1]?.try &.["videoId"] + + response = { + "playlistHtml" => playlist_html, + "index" => index, + "nextVideo" => next_video, + }.to_json + end + + response end - - if format == "html" - response = JSON.parse(response) - playlist_html = template_playlist(response) - next_video = response["videos"].as_a[1]?.try &.["videoId"] - - response = { - "playlistHtml" => playlist_html, - "nextVideo" => next_video, - }.to_json - end - - response end get "/api/v1/mixes/:rdid" do |env| @@ -4418,6 +4826,224 @@ delete "/api/v1/auth/subscriptions/:ucid" do |env| env.response.status_code = 204 end +get "/api/v1/auth/playlists" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1", user.email, as: InvidiousPlaylist) + + JSON.build do |json| + json.array do + playlists.each do |playlist| + playlist.to_json(0, locale, config, Kemal.config, json) + end + end + end +end + +post "/api/v1/auth/playlists" do |env| + env.response.content_type = "application/json" + user = env.get("user").as(User) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) + if !title + error_message = {"error" => "Invalid title."}.to_json + env.response.status_code = 400 + next error_message + end + + privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } + if !privacy + error_message = {"error" => "Invalid privacy setting."}.to_json + env.response.status_code = 400 + next error_message + end + + if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + error_message = {"error" => "User cannot have more than 100 playlists."}.to_json + env.response.status_code = 400 + next error_message + end + + host_url = make_host_url(config, Kemal.config) + + playlist = create_playlist(PG_DB, title, privacy, user) + env.response.headers["Location"] = "#{host_url}/api/v1/auth/playlists/#{playlist.id}" + env.response.status_code = 201 + { + "title" => title, + "playlistId" => playlist.id, + }.to_json +end + +patch "/api/v1/auth/playlists/:plid" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && !playlist.privacy.public? + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + if playlist.author != user.email + env.response.status_code = 403 + error_message = {"error" => "Invalid user"}.to_json + next error_message + end + + title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title + privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } || playlist.privacy + description = env.params.json["description"]?.try &.as(String).delete("\r") || playlist.description + + if title != playlist.title || + privacy != playlist.privacy || + description != playlist.description + updated = Time.utc + else + updated = playlist.updated + end + + PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) + env.response.status_code = 204 +end + +delete "/api/v1/auth/playlists/:plid" do |env| + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && !playlist.privacy.public? + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + if playlist.author != user.email + env.response.status_code = 403 + error_message = {"error" => "Invalid user"}.to_json + next error_message + end + + PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) + PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + + env.response.status_code = 204 +end + +post "/api/v1/auth/playlists/:plid/videos" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && !playlist.privacy.public? + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + if playlist.author != user.email + env.response.status_code = 403 + error_message = {"error" => "Invalid user"}.to_json + next error_message + end + + if playlist.index.size >= 500 + env.response.status_code = 400 + error_message = {"error" => "Playlist cannot have more than 500 videos"}.to_json + next error_message + end + + video_id = env.params.json["videoId"].try &.as(String) + if !video_id + env.response.status_code = 403 + error_message = {"error" => "Invalid videoId"}.to_json + next error_message + end + + begin + video = get_video(video_id, PG_DB) + rescue ex + error_message = {"error" => ex.message}.to_json + env.response.status_code = 500 + next error_message + end + + playlist_video = PlaylistVideo.new( + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: plid, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX) + ) + + video_array = playlist_video.to_a + args = arg_array(video_array) + + PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) + PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = video_count + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) + + host_url = make_host_url(config, Kemal.config) + + env.response.headers["Location"] = "#{host_url}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index}" + env.response.status_code = 201 + playlist_video.to_json(locale, config, Kemal.config, index: playlist.index.size) +end + +delete "/api/v1/auth/playlists/:plid/videos/:index" do |env| + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + index = env.params.url["index"].to_i64(16) + + playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !playlist || playlist.author != user.email && !playlist.privacy.public? + env.response.status_code = 404 + error_message = {"error" => "Playlist does not exist."}.to_json + next error_message + end + + if playlist.author != user.email + env.response.status_code = 403 + error_message = {"error" => "Invalid user"}.to_json + next error_message + end + + if !playlist.index.includes? index + env.response.status_code = 404 + error_message = {"error" => "Playlist does not contain index"}.to_json + next error_message + end + + PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) + PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = video_count - 1, updated = $2 WHERE id = $3", index, Time.utc, plid) + + env.response.status_code = 204 +end + +# patch "/api/v1/auth/playlists/:plid/videos/:index" do |env| +# TODO: Playlist stub +# end + get "/api/v1/auth/tokens" do |env| env.response.content_type = "application/json" user = env.get("user").as(User) diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index f2240691..a3dfd062 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -129,7 +129,7 @@ class AuthHandler < Kemal::Handler error_message = {"error" => ex.message}.to_json env.response.status_code = 403 - env.response.puts error_message + env.response.print error_message end end end @@ -159,7 +159,8 @@ class APIHandler < Kemal::Handler env.response.output.rewind - if env.response.headers.includes_word?("Content-Type", "application/json") + if env.response.output.as(IO::Memory).size != 0 && + env.response.headers.includes_word?("Content-Type", "application/json") response = JSON.parse(env.response.output) if fields_text = env.params.query["fields"]? @@ -194,7 +195,7 @@ class APIHandler < Kemal::Handler end ensure env.response.output = output - env.response.puts response + env.response.print response env.response.flush end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 615e62df..d227fdf9 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -598,7 +598,17 @@ def extract_shelf_items(nodeset, ucid = nil, author_name = nil) return items end -def analyze_table(db, logger, table_name, struct_type = nil) +def check_enum(db, logger, enum_name, struct_type = nil) + if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) + logger.puts("CREATE TYPE #{enum_name}") + + db.using_connection do |conn| + conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql")) + end + end +end + +def check_table(db, logger, table_name, struct_type = nil) # Create table if it doesn't exist begin db.exec("SELECT * FROM #{table_name} LIMIT 0") diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index a5383daf..f65e434d 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -1,5 +1,51 @@ struct PlaylistVideo - def to_json(locale, config, kemal_config, json : JSON::Builder) + def to_xml(host_url, auto_generated, 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("link", rel: "alternate", href: "#{host_url}/watch?v=#{self.id}") + + xml.element("author") do + if auto_generated + xml.element("name") { xml.text self.author } + xml.element("uri") { xml.text "#{host_url}/channel/#{self.ucid}" } + else + xml.element("name") { xml.text author } + xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" } + end + 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(host_url, auto_generated, xml : XML::Builder? = nil) + if xml + to_xml(host_url, auto_generated, xml) + else + XML.build do |json| + to_xml(host_url, auto_generated, xml) + end + end + end + + def to_json(locale, config, kemal_config, json : JSON::Builder, index : Int32?) json.object do json.field "title", self.title json.field "videoId", self.id @@ -12,17 +58,23 @@ struct PlaylistVideo generate_thumbnails(json, self.id, config, kemal_config) end - json.field "index", self.index + 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 "lengthSeconds", self.length_seconds end end - def to_json(locale, config, kemal_config, json : JSON::Builder | Nil = nil) + def to_json(locale, config, kemal_config, json : JSON::Builder? = nil, index : Int32? = nil) if json - to_json(locale, config, kemal_config, json) + to_json(locale, config, kemal_config, json, index: index) else JSON.build do |json| - to_json(locale, config, kemal_config, json) + to_json(locale, config, kemal_config, json, index: index) end end end @@ -35,12 +87,66 @@ struct PlaylistVideo length_seconds: Int32, published: Time, plid: String, - index: Int32, + index: Int64, live_now: Bool, }) end struct Playlist + def to_json(offset, locale, config, kemal_config, json : JSON::Builder, continuation : String? = nil) + json.object do + json.field "type", "playlist" + json.field "title", self.title + json.field "playlistId", self.id + json.field "playlistThumbnail", 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", 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 "isListed", self.privacy.public? + + json.field "videos" do + json.array do + videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) + videos.each_with_index do |video, index| + video.to_json(locale, config, Kemal.config, json) + end + end + end + end + end + + def to_json(offset, locale, config, kemal_config, json : JSON::Builder? = nil, continuation : String? = nil) + if json + to_json(offset, locale, config, kemal_config, json, continuation: continuation) + else + JSON.build do |json| + to_json(offset, locale, config, kemal_config, json, continuation: continuation) + end + end + end + db_mapping({ title: String, id: String, @@ -53,57 +159,122 @@ struct Playlist updated: Time, thumbnail: String?, }) + + def privacy + PlaylistPrivacy::Public + end end -def fetch_playlist_videos(plid, page, video_count, continuation = nil, locale = nil) - client = make_client(YT_URL) +enum PlaylistPrivacy + Public = 0 + Unlisted = 1 + Private = 2 +end - if continuation - html = client.get("/watch?v=#{continuation}&list=#{plid}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") - html = XML.parse_html(html.body) +struct InvidiousPlaylist + def to_json(offset, locale, config, kemal_config, json : JSON::Builder, continuation : String? = nil) + json.object do + json.field "type", "invidiousPlaylist" + json.field "title", self.title + json.field "playlistId", self.id - index = html.xpath_node(%q(//span[@id="playlist-current-index"])).try &.content.to_i? - if index - index -= 1 - end - index ||= 0 - else - index = (page - 1) * 100 - end + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", nil + json.field "authorThumbnails", [] of String - if video_count > 100 - url = produce_playlist_url(plid, index) + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + json.field "videoCount", self.video_count - response = client.get(url) - response = JSON.parse(response.body) - if !response["content_html"]? || response["content_html"].as_s.empty? - raise translate(locale, "Empty playlist") - end + json.field "viewCount", self.views + json.field "updated", self.updated.to_unix + json.field "isListed", self.privacy.public? - document = XML.parse_html(response["content_html"].as_s) - nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])) - videos = extract_playlist(plid, nodeset, index) - else - # Playlist has less than one page of videos, so subsequent pages will be empty - if page > 1 - videos = [] of PlaylistVideo - else - # Extract first page of videos - response = client.get("/playlist?list=#{plid}&gl=US&hl=en&disable_polymer=1") - document = XML.parse_html(response.body) - nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])) - - videos = extract_playlist(plid, nodeset, 0) - - if continuation - until videos[0].id == continuation - videos.shift + json.field "videos" do + json.array do + videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) + videos.each_with_index do |video, index| + video.to_json(locale, config, Kemal.config, json, offset + index) + end end end end end - return videos + def to_json(offset, locale, config, kemal_config, json : JSON::Builder? = nil, continuation : String? = nil) + if json + to_json(offset, locale, config, kemal_config, json, continuation: continuation) + else + JSON.build do |json| + to_json(offset, locale, config, kemal_config, json, continuation: continuation) + end + end + end + + property thumbnail_id + + module PlaylistPrivacyConverter + def self.from_rs(rs) + return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8)))) + end + end + + db_mapping({ + title: String, + id: String, + author: String, + description: {type: String, default: ""}, + video_count: Int32, + created: Time, + updated: Time, + privacy: {type: PlaylistPrivacy, default: PlaylistPrivacy::Private, converter: PlaylistPrivacyConverter}, + index: Array(Int64), + }) + + def thumbnail + @thumbnail_id ||= PG_DB.query_one?("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 1", self.id, self.index, as: String) || "-----------" + "/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).gsub("\n", "
") + end +end + +def create_playlist(db, title, privacy, user) + plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}" + + playlist = InvidiousPlaylist.new( + title: title.byte_slice(0, 150), + id: plid, + author: user.email, + description: "", # Max 5000 characters + video_count: 0, + created: Time.utc, + updated: Time.utc, + privacy: privacy, + index: [] of Int64, + ) + + playlist_array = playlist.to_a + args = arg_array(playlist_array) + + db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + + return playlist end def extract_playlist(plid, nodeset, index) @@ -144,7 +315,7 @@ def extract_playlist(plid, nodeset, index) length_seconds: length_seconds, published: Time.utc, plid: plid, - index: index + offset, + index: (index + offset).to_i64, live_now: live_now ) end @@ -200,6 +371,18 @@ def produce_playlist_url(id, index) return url end +def get_playlist(db, plid, locale, refresh = true, force_refresh = false) + if plid.starts_with? "IV" + if playlist = db.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + return playlist + else + raise "Playlist does not exist." + end + else + return fetch_playlist(plid, locale) + end +end + def fetch_playlist(plid, locale) client = make_client(YT_URL) @@ -261,6 +444,59 @@ def fetch_playlist(plid, locale) return playlist end +def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) + if playlist.is_a? InvidiousPlaylist + if !offset + index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", playlist.id, continuation, as: Int64) + offset = playlist.index.index(index) || 0 + end + + db.query_all("SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3", playlist.id, playlist.index, offset, as: PlaylistVideo) + else + fetch_playlist_videos(playlist.id, playlist.video_count, offset, locale, continuation) + end +end + +def fetch_playlist_videos(plid, video_count, offset = 0, locale = nil, continuation = nil) + client = make_client(YT_URL) + + if continuation + html = client.get("/watch?v=#{continuation}&list=#{plid}&gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999") + html = XML.parse_html(html.body) + + index = html.xpath_node(%q(//span[@id="playlist-current-index"])).try &.content.to_i?.try &.- 1 + offset = index || offset + end + + if video_count > 100 + url = produce_playlist_url(plid, offset) + + response = client.get(url) + response = JSON.parse(response.body) + if !response["content_html"]? || response["content_html"].as_s.empty? + raise translate(locale, "Empty playlist") + end + + document = XML.parse_html(response["content_html"].as_s) + nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])) + videos = extract_playlist(plid, nodeset, offset) + elsif offset > 100 + return [] of PlaylistVideo + else # Extract first page of videos + response = client.get("/playlist?list=#{plid}&gl=US&hl=en&disable_polymer=1") + document = XML.parse_html(response.body) + nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])) + + videos = extract_playlist(plid, nodeset, 0) + end + + until videos.empty? || videos[0].index == offset + videos.shift + end + + return videos +end + def template_playlist(playlist) html = <<-END_HTML

diff --git a/src/invidious/search.cr b/src/invidious/search.cr index e62d1310..3a31c5e7 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -431,3 +431,69 @@ def produce_channel_search_url(ucid, query, page) return url end + +def process_search_query(query, page, user, region) + if user + user = user.as(User) + view_name = "subscriptions_#{sha256(user.email)}" + end + + channel = nil + content_type = "all" + date = "" + duration = "" + features = [] of String + sort = "relevance" + subscriptions = nil + + operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) } + operators.each do |operator| + key, value = operator.downcase.split(":") + + case key + when "channel", "user" + channel = operator.split(":")[-1] + when "content_type", "type" + content_type = value + when "date" + date = value + when "duration" + duration = value + when "feature", "features" + features = value.split(",") + when "sort" + sort = value + when "subscriptions" + subscriptions = value == "true" + else + operators.delete(operator) + end + end + + search_query = (query.split(" ") - operators).join(" ") + + if channel + count, items = channel_search(search_query, page, channel) + elsif subscriptions + if view_name + items = PG_DB.query_all("SELECT id,title,published,updated,ucid,author,length_seconds FROM ( + SELECT *, + to_tsvector(#{view_name}.title) || + to_tsvector(#{view_name}.author) + as document + FROM #{view_name} + ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", search_query, (page - 1) * 20, as: ChannelVideo) + count = items.size + else + items = [] of ChannelVideo + count = 0 + end + else + search_params = produce_search_params(sort: sort, date: date, content_type: content_type, + duration: duration, features: features) + + count, items = search(search_query, page, search_params, region).as(Tuple) + end + + {search_query, count, items} +end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 6149ae7a..f2ebb66f 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -282,6 +282,49 @@ def subscribe_ajax(channel_id, action, env_headers) end end +# TODO: Playlist stub, sync with YouTube for Google accounts +# def playlist_ajax(video_ids, source_playlist_id, name, privacy, action, env_headers) +# headers = HTTP::Headers.new +# headers["Cookie"] = env_headers["Cookie"] +# +# client = make_client(YT_URL) +# html = client.get("/view_all_playlists?disable_polymer=1", headers) +# +# cookies = HTTP::Cookies.from_headers(headers) +# html.cookies.each do |cookie| +# if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name +# if cookies[cookie.name]? +# cookies[cookie.name] = cookie +# else +# cookies << cookie +# end +# end +# end +# headers = cookies.add_request_headers(headers) +# +# if match = html.body.match(/'XSRF_TOKEN': "(?[A-Za-z0-9\_\-\=]+)"/) +# session_token = match["session_token"] +# +# headers["content-type"] = "application/x-www-form-urlencoded" +# +# post_req = { +# video_ids: [] of String, +# source_playlist_id: "", +# n: name, +# p: privacy, +# session_token: session_token, +# } +# post_url = "/playlist_ajax?#{action}=1" +# +# response = client.post(post_url, headers, form: post_req) +# if response.status_code == 200 +# return JSON.parse(response.body)["result"]["playlistId"].as_s +# else +# return nil +# end +# end +# end + def get_subscription_feed(db, user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index a6e1aadb..1ae31257 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1274,6 +1274,20 @@ def itag_to_metadata?(itag : String) return VIDEO_FORMATS[itag]? end +def process_continuation(db, query, plid, id) + continuation = nil + if plid + if index = query["index"]?.try &.to_i? + continuation = index + else + continuation = id + end + continuation ||= 0 + end + + continuation +end + def process_video_params(query, preferences) annotations = query["iv_load_policy"]?.try &.to_i? autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr new file mode 100644 index 00000000..f1899faa --- /dev/null +++ b/src/invidious/views/add_playlist_items.ecr @@ -0,0 +1,56 @@ +<% content_for "header" do %> +<%= playlist.title %> - Invidious + +<% end %> + +
+
+
+
+
+ <%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %> + +
+ value="<%= HTML.escape(query) %>"<% else %>placeholder="<%= translate(locale, "Search for videos") %>"<% end %>> + +
+
+
+
+
+
+ + + + +
+ <% videos.each_slice(4) do |slice| %> + <% slice.each do |item| %> + <%= rendered "components/item" %> + <% end %> + <% end %> +
+ +<% if query %> +
+
+ <% if page > 1 %> + + <%= translate(locale, "Previous page") %> + + <% end %> +
+
+
+ <% if count >= 20 %> + + <%= translate(locale, "Next page") %> + + <% end %> +
+
+<% end %> diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index d78d8c4b..f7b9cce6 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -13,7 +13,7 @@

<%= translate(locale, "`x` subscribers", number_with_separator(item.subscriber_count)) %>

<% if !item.auto_generated %>

<%= translate(locale, "`x` videos", number_with_separator(item.video_count)) %>

<% end %>
<%= item.description_html %>
- <% when SearchPlaylist %> + <% when SearchPlaylist, InvidiousPlaylist %> <% if item.id.starts_with? "RD" %> <% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").full_path.split("/")[2]}" %> <% else %> @@ -56,6 +56,19 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
+ <% if plid = env.get?("remove_playlist_items") %> +
" method="post"> + "> +

+ + + +

+
+ <% end %> + <% if item.responds_to?(:live_now) && item.live_now %>

<%= translate(locale, "LIVE") %>

<% elsif item.length_seconds != 0 %> @@ -63,7 +76,7 @@ <% end %>
<% end %> -

<%= item.title %>

+

<%= item.title %>

@@ -103,6 +116,17 @@

+ <% elsif plid = env.get? "add_playlist_items" %> +
" method="post"> + "> +

+ + + +

+
<% end %> <% if item.responds_to?(:live_now) && item.live_now %> diff --git a/src/invidious/views/create_playlist.ecr b/src/invidious/views/create_playlist.ecr new file mode 100644 index 00000000..14f3673e --- /dev/null +++ b/src/invidious/views/create_playlist.ecr @@ -0,0 +1,39 @@ +<% content_for "header" do %> +<%= translate(locale, "Create playlist") %> - Invidious +<% end %> + +
+
+
+
+
+
+ <%= translate(locale, "Create playlist") %> + +
+ + "> +
+ +
+ + +
+ +
+ +
+ + +
+
+
+
+
+
diff --git a/src/invidious/views/delete_playlist.ecr b/src/invidious/views/delete_playlist.ecr new file mode 100644 index 00000000..480e36f4 --- /dev/null +++ b/src/invidious/views/delete_playlist.ecr @@ -0,0 +1,24 @@ +<% content_for "header" do %> +<%= translate(locale, "Delete playlist") %> - Invidious +<% end %> + +
+
+ <%= translate(locale, "Delete playlist `x`?", %|"#{HTML.escape(playlist.title)}"|) %> + +
+
+ +
+ +
+ + +
+
diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr new file mode 100644 index 00000000..bd8d6207 --- /dev/null +++ b/src/invidious/views/edit_playlist.ecr @@ -0,0 +1,81 @@ +<% content_for "header" do %> +<%= playlist.title %> - Invidious + +<% end %> + +
+
+
+

+ + <%= playlist.author %> | + <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | + <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | + "> + + +
+
+

+
+ +
+
+
+

+
+
+ +
+ +
+ +
+ +<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> +
+

+ +

+
+<% end %> + +
+
+
+ +
+ <% videos.each_slice(4) do |slice| %> + <% slice.each do |item| %> + <%= rendered "components/item" %> + <% end %> + <% end %> +
+ +
+
+ <% if page > 1 %> + + <%= translate(locale, "Previous page") %> + + <% end %> +
+
+
+ <% if videos.size == 100 %> + + <%= translate(locale, "Next page") %> + + <% end %> +
+
diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index 1a253026..6c06bf2e 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -29,6 +29,7 @@ + +<% end %> +
<% videos.each_slice(4) do |slice| %> <% slice.each do |item| %> diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 56334dd9..1183fba8 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -261,6 +261,10 @@ function update_value(element) { <%= translate(locale, "Manage tokens") %>
+ + diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/view_all_playlists.ecr new file mode 100644 index 00000000..ef9c85c0 --- /dev/null +++ b/src/invidious/views/view_all_playlists.ecr @@ -0,0 +1,22 @@ +<% content_for "header" do %> +<%= translate(locale, "Playlists") %> - Invidious +<% end %> + +
+
+

<%= translate(locale, "`x` playlists", %(#{items.size})) %>

+
+ +
+ +
+ <% items.each_slice(4) do |slice| %> + <% slice.each do |item| %> + <%= rendered "components/item" %> + <% end %> + <% end %> +
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 6e37f7a6..00a493af 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -29,6 +29,7 @@