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
@@ -755,12 +781,449 @@ get "/embed/:id" do |env|
rendered "embed"
end
-# Playlists
-
+# 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]?
-
- env.response.content_type = "application/json"
- plid = env.params.url["plid"]
-
- page = env.params.query["page"]?.try &.to_i?
- page ||= 1
-
- format = env.params.query["format"]?
- format ||= "json"
-
- continuation = env.params.query["continuation"]?
+{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route|
+ get route do |env|
+ locale = LOCALES[env.get("preferences").as(Preferences).locale]?
- if plid.starts_with? "RD"
- next env.redirect "/api/v1/mixes/#{plid}"
- end
+ env.response.content_type = "application/json"
+ plid = env.params.url["plid"]
- 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
+ offset = env.params.query["index"]?.try &.to_i?
+ offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 }
+ offset ||= 0
- begin
- videos = fetch_playlist_videos(plid, page, playlist.video_count, continuation, locale)
- rescue ex
- videos = [] of PlaylistVideo
- end
+ continuation = env.params.query["continuation"]?
- 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
+ format = env.params.query["format"]?
+ format ||= "json"
- json.field "author", playlist.author
- json.field "authorId", playlist.ucid
- json.field "authorUrl", "/channel/#{playlist.ucid}"
+ if plid.starts_with? "RD"
+ next env.redirect "/api/v1/mixes/#{plid}"
+ end
- json.field "authorThumbnails" do
- json.array do
- qualities = {32, 48, 76, 100, 176, 512}
+ 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
- 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
+ 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
- json.field "description", html_to_content(playlist.description_html)
- json.field "descriptionHtml", playlist.description_html
- json.field "videoCount", playlist.video_count
+ response = playlist.to_json(offset, locale, config, Kemal.config, continuation: continuation)
- json.field "viewCount", playlist.views
- json.field "updated", playlist.updated.to_unix
+ 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"]
- json.field "videos" do
- json.array do
- videos.each do |video|
- video.to_json(locale, config, Kemal.config, json)
- end
- end
- end
+ response = {
+ "playlistHtml" => playlist_html,
+ "index" => index,
+ "nextVideo" => next_video,
+ }.to_json
end
- 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
+ response
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
+ 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 "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, offset + index)
+ end
+ end
+ end
end
- index ||= 0
- else
- index = (page - 1) * 100
end
- if video_count > 100
- url = produce_playlist_url(plid, index)
+ 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
- response = client.get(url)
- response = JSON.parse(response.body)
- if !response["content_html"]? || response["content_html"].as_s.empty?
- raise translate(locale, "Empty playlist")
+ property thumbnail_id
+
+ module PlaylistPrivacyConverter
+ def self.from_rs(rs)
+ return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8))))
end
+ 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, 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")]))
+ 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),
+ })
- videos = extract_playlist(plid, nodeset, 0)
+ 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
- if continuation
- until videos[0].id == continuation
- videos.shift
- end
- end
- end
+ def author_thumbnail
+ nil
end
- return videos
+ 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
<%= 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 %><%= translate(locale, "LIVE") %>
<% elsif item.length_seconds != 0 %> @@ -63,7 +76,7 @@ <% end %><%= item.title %>
+@@ -103,6 +116,17 @@
+ <% elsif plid = env.get? "add_playlist_items" %> + <% 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 %> +